Create a Spring Boot service with WebSockets enabled

I recently had an Entando user ask about WebSocket (opens new window) support in the Platform. WebSockets can be very useful for real time system updates, especially those involving one-to-many communications with many recipients.

My stock answer to this kind of question is that it's a development detail and should work, but it's up to each team to implement and support it fully. WebSockets run on TCP so it's a safe answer but I was curious if there would be any issue when using WebSockets in an Entando-managed microservice. Short story, there isn't - but I figured I'd jot down some notes on the implementation. In this case I worked up a quick STOMP example so you'll see that reflected in the naming below.

Note: This post is more of an outline of the procedure, not a step-by-step tutorial, but the source code is linked at the bottom.

# Setup the Entando Bundle

  1. Start by setting up an Entando bundle with a microservice stubbed out:
ent bundle init stomp-example
cd stomp-example
ent bundle ms add spring-stomp-ms
  1. Next I used the Spring initializr (opens new window) to create the microservice with Maven, Spring Boot 2.7.14, and Java 11. This is similar to the steps in our basic Spring Boot tutorial with the addition of the WebSocket dependency.
	Project=Maven
	Language=Java
	Spring Boot version=2.7.14
	Group=com.entando.example
	Artifact=spring-stomp-ms 
	Name=spring-ms 
	Description=Demo project for Spring Boot with Stomp
	Package name=com.entando.example.stomp
	Packaging=Jar
	Java=11
	Dependencies:
	  #under WEB: Spring Web 
	  #under OPS: Spring Boot Actuator
      #under MESSAGING: WebSocket

spring initializr

  1. Generate the service and unzip the output into the microservices/spring-stomp-ms directory.

# Implement the STOMP service

Next we need to implement the service endpoints. Here I used Baeldung's Intro to WebSockets with Spring (opens new window) tutorial for guidance, so refer to this page for additional details.

  1. Add dependencies to the pom.xml:
		<dependency>
    		<groupId>org.springframework</groupId>
    		<artifactId>spring-websocket</artifactId>
		</dependency>

		<dependency>
    		<groupId>org.springframework</groupId>
    		<artifactId>spring-messaging</artifactId>
		</dependency>

		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
    		<artifactId>jackson-core</artifactId>
		</dependency>

		<dependency>
    		<groupId>com.fasterxml.jackson.core</groupId>
    		<artifactId>jackson-databind</artifactId> 
		</dependency>
  1. Create configuration/WebSocketConfig.java to enable the broker and endpoints. In this case I've also added a CORS config (the setAllowedOrigins call) for test purposes, but this should be disabled in a production deployment, typically using appropriate application profiles.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
         registry.addEndpoint("/chat")
                //NOTE: this is not recommended for production use and should be replaced by dev/prod profiles
                .setAllowedOrigins("http://localhost:8081")
                .withSockJS();
    }
}
  1. Create entity/Message.java and entity/OutputMessage.java for the incoming and outgoing messages, respectively:
public class Message {

    private String from;
    private String text;

    public String getText() {
        return text;
    }

    public String getFrom() {
        return from;
    }
}
package com.entando.example.stomp.entity;

public class OutputMessage {

    private String from;
    private String text;
    private String time;

    public OutputMessage(final String from, final String text, final String time) {

        this.from = from;
        this.text = text;
        this.time = time;
    }

    public String getText() {
        return text;
    }

    public String getTime() {
        return time;
    }

    public String getFrom() {
        return from;
    }
}
  1. Add controller/ChatController.java to map the chat messages to the message broker:
@Controller
public class ChatController {

    @MessageMapping("/chat")
    @SendTo("/topic/messages")
    public OutputMessage send(final Message message) throws Exception {

        final String time = new SimpleDateFormat("HH:mm").format(new Date());
        return new OutputMessage(message.getFrom(), message.getText(), time);
    }
}
  1. Add the following two lines to src/main/java/resources/application.properties so the service will: 1) run on the conventional Entando port, and 2) use the standard healthcheck path.
server.port=8081
management.endpoints.web.base-path=/api
  1. From the bundle directory, start the service:
ent bundle run spring-stomp-ms
  1. Confirm the service is working as expected with two basic checks:
  • Access http://localhost:8081/chat which should show the text Welcome to SockJS!
  • Access http://localhost:8081/api/health which should respond with {"status":"UP"}.

# Add an HTML page to test the service

Here I followed the Baeldung tutorial and created a simple HTML page for testing the basic STOMP chat client.

Note: In a real implementation, the sockjs library can be included in a micro frontend (e.g., React, Angular, etc.), or an existing STOMP component can be reused (e.g., react-stomp (opens new window)), but that is left as an exercise for the reader.

  1. Create main/webapp/index.html:
<html>
    <head>
        <title>Chat WebSocket</title>
        <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
        <script src="stomp.js"></script>
        <script type="text/javascript">
            var stompClient = null;
            
            function setConnected(connected) {
                document.getElementById('connect').disabled = connected;
                document.getElementById('disconnect').disabled = !connected;
                document.getElementById('conversationDiv').style.visibility 
                  = connected ? 'visible' : 'hidden';
                document.getElementById('response').innerHTML = '';
            }
            
            function connect() {
                var serverUrl = 'http://localhost:8081/chat';
                var socket = new SockJS(serverUrl);
                stompClient = Stomp.over(socket);  
                stompClient.connect({}, function(frame) {
                    setConnected(true);
                    console.log('Connected: ' + frame);
                    stompClient.subscribe('/topic/messages', function(messageOutput) {
                        showMessageOutput(JSON.parse(messageOutput.body));
                    });
                });
            }
            
            function disconnect() {
                if(stompClient != null) {
                    stompClient.disconnect();
                }
                setConnected(false);
                console.log("Disconnected");
            }
            
            function sendMessage() {
                var from = document.getElementById('from').value;
                var text = document.getElementById('text').value;
                stompClient.send("/app/chat", {}, 
                  JSON.stringify({'from':from, 'text':text}));
            }
            
            function showMessageOutput(messageOutput) {
                var response = document.getElementById('response');
                var p = document.createElement('p');
                p.style.wordWrap = 'break-word';
                p.appendChild(document.createTextNode(messageOutput.from + ": " 
                  + messageOutput.text + " (" + messageOutput.time + ")"));
                response.appendChild(p);
            }
        </script>
    </head>
    <body onload="disconnect()">
        <div>
            <div>
                <input type="text" id="from" placeholder="Choose a nickname"/>
            </div>
            <br />
            <div>
                <button id="connect" onclick="connect();">Connect</button>
                <button id="disconnect" disabled="disabled" onclick="disconnect();">
                    Disconnect
                </button>
            </div>
            <br />
            <div id="conversationDiv">
                <input type="text" id="text" placeholder="Write a message..."/>
                <button id="sendMessage" onclick="sendMessage();">Send</button>
                <p id="response"></p>
            </div>
        </div>

    </body>
</html>
  1. Go to http://localhost:8081 to see a basic chat interface with options to connect to the service and send messages.

  2. Open two (or more) browser windows and observe the publish/subscribe behavior in action.

The browser console can be used to see the different STOMP messages due to console logging included in stomp.js. In Chrome, the network traffic can be observed by filtering on WS, selecting the WebSocket resource, and viewing the Messages pane for that socket.

chat messages

# Deploy the bundle to Entando

You should now be able to build, deploy, and install the bundle using the standard steps.

ent bundle pack
ent bundle publish
ent bundle deploy
ent bundle install

# Test the live service

  1. Once the bundle is deployed, examine the ingress paths and check that the service is working correctly. The ingress path looks something like this:
http://YOUR-HOST/stomp-example-f9335a57/spring-stomp-ms/chat
  1. Copy the webapp/index.html to a new file (e.g., prod.html) and update the serverUrl to point to your endpoint:
var serverUrl = 'https://YOUR-HOST/stomp-example-f9335a57/spring-stomp-ms/chat';
  1. Access the new file at http://localhost:8081/prod.html and confirm the chat service is working.

Note: A CORS error will break the page if you did not include the WebConfig rule to allow access from localhost:8081.

Note#2: This step requires having the local service running to serve the HTML file but any web server will do, e.g., serve -l 8081 microservices/spring-stomp-ms/src/main/webapp/

# Observations

Implementing a basic chat client using Spring Boot 2, WebSockets, and Entando 7.2 was straightforward and fairly self-explanatory. Please feel free to join the community (opens new window) and ask questions if your experience is different!

# Reference