Akka: Actors Design And Communication Techniques
I’m working with akka within last ~3 years. I can’t say that my knowledge is brilliant. There are still many things which I learn about akka while doing daily programming. Despite this fact, I decided to share my view on actors design and communication techniques. Exactly this talk I made at Scala UA Conference 2018.
In this blog post you have 2 ways to get the content: video (54 minutes) or brief summary in a text format. So feel free to choose those one which works better for you.
Video: Actors Design And Communication Techniques
Text version
Let’s start from a pretty common definition. Akka is a toolkit which implements an actor model. It fits really well for implementation of distributed and reactive systems. Main characteristics of akka are: elastic & decentralized, resilient by design, high performance, reactive streaming data. All these features have a good explanation on official akka site.
Further I want to continue with more conceptual things.
Actor in the middle.
Actor is a main entity within any actor system. Roughly speaking it represents a single-tread environment for encapsulation of business logic and state. So any Actor can do some job and store some data.
How to understand a “single-tread” environment? Well, every actor has its own mailbox, it’s implemented like a queue. An actor can process only one message at once. That’s why it’s ok to use actor model for building of reactive systems.
Stop, but if an actor has its mailbox we need to send something in it. Correct! The only way to communicate between actors is to send messages. This is another one pretty important feature – sending and receiving of messages. In some sense we can say that every actor has its own set of messages which it can handle. That’s why a set of messages defined for an actor frequently called a communication protocol.
That’s not all what you need to know about actors. Any actor is able to create child actors. This is crucial feature, because if you want to perform some “expensive” action from time consuming point of view, it makes sense to split it and send to child actors for parallel execution.
As a consequence of the last statement, every parent actor can define its own supervision strategy for its child actors. This means that if any of child actors fails, you are able to decide on a top level, what to do with this failure. For example you may want to continue processing of next messages, or maybe it makes sense to fail a computation at all due to a mistake in one of its stages.
Messages as a communication protocol.
Let’s start from a good advice: put all messages in a companion object of an actor. By doing this, you make a significant input in a code maintenance. The next good idea is to name incoming messages as commands (intentions) and name outgoing messages as completed actions (facts).
All communication between actors happens with help of messages. A massage can be represented as Any
. This circumstance brings us one serious inconvenience – we loose type safety. In order to avoid it, its ok to define well typed communication protocols for each actor.
case class UserData(email: String, password: String) object UserCreationActor { trait UCMsg case class CreateUser(user: UserData) extends UCMsg case class UserCreated(id: String) extends UCMsg trait UCError extends UCMsg case class EmailInUse(email: String) extends UCError case class DBError(message: String) extends UCError }
Definitely the example above is not brilliant from the “strong type system” point of view, but in general the idea should be clear.
Akka Typed module is in active development. Its main purpose to make actors really type safe. So in the nearest future it would be possible to use it in a production without fear that API will change significantly.Messages either initiate some computations (work) or notify about result. Let’s talk about the work. Sometimes a work is something really small, sometimes it is something what requires more time and resources. From this perspective it makes sense to split big tasks on smaller one, in order to increase an actor throughput. Because as you remember, an actor is able to process just one message per time.
So instead of this:
class UserCreationActor(db: DataBase) extends Actor { override def receive = { case CreateUser(user) => val senderActor = sender() db.checkEmail(user.email) map { case None => db.insert(user) map { case Success(id) => senderActor ! UserCreated(id) case Failure(ex) => senderActor ! DBError(ex.getMessage) } case Some(u) => senderActor ! EmailInUse(user.email) } } }
It makes sense to use something like this:
override def receive = { case CheckEmail(user) => val senderActor = sender() db.checkEmail(user.email) map { case None => self tell(CreateUser(user), senderActor) case Some(u) => senderActor ! EmailInUse(u.email) } case CreateUser(user) => val senderActor = sender() db.insert(user) map { case Success(id) => senderActor ! UserCreated(id) case Failure(ex) => senderActor ! DBError(ex.getMessage) } //case … }
Streams are the real power
Finally it makes sense to delegate all routine to akka streams. Since complex computations consist of smaller one, it is natural to declare each of them as a separate stage. Then you are able to compose these stages in a sequence. This sequence can be interpreted as an instruction which need to be executed in order to complete the computation.
Let’s see how it can be implemented:
//messages case class CreateUser(email: String) sealed trait CUResult sealed trait CUError extends CUResult { val email: String } case class UserCreated(email: String) extends CUResult case class EmailIsAlreadyInUse(email: String) extends CUError case class NoUserInExternalDB(email: String) extends CUError case class UserDBError(email: String) extends CUError //stages val checkUserEmailFlow = Flow[CreateUser] .map { createUser => if (userDB.contains(User(createUser.email))) Left(EmailIsAlreadyInUse(createUser.email)) else Right(createUser) } val check3rdPartyServiceFlow = Flow[Either[CreateUserError, CreateUser]].map { case left: Left(EmailIsAlreadyInUse(email)) => left case right @ Right(CreateUser(email)) => if (externalService.contains(User(email))) right else Left(NoUserInExternalDB(email)) } val saveUserFlow = Flow[Either[CreateUserError, CreateUser]].map { case left: Left(EmailIsAlreadyInUse(email)) => left case left: Left(NoUserInExternalDB(email)) => left case Right(CreateUser(email)) => addUser(User(email)) } //executable graph val runnableGraph = Source .single(CreateUser("test@gmail.com")) .via(checkUserEmailFlow) .via(check3rdPartyServiceFlow) .via(saveUserFlow) .toMat(Sink.head)(Keep.right)
Streams use actors under the hood. So you do not need to care about manual actors creation and management. Just describe a logic you want to implement. Moreover, akka streams know how to deal with back-pressure (I even don’t want to write more about this, because there are so many words told about it).
Summary
In general I explained in the text version the main idea of my talk. But there are several things which I omitted, so if you want to listen about handling of errors in akka apps and handling of timeouts, I recommend to watch the video.