1. Overview

After having introduced the Tell Pattern, it’s time to make an overview of some interaction that is more frequent in real-world scenarios: the request-response pattern.

2. Akka Dependencies

As usual, to use the Akka Typed library, we need to import akka-actor-typed, and we’ll need akka-actor-testkit-typed for testing:

libraryDependencies += "com.typesafe.akka" % "akka-actor-typed_2.12" % "2.6.8",
libraryDependencies += "com.typesafe.akka" % "akka-actor-testkit-typed_2.12" % "2.6.8" % Test

3. Scenario

To talk about the request-response pattern, we first need an example that fits our needs. So, let’s define an actor that responds to messages it processes.

Let’s imagine that we want a service that encodes incoming strings to Base64 strings. Then, the clients of such a service can send plain strings and expect the service to return their Base64 representations. First of all, we define the communication protocol:

sealed trait Request
final case class ToEncode(payload: String, replyTo: ActorRef[Encoded]) extends Request
sealed trait Response
final case class Encoded(payload: String) extends Response

Any encoding request uses the ToEncode message, containing the string to encode. After that, the service returns the encoded string using an instance of the Encoded message.

4. Request-Response: the Easy Way

If we want actors to implement the request-response pattern, we need ActorRefs. An ActorRef[-T] refers to an actor that can handle messages of type T. The called actor must use an ActorRef of the caller to respond with the requested information.

In our example, we pass ActorRef[Encoded] among actors inside the To**Encode message. Indeed, it has an attribute replyTo of type ActorRef[Encoded].

So, with this information, let’s implement the Base64Encoder actor:

object Base64Encoder {
  def apply(): Behavior[Encode] =
    Behaviors.receiveMessage {
      case ToEncode(payload, replyTo) =>
        val encodedPayload = Base64.getEncoder.encode(payload.getBytes(StandardCharsets.UTF_8))
        replyTo ! Encoded(encodedPayload.toString)
        Behaviors.same
  }
}

In this example, the Base64Encoder actor receives a request for encoding. The message of type To**Encode contains both the text to encode and the reference to the caller actor. Once the payload is successfully encoded, the actor can use the tell operator to respond to the caller:

replyTo ! ToEncoded(encodedPayload.toString)

Let’s see one possible implementation of the caller actor:

object NaiveEncoderClient {
  def apply(encoder: ActorRef[Request]): Behavior[Encoded] =
    Behaviors.setup { context =>
      encoder ! ToEncode("The answer is 42", context.self)
      Behaviors.receiveMessage {
        case Encoded(payload) => context.log.info(s"The encoded payload is $payload")
          Behaviors.empty
      }
    }
}

As we can see, the NaiveEncoderClient uses the context to retrieve an ActorRef of itself:

context.self

After having sent the message, the actor starts to wait for the response containing the encoded message.

The above interaction is the easiest we can have that involves a response. However, it is very unlikely that we can use it in a real-world scenario. Indeed, the caller actor, NaiveEncoderClient, must use the response protocol defined by the actor Base64Encoder – so that it can’t listen to different kinds of messages.

What if the NaiveEncoderClient wants to listen to another type of message? More often than not, the caller actor will have its own protocol. How can we solve this problem? Let’s introduce the adapted response pattern.

5. Request-Response: the Adapted Response Pattern

We must use the adapted response pattern every time we want the caller actor to have its own protocol. Let’s define a new communication protocol:

sealed trait Command
final case class KeepASecret(secret: String) extends Command

The message KeepASecret asks an actor to keep a secret string. Our actor, EncoderClient, wants to keep the secret in a Base64 format. Hence, we need a strategy that allows the actor to listen to both messages of type KeepASecret and Encoded.

Fortunately, the Akka Typed library gives us a mechanism to define a message adapter, which means to adapt an Encoded message to the trait Command.

First of all, we’ll define a private message adapter:

private final case class WrappedEncoderResponse(response: Encoded) extends Command

So, when the actor receives the encoded message, it translates it into a WrappedEncoderResponse. The resulting structure is indeed an actor called a message mapper that handles messages of Encoded type:

val encoderResponseMapper: ActorRef[Encoded] =
  context.messageAdapter(response => WrappedEncoderResponse(response))

Finally, the reference to the mapper must be passed together with the Encoded message. The mapper will forward the response to our actor, wrapping the original message using the function set up during the mapper declaration:

object EncoderClient {
  def apply(encoder: ActorRef[Request]): Behavior[Command] =
    Behaviors.setup { context =>
      val encoderResponseMapper: ActorRef[Encoded] =
        context.messageAdapter(response => WrappedEncoderResponse(response))
      Behaviors.receiveMessage {
        case KeepASecret(secret) =>
          encoder ! ToEncode(secret, encoderResponseMapper)
          Behaviors.same
        case WrappedEncoderResponse(response) =>
          context.log.info(s"I will keep a secret for you: ${response.payload}")
          Behaviors.same
      }
    }
}

We can register many different message adapters, but it’s only possible to have a single message adapter for each type of incoming message. That also means that a newly registered adapter will replace an existing adapter for the same message class.

The use of the adapted response pattern allows us to interact with different actors using many other protocols. However, some problems still remain. How can we associate a response with its request? If we don’t want to implement a home-made correlation id, we need to use a different strategy. Let’s see which one.

6. Request-Response: the Ask Pattern

The Ask Pattern allows us to implement the interactions that need to associate a request to precisely one response. So, it’s different from the more straightforward adapted response pattern because we can now associate a response with its request.

6.1. The API Gateway Scenario

Let’s make our example more difficult to better understand the pattern. We’ll implement API Gateway, which is a service that hides the APIs of a complex system from clients. API Gateways offer a more accessible and more consistent way to interact with systems like microservices architectures. In our case, we’re going to develop a gateway for the encoder service.

First of all, we need to define the protocol of our new APIGateway actor:

sealed trait Command
final case class PleaseEncode(payload: String, replyTo: ActorRef[GentlyEncoded]) extends Command
final case class GentlyEncoded(encodedPayload: String)

As we can see, the protocol defines a request and a response, and in the former, the caller gives a reference to itself to facilitate the forwarding of the encoded payload. Hence, it’s important that requests and responses are perfectly tied together.

As we have done for the adapted response pattern, we need to adapt the Base64Encoder actor’s protocol to the protocol of the APIGateway actor. So, let’s define the adapters. This time, we will also define an adapter to use in case of errors during the communication (we will see why in a moment):

private case class AdaptedResponse(payload: String, replyTo: ActorRef[GentlyEncoded]) extends Command
private case class AdaptedErrorResponse(error: String) extends Command

As you may notice, the AdaptedResponse message includes the reference to the API Gateway’s external caller. Such reference comes to the gateway inside the PleaseEncode message. Let’s see how to tie them together.

6.2. How to Use the Ask Pattern

We are going to use the ask method offered by the context object:

implicit val timeout: Timeout = 5.seconds
Behaviors.receiveMessage {
  case PleaseEncode(payload, replyTo) =>
    context.ask(encoder, ref => ToEncode(payload, ref)) {
      case Success(Encoded(encodedPayload)) => AdaptedResponse(encodedPayload, replyTo)
      case Failure(exception) => AdaptedErrorResponse(exception.getMessage)
    }
    Behaviors.same

The ask method takes two parameters. The first is the ActorRef to send the request. The second is a function that, given the reference to the calling actor itself, produces the message to send. Moreover, the invocation to the ask method makes a Future[Encoded]. Futures are data structures that represent some value that will be available in the future. A Future can complete successfully, or with a failure containing the raised exception.

Hence, the use of Futures allows us to bind a specific request to its response. The function mapping the Future to a message is executed in the same context of the request. So, it is possible to safely use the replyTo information stored in the PleaseEncode message.

It is not the right choice to wait forever for the resolution of a Future. Therefore, we define a timeout in the context of the actor’s behavior.

Finally, we just have to define our APIGateway actor’s behavior in response to the messages of type AdaptedResponse and AdaptedErrorResponse:

Behaviors.receiveMessage {
  // ...
  case AdaptedResponse(encoded, ref) =>
    ref ! GentlyEncoded(encoded)
    Behaviors.same
  case AdaptedErrorResponse(error) =>
    context.log.error(s"There was an error during encoding: $error")
    Behaviors.same
}

7. Conclusion

In the article, we reviewed some of the available patterns to develop a request-response interaction between Akka typed actors. It’s important to note that this is not the full story, and there are more advanced patterns that implement even more complex kinds of interactions, such as the per-session child Actor or the general-purpose response aggregator.

As always, the full code examples used in this tutorial are available over on GitHub.


« 上一篇: Scala中的Option类型