Push for Liferay

Liferay 7 supports Websockets; see how you can implement your own Push support.

Introduction

I was recently polling my coworkers looking for new blog ideas, and Filipe Melo asked about Websockets. I haven't seen any other documentation or blogs about building Websocket solutions for Liferay, so I figured this would be an interesting challenge.

Websockets was introduced to provide a full duplex and realtime client/server communication path between remote clients and your server-side framework. JSR 356 was introduced to add Websocket support to Java applications, and Liferay added support for Websockets in 7.0.

Most of the time when you happen upon an article about Websockets, they present an implementation of a chat room, and we'll be implementing a similar thing here. All users will be in the chat room, anyone can send a message, and the messages will be broadcast to everyone. Unlike other examples though, our server is going to run in Liferay and will be cluster-aware and our client will be a simple portlet that can be added to a portal page.

Yeah I know, it's a lame example, but it is just an example. The model that I'm presenting here can be used for anything you have a need for.

The Websocket Whiteboard

Liferay 7 introduced an HTTP Whiteboard, and it allows OSGi modules to create servlets that live under the /o/my-servlet sort of path.

Liferay also introduced a Websocket Whiteboard to provide similar capabilities for OSGi modules to define new Websocket endpoints that will be available under the /o/websocket path.

The Websocket Whiteboard only supports the Websocket extension techniques, not the annotation-based technique specified in JSR 356.

To create our server-side endpoint, we need to extend the javax.websocket.Endpoint class, and since we're using OSGi we'll also need to add the @Component annotation:

@Component(
        immediate = true,
        property = {
                "org.osgi.http.websocket.endpoint.path=/o/websocket/chat/{username}",
                "org.osgi.http.websocket.endpoint.decoders=" 
                   + "com.dnebinger.websockets.chat.ChatMessageDecoder",
                "org.osgi.http.websocket.endpoint.encoders=" 
                   + "com.dnebinger.websockets.chat.ChatMessageEncoder"
        },
        service = Endpoint.class
)
public class ChatEndpoint extends Endpoint {
    /**
     * onOpen: We must implement this method to be a real endpoint.
     *
     * @param session the session that has just been activated.
     * @param config  the configuration used to configure this endpoint.
     */
    @Override
    public void onOpen(final Session session, EndpointConfig config) {
    }
}

This is our definition for the ChatEndpoint class. It extends Endpoint, and it is also registered as an @Component.

The properties set the main configuration for the endpoint.

Property Description
org.osgi.http.websocket.endpoint.path Defines the path for the endpoint to match on. Paths must be unique in the Websocket Whiteboard. In this case we are registering under /o/websocket/chat, and no other Websocket can handle this same path. It also supports named path parameters. I recommend paths using the /o/websocket prefix to avoid conflict with other portal or portlet URLs.
org.osgi.http.websocket.endpoint.decoders A comma-separated list of class names that implement the Decoder interface to transform incoming raw message data into Java objects.
org.osgi.http.websocket.endpoint.encoders Comma-separated list of class names that implement the Encoder interface to transform Java objects into raw types that can be sent as a Websocket response.
org.osgi.http.websocket.endpoint.subprotocol An optional comma-separated list of supported subprotocols.
So actually I encountered a bug trying to list the decoder and encoder classes and I've opened a support ticket on them. In the mean time, I suggest not trying to leverage the encoder/decoder logic that I'm going to blog about here as though it is working just fine.

Receiving Messages

in the onOpen() method of our Endpoint implementation, we can register a new message handler:

public void onOpen(final Session session, EndpointConfig config) {
  final String username = session.getPathParameters().get("username");

  // add a message handler for new chat messages
  session.addMessageHandler(new MessageHandler.Whole<ChatMessage>() {
    /**
     * onMessage: Called when the message has been fully received.
     *
     * @param message the message data.
     */
    @Override
    public void onMessage(ChatMessage message) {
      message.setFrom(username);

      // send to all active sessions on the current node
      broadcast(message);

      // broadcast to the cluster so they also will broadcast to 
      // their websocket sessions
      chatMessageClusterSender.sendChatMessage(
        message.getFrom(), message.getMessage());
    }
  });

  session.addMessageHandler(new MessageHandler.Whole<String>() {
    @Override
    public void onMessage(String text) {
      JSONObject json = JSONFactoryUtil.createJSONObject(text);
      ChatMessage message = new ChatMessage(username, json.getString("message"));
      broadcast(message);
      chatMessageClusterSender.sendChatMessage(username, message.getMessage());
    }
  });
}

We can access the defined path parameters from the Session's getPathParameters() method and extract the one we want.

We then add a new MessageHandler implementation for a whole ChatMessage object (this will be created from the Decoders property). You can add multiple new MessageHandler implementations for different types of objects or even for the raw types as we did here by adding a MessageHandler using a raw String type (with the bug I spoke of above, the only MessageHandler I could register was this String method).

In the implementation above, we're using a static public method broadcast() (not shown) to send the message to all of the active websocket sessions on this node.

And, to be cluster-friendly, we're also going to use the cluster-wide messaging technique to get the chat message to all of the other nodes, and they too will invoke the broadcast() method to broadcast the message to the active websocket sessions they have registered.

We need to do this because, in a cluster, a client will establish a websocket session with only one node in the cluster. This means that each node in your cluster can have one or more active sessions on their own that the other nodes will not know about.

Leveraging Liferay's built-in cluster-wide messaging we can send the chat message to all of the other nodes in the cluster, and they can be responsible for sending the chat message to the active sessions they each are tracking.

Test Portlet

I created a super-simple portlet to just demonstrate how to create a websocket client that interacted with our new Chat server-side component. Here's the view.jsp:

<%@ include file="/init.jsp" %>

<form>
  <input id="chatMessageText" type="text">
  <input onclick="wsSendMessage();" value="Chat" type="button">
  <input onclick="wsCloseConnection();" value="Disconnect" type="button">
</form>
<br>
<textarea id="echoText" rows="5" cols="30"></textarea>
<script type="text/javascript">
  var chatUrl = "ws://localhost:8080/o/websocket/chat/<%= user.getScreenName() %>";
  var webSocket = new WebSocket(chatUrl);
  var echoText = document.getElementById("echoText");

  echoText.value = "";

  var message = document.getElementById("chatMessageText");

  webSocket.onopen = function(message){ wsOpen(message);};
  webSocket.onmessage = function(message){ wsGetMessage(message);};
  webSocket.onclose = function(message){ wsClose(message);};
  webSocket.onerror = function(message){ wsError(message);};

  function wsOpen(message){
    echoText.value += "Connected ... \n";
  }
  function wsSendMessage(){
    var chatMsg = { from: "<%= user.getScreenName() %>", message: message.value };

    webSocket.send(JSON.stringify(chatMsg));
    echoText.value += "Message sent to the server : " + message.value + "\n";
    message.value = "";
  }
  function wsCloseConnection(){
    webSocket.close();
  }
  function wsGetMessage(message){
    echoText.value += "Message received from to the server : " + message.data + "\n";
  }
  function wsClose(message){
    echoText.value += "Disconnect ... \n";
  }

  function wsError(message){
    echoText.value += "Error ... \n";
  }
</script>

First I have a simple form for a message, a Chat button to send the message, a Disconnect button to drop the connection, and finally a text area to show the received messages.

There's a bunch of javascript to establish the Websocket connection and then a bunch of method wiring for sending the message and what not.

In the wsSendMessage() method, I'm building the JS object that the server side expects and sending it on the socket. In the wsGetMessage() method I'm just dumping the data, but I know I'm getting back the same JSON object that I'm sending so I could use the fields in a smarter way.

In my local environ I created a few different accounts and used different browsers to log in and share messages, here's an example from the chat:

Connected ... 
Message received from to the server : {"from":"test","message":"Connected!"}
Message received from to the server : {"from":"37410","message":"Connected!"}
Message received from to the server : {"from":"alpha","message":"Connected!"}
Message received from to the server : {"from":"alpha","message":"hey who is on?"}
Message sent to the server : I'm on!
Message received from to the server : {"from":"test","message":"I'm on!"}
Message received from to the server : {"from":"alpha","message":"Have you seen beta?"}
Message sent to the server : No but I hear he might log in later...
Message received from to the server : {"from":"test","message":"No but I hear he might log in later..."}
Message received from to the server : {"from":"37410","message":"Connected!"}
Message received from to the server : {"from":"beta","message":"Connected!"}
Message received from to the server : {"from":"alpha","message":"Looks like beta has joined"}
Message received from to the server : {"from":"beta","message":"yeah I'm here too"}

Conclusion

So pretty simple, right? I was actually kind of surprised at how easy it was to get rolling...

Using this as a model, you could build duplex systems like this where the browsers are sending data and the server side is pushing data or you could focus on one-way data sending from the browser to the server or a push from the server to the browsers.

And with the cluster support that Liferay has, you can handle your Websockets across all of the nodes in the cluster; the browsers won't know or care what node they're connected to because all nodes will be able to send the same data.

The repo for these modules is available here: https://github.com/dnebing/websockets

Hope you find this useful! Let me know what kind of Websocket solutions you come up with...

4
Blogs

Thanks David for the blog. I have couple of queries. Let me give you some context, We are using liferay headless as backend and front end as React JS/Mobile Apps. We are planning to use web socket for user's notifications so client does not need to call our rest(graphql) API at specific interval and they get instant notifications whenever we push from backend.  So I want to know, 1. web socket would be right choice for this scenario? 2. If yes, How I can authenicate user for web socket end point?

 

Thanks in advance.

Sure, sounds like the kind of thing WebSockets was designed for.

The WS spec is kind of light on any kind of supported auth mechanism, and Liferay doesn't give you one either.

The common implementations you find elsewhere such as "first message sent is authentication" would work, your implementation can read the message and invoke the Liferay auth pipeline too. Alternatively if you have the p_auth cookie (from logged into Liferay via browser), you can use PortalUtil's methods to get the current user from the http request, so that can be kind of transparent.

One alternative that popped into my head, a headless method that returns a token value and then that token is passed in as a parameter (or first message) that the server code validates...

I guess there's a number of ways to skin this cat, you just have to find the method that works best in your environments...

Thanks for the example provided. But I'm curious about if the portlet has been tried out on a WebLogic based scenario. I'm sure it works on Tomcat, but no so sure it will do when deployed on a WebLogic server as I reported a problem with the OSGi module 'com.liferay.websocket.whiteboard', which informs that "a WebSocket server container is not registered", as explained in this post: https://liferay.dev/en/ask/questions/development/com-liferay-websocket-whiteboard-module-not-working

I'd really appreciate your appraisal. Thanks.

Folks are still using WebLogic?  ;-)

If you're using WebLogic, you must be a DXP customer and, if so, you should reach out to support to resolve this issue.

If you're on CE, well CE only supports open source app servers such as tomcat or wildfly.