Nov 24, 2018

[Golang] protoactor-go 201: How middleware works to intercept incoming and outgoing messages

As described in a previous article, Protoactor-go 101: How actors communicate with each other, the core of actor system is message passing. Fine-grained actors work on their own tasks, communicate with each other by passing messages and achieve a bigger task as a whole. To intercept the incoming and outgoing messages to execute tasks before and after the message handling, protoactor supports middleware mechanism.
Protoactor’s plugin mechanism is built on top of this middleware mechanism so knowing middleware is vital to building highly customized actor.

Types of middleware

To intercept incoming and outgoing messages, two kinds of middleware are provided: actor.InboundMiddleware and actor.OutboundMiddleware. Inbound middleware is invoked when a message reaches an actor; Outbound middleware is invoked when a message is sent to another actor. Multiple middlewares can be registered for a given actor so it is possible to divide middleware’s tasks into small pieces and create a middleware for each of them in favor of maintainability.

Under the hood

To register a middleware to an actor, use Props.WithMiddleware() or Props.WithOutboundMiddleware(). Passed middleware implementations are appended to an internal slice so they can be referenced on actor construction.

// Assign one or more middlewares to the props
func (props *Props) WithMiddleware(middleware ...InboundMiddleware) *Props {
   props.inboundMiddleware = append(props.inboundMiddleware, middleware...)
   return props
}
  
func (props *Props) WithOutboundMiddleware(middleware ...OutboundMiddleware) *Props {
   props.outboundMiddleware = append(props.outboundMiddleware, middleware...)
   return props
}

On actor construction, stashed middlewares are transformed into a middleware chain. At this point a group of one or more middlewares are combined together and shape one actor.ActorFunc(). Middlewares are processed in reversed order in this process so they are executed in the registered order on message reception.

func makeInboundMiddlewareChain(middleware []InboundMiddleware, lastReceiver ActorFunc) ActorFunc {  
   if len(middleware) == 0 {  
      return nil  
   }  
  
   h := middleware[len(middleware)-1](lastReceiver)  
   for i := len(middleware) - 2; i >= 0; i-- {  
      h = middleware[i](h)  
   }  
   return h  
}  
  
func makeOutboundMiddlewareChain(outboundMiddleware []OutboundMiddleware, lastSender SenderFunc) SenderFunc {  
   if len(outboundMiddleware) == 0 {  
      return nil  
   }  
  
   h := outboundMiddleware[len(outboundMiddleware)-1](lastSender)  
   for i := len(outboundMiddleware) - 2; i >= 0; i-- {  
      h = outboundMiddleware[i](h)  
   }  
   return h  
}

When actor.Context handles an incoming message, the actor.Context that holds all the contextual information including message itself is passed to that middleware chain. One important thing to notice at this point is that the original message reception method, actor.Receive(), is wrapped in an anonymous function to match actor.ActorFunc() signature and is registered to the very end of the middleware chain. So when the context information is passed to the middleware chain, middlewares are executed in the registered order and actor.Receive() is called at last.

func (ctx *localContext) processMessage(m interface{}) {  
   ctx.message = m  
  
   if ctx.inboundMiddleware != nil {  
      ctx.inboundMiddleware(ctx)  
   } else {  
      if _, ok := m.(*PoisonPill); ok {  
         ctx.self.Stop()  
      } else {  
         ctx.receive(ctx)  
      }  
   }  
  
   ctx.message = nil  
}

Likewise, when a message is being sent to another actor, the registered outbound middlewares are executed in the registered order.

Example

Below is an example that leaves log messages around Actor.Receive() invocation and Context.Request().

Below is an example that leaves log messages when message a comes and goes. As the comment suggests, inbound middleware can run its task before and/or after actor.Receive() execution. Similarly, outbound middleware can run a task before and/or after the original message sending logic. The event occurrence order is described in the comment section of the example.

Conclusion

Middleware mechanism can be used to run a certain logic before and after the original method invocation in an AOP-ish manner.