Akka: Change an actor's behavior using context.become

Earlier, I've looked at the basics of Akka actors by creating a simple chatroom. Today I'm going to continue by looking at how we could change an actor's behavior using context.become by introducing the concept of a closed and open chatroom.

Let's start by introducing a simplified version of the chatroom.

Server actor

class Server extends Actor {

    var clients = List[(String, ActorRef)]()

    def receive = {
      case Connect(username) => {
          broadcast(Info(f"$username%s joined the chat"))
          clients = (username,sender) :: clients
        }
      case Broadcast(msg) => {
          val username = clients.filter(sender == _._2).head._1
          broadcast(NewMsg(username, msg))
      }
    }

    def broadcast(msg: Msg) {
        clients.foreach(_._2 ! msg)
    }
 }

In this version of the server, we've left out everything except the possibility of connecting and broadcasting a message from a client to the other clients in the chatroom.

The focus of this post will be on the server actor, but first let's take a quick look at the client as well.

Client actor

class Client(val username: String, server: ActorRef) extends Actor {

  server ! Connect(username)

  def receive = {
    case NewMsg(from, msg) => {
      println(f"[$username%s's client] - $from%s: $msg%s")
    }
    case Send(msg) => server ! Broadcast(msg)
    case Info(msg) => {
      println(f"[$username%s's client] - $msg%s")
    }
    case Disconnect(msg) => {
      println(f"[$username%s's client] - $msg%s")
      self ! PoisonPill
    }
  } 
}

Introducing the closed chatroom behavior

So far we've seen the behavior of an open chatroom. Now, let's add the behavior of a closed chatroom to the server actor.

class Server extends Actor {

    var clients = List[(String, ActorRef)]()

    def receive = open

    def open: Receive = {
      case Connect(username) => {
          broadcast(Info(f"$username%s joined the chat"))
          clients = (username,sender) :: clients
        }
      case Broadcast(msg) => {
          val username = clients.filter(sender == _._2).head._1
          broadcast(NewMsg(username, msg))
      }
      case Close => {
        broadcast(Disconnect("Chatroom is now closed"))
        context.become(closed)
      }
    }

    def closed: Receive = {
      case Open => {
        clients = List[(String, ActorRef)]()
        context.become(open)
      }
      case _ => sender ! Info("Chatroom is closed")
    }

    def broadcast(msg: Msg) {
        clients.foreach(_._2 ! msg)
    }
 }

The first thing to notice is that we extracted the original behavior into a function called open.

Next thing to notice is that we also introduced a function named closed. This function holds the behavior of a closed chatroom.

When creating a new server actor, the receive method will set open as the initial behavior.

Now, if we receive a Close message, we'll change to the closed behavior by using context.become with the closed function as an argument.

Now the messages received by the actor will be treated by the closed function.

If we receive an Open message, we'll change back to the open behavior using the same approach.

Great! However, there are still some improvements to be done.

Removing the explicit variable

Having an explicit variable holding the clients isn't a very nice solution.

First of all the clients belong to the open behavior. By having it stored in a variable it could be reached anywhere in the actor.

Second, it's not intuitive for the closed behavior to make sure the client list is cleaned up before changing back to the open behavior.

Luckily, by introducing context.become, we can handle this in a better way.

class Server extends Actor {

    def receive = open(List[(String, ActorRef)]())

    def open(clients: List[(String, ActorRef)]): Receive = {
      case Connect(username) => {
          broadcast(Info(f"$username%s joined the chat"), clients)
          context.become(open((username,sender) :: clients))
      }
      case Broadcast(msg) => {
          val username = clients.filter(sender == _._2).head._1
          broadcast(NewMsg(username, msg), clients)
      }
      case Close => {
        broadcast(Disconnect("Chatroom is now closed"), clients)
        context.become(closed)
      }
    }

    def closed: Receive = {
      case Open => context.become(open(List[(String, ActorRef)]()))
      case _ => sender ! Disconnect("Chatroom is closed")
    }

    def broadcast(msg: Msg, clients: List[(String, ActorRef)]) {
        clients.foreach(_._2 ! msg)
    }
 }

By changing the open function to hold the clients as a parameter, we're able to remove the variable, leaving us with the clients where they belong.

When adding another client, we simply change our behavior to open with an updated client list as an argument.

Doing this also makes it easy for the closed behavior to understand that it needs to send an empty list of clients when changing the behavior back to open.

Let's take our chatroom for a spin.

As with the last post on akka, I'll use sbt console to interact with the actors.

First, let's start our chatroom and get a client connected.

$ sbt console
> import scamsg._
> import akka.actor._

> val system = ActorSystem("System")

> val server = system.actorOf(Props[Server])

> val c1 = system.actorOf(Props(new Client("Sam", server)))

> c1 ! Send("Hi, anyone here?")
[Sam's client] - Sam: Hi, anyone here?

Ok, now that we have tested the open behavior a bit, let's close the chatroom.

> server ! Close
[Sam's client] - Chatroom is now closed

As expected, Sam's client got shutdown.

All attempts to connect should now be rejected.

> system.actorOf(Props(new Client("Mia", server)))
[Mia's client] - Chatroom is closed

Now, if we open the chatroom again, the clients should be able to connect again.

> server ! Open

> val c2 = system.actorOf(Props(new Client("Mia", server)))

> c2 ! Send("Finally in!")
[Mia's client] - Mia: Finally in!

Enjoyed the post?

If you don't want to miss future posts, make sure to subscribe