Extensible and configurable WebSocket messages

WebSockets provide a flexible bi-directional way to communicate between the web browser and the backend server. In particular, it allows the server to send push messages to the client in order to inform it about data updates and the like.

However, the Java API only allows for static Object encoders and decoders, i.e. they cannot use dependency injection. We will show here to circumvent this problem and provide extensible message encoding using JSON.

When configuring a WebSocket endpoint it is possible to define various encoders/decoders. When a message is sent/arrives they will be executed in order until one gives a valid result.

@ServerEndpoint(value = "/ws/{username}",
  encoders = [WSMessageEncoder::class], decoders = [WSMessageDecoder::class])
class WSServerEndpoint {
  ...
}

We found however that is best practice to define just one generic basic message type, like this:

interface WSMessage {
  val messageType: String
  val message: String
}

and then define several subclasses/implementations:

data class WSSynchronizationMessage
@ConstructorProperties("messageType", "message", "data")
constructor(
  override val messageType: String = "SYNCH_COMPLETED_MESSAGE",
  override val message: String = "Synchronisation complete",
  val data: String
) : WSMessage

data class WSUpdateMessage
@ConstructorProperties("messageType", "message", "otherData")
constructor(
  override val messageType: String = "UPDATE_MESSAGE",
  override val message: String = "Task updated",
  val otherData: Int
) : WSMessage
...

BTW: The @ConstructorProperties annotation allows the JSON serializer to directly invoke the constructor, instead of calling several setters.

Message encoding

In our systems we use a lot of dependency injection, e.g. to provide a fully configured JSON “object mapper“, logger, or anything else that should not be instantiated statically.
This does not work for message encoders, since they will be created ad hoc by the WebSocket framework using an empty constructor. Instead we found it most feasible to retrieve dependencies from the user properties of the endpoint configuration.

class WSMessageEncoder: Text<WSMessage> {
  private lateinit var config: EndpointConfig

  override fun encode(data: WSMessage): String {
    val objectMapper = config.userProperties[OBJECT_MAPPER_KEY]
       as ObjectMapper

    try {
      return objectMapper.writeValueAsString(data)
    } catch (e: JsonProcessingException) {
      throw EncodeException(data, "Encoding failed", e)
    }
  }

  override fun init(config: EndpointConfig) {
    this.config = config
  }
}

Note that we cannot obtain values from the user properties within the init() method since they have not been initialized at that time of the endpoint’s life cycle.

Message decoding

The message decoder needs to be configured with information about the possible message types and how to map them to their corresponding Java classes:

class WSMessageDecoder(private val messageTypes: Map<String, Class<out WSMessage>>) : Text<WSMessage> {
  ...
  override fun decode(message: String): WSMessage {
    val objectMapper = config.userProperties[OBJECT_MAPPER_KEY] 
       as ObjectMapper

    return try {
      val (messageType, _) = objectMapper.readValue(message, 
          PlainWSMessage::class.java)
      val wsMessageClass = messageTypes[messageType]

      objectMapper.readValue(message, wsMessageClass) as WSMessage
    } catch (e: Exception) {
      throw DecodeException(message, "Decoding failed", e)
    }
  }
  ...

  override fun willDecode(s: String) = 
     s.containsAnyOf(messageTypes.keys)
}

We read the message type first and then use it to determine the matching Java class.

Note that the willDecode() method performs a quick check in order to ensure that one of the message types will finally match.

Configuration

The pre-configured object mapper or any other dependent object may be “injected” into the user properties whenever a client creates a new connection:

...

@Inject
private lateinit var objectMapper: ObjectMapper

@OnOpen
fun onOpen(session: Session, endpointConfig: EndpointConfig,
           @PathParam("username") username: String) {
  endpointConfig.userProperties[OBJECT_MAPPER_KEY] = objectMapper
  ...    
}

Leave a Reply