Spring boot development series - experience in developing WebSocket

1, foreword

In some project scenarios, WebSocket is a powerful tool, but after all, there are few general application scenarios. While still remembering, write down some experiences in the development process, so as not to forget when it is needed again in a year and a half. I have written a "WebSocket, no polling" before, and talked about some concepts and application scenarios of WebSocket. But this paper is partial to actual combat, and will explain more code.

The code includes the server and client of WebSocket, and how to write the unit test of WebSocket. Among them, some "pits" will be analyzed.

2. WebSocket server

WebSocket server is the program that provides WebSocket services. Spring boot develops WebSocket in two ways: declarative and programmatic. The former is the simplest, and I use declarative.

2.1,pom.xml

        <!--websocket Server side-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

2.2 register Bean

Configure the ServerEndpointExporter in the @ Configuration class (the startup class also contains the annotation). After Configuration, all websocket endpoints declared by the @ ServerEndpoint annotation will be registered automatically.

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }

2.3. WebSocket server class

According to the above, the class that writes WebSocket needs to be declared by "@ ServerEndpoint" annotation.
MyWebSocketService .java

@Component
@ServerEndpoint(value = "/xxx/{userId}")
@Slf4j
public class MyWebSocketService {
    private String userId = "anonymous";
    private static int onlineCount = 0;
    private Session session;
    private static ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<>();


    @OnOpen
    public void onOpen(Session curSession, @PathParam("userId") String curUserId) {
        this.session = curSession;
        this.userId = curUserId;
        sessionPool.put(curUserId, curSession);
        addOnlineCount();
        log.info(curUserId + "There is a connection to join! The current number of people online is" + onlineCount);
    }

    /**
     * Method of connection close call
     */
    @OnClose
    public void onClose() {
        if (sessionPool.get(this.userId) != null) {
            sessionPool.remove(userId);
            subOnlineCount();
            log.info(userId + "There is a connection closed! The current number of people online is" + getOnlineCount());
        }
    }

    /**
     * Method of calling after receiving client message
     *
     * @param message Messages sent by clients
     */
    @OnMessage
    public void onMessage(String message) {
        handleMessage(message);
    }

    /**
     * @param curSession
     * @param error
     */
    @OnError
    public void onError(Session curSession, Throwable error) {
        log.error(error.getMessage(), error);
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        onlineCount--;
    }

    /**
     * Send to single user only
     *
     * @param curUserId
     * @param socketMessage
     */
    public void sendMessageSingle(String curUserId, SocketMessage socketMessage) {
        Session curSession = sessionPool.get(curUserId);
        if (curSession != null) {
            try {
                String response = JSON.toJSONString(socketMessage);
                curSession.getBasicRemote().sendText(response);
            } catch (Exception e) {
                log.error(e.getMessage(), e);
            }
        }
    }

  
    /**
     * Processing messages sent by clients
     * websocket Early initialization, unable to inject Bean
     *
     * @param message
     * @return
     */
    private void handleMessage(String message) {
        try {
            SocketMessage request = JSON.parseObject(message, SocketMessage.class);
            switch (ModuleEnum.valueOf(request.getModule())) {
                case HEART_CHECK:
                    this.session.getBasicRemote().sendText(
                            JSON.toJSONString(new SocketMessage(ModuleEnum.HEART_CHECK.name(), "Reply to heartbeat check")));
                    break;
                case ACTION_MAP_SWITCH:
                    MapMapper mapMapper =ApplicationContextRegister.getApplicationContext().getBean(MapMapper.class);
                    mapMapper.updateAllBranchVisible();
                    sendMessageSingle(chlWeb, request);
                    break;
                 //case and so on, other processing logic
                default:
                    break;
            }

        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }

}

2.4 conflict between single case and multiple cases

One step in the above code is to call the method of dao layer, handleMessage method.

//...
                case ACTION_MAP_SWITCH:
                    MapMapper mapMapper =ApplicationContextRegister.getApplicationContext().getBean(MapMapper.class);
                    mapMapper.updateAllBranchVisible();
//...

Normally, when we develop Spring boot, we use the IOC features of Spring container to inject Service, Dao and other direct dependencies, similar to the following.

    @Autowired
    private MapMapper mapMapper;

//...
                case ACTION_MAP_SWITCH:
                    mapMapper.updateAllBranchVisible();
//...

However, if this is done, an error will be reported. When mapMapper.updateAllBranchVisible(); method is executed, a null pointer will be reported, that is, MapMapper's Bean is not injected. So this article uses the Spring container context to create MapMapper's Bean in the way of factory class.
ApplicationContextRegister.java

@Component
public class ApplicationContextRegister implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void  setApplicationContext(ApplicationContext curApplicationContext) {
        applicationContext = curApplicationContext;
    }

    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }

}

To understand why, first understand how Spring injects beans:

Some people may not know that the Bean instantiated by Spring by default is a singleton pattern, which means that when the Spring container is loaded, an instance of MapMapper is injected. No matter how many interfaces are called again, the same instance of the Bean is loaded.

WebSocket is a multi instance mode. When an instance is initialized for the first time when a project is started, the instance of MapMapper can be loaded successfully. Unfortunately, WebSocket has no user connection at this time. When there is a first user connection, WebSocket class will create a second instance, but because Spring's Dao layer is a singleton mode, the corresponding instance of MapMapper is empty at this time. Every time a new user is connected, a new WebSocket instance will be created. Of course, the MapMapper instance is empty.

3. WebSocket client

In general, few people write WebSocket clients in spring boot, usually the back-end provides services, and the front-end communicates as the client. But if your application scenario is a long connection interaction between back ends, it will still be used. Or, when you need to write unit tests to your server, let's talk later.

3.1,pom.xml

        <!--websocket Client-->
        <dependency>
            <groupId>org.java-websocket</groupId>
            <artifactId>Java-WebSocket</artifactId>
            <version>1.3.8</version>
        </dependency>

3.2. WebSocket client class

The way to configure the WebSocket client is simpler. It inherits and implements the WebSocketClient class.

MyWebSocketClient.java

import lombok.extern.slf4j.Slf4j;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;

@Slf4j
public class MyWebSocketClient extends WebSocketClient {
public MyWebSocketClient(URI uri){
    super(uri);
}

    @Override
    public void onOpen(ServerHandshake serverHandshake) {
        log.info("Client connection successful");
    }

    @Override
    public void onMessage(String s) {
        log.info("Message received by client:"+s);
    }

    @Override
    public void onClose(int i, String s, boolean b) {
        log.info("Client closed successfully");
    }

    @Override
    public void onError(Exception e) {
        log.error("Client error");
    }

    public static void main(String[] args) {
        try {
            MyWebSocketClient myWebSocketClient = new MyWebSocketClient(new URI("ws://localhost:9000/xxx/user1"));
            myWebSocketClient.connect();
            while (!WebSocket.READYSTATE.OPEN.equals(myWebSocketClient.getReadyState())) {
                log.info("WebSocket Client connection, please wait...");
                Thread.sleep(500);
            }
            myWebSocketClient.send("{\"module\":\"HEART_CHECK\",\"message\":\"Request heartbeat\"}");
            myWebSocketClient.close();
        } catch (Exception e) {
            log.error("error", e);
        }
    }

}

4. WebSocket unit test

The customer requires that our SpringBoot program pass the quality inspection of sonar before it is released. One of them is to "ensure the coverage of unit test is more than 50%". We all know that the unit test of ordinary Http interface will not come out of Baidu. But it's hard for you to come out of Baidu. How to do unit test for WebSocket interface?

Later, I thought, unit testing is nothing more than listening to the routing of back-end services and calling the program methods. Can I write a test class to test the logic of the server by creating a WebSocket client and simulating the front end? In fact, I study "3. WebSocket client" to improve the coverage of this unit test.

4.1,WebEnvironment

When we write Junit's test class, we usually get the startup class through @ SpringBootTest and load the spring boot configuration as follows. But if there is WebSocket in our project, an error will be reported that WebSocket cannot be started.

@RunWith(SpringRunner.class)
@SpringBootTest
public class CompositeControllerTest{
    @Test
    public void websocketClient() {
        int num = new Integer(1);
        Assert.assertEquals(num, 1);
    }
}

@The SpringBootTest annotation actually has a webEnvironment attribute. There are four types of SpringBootTest.WebEnvironment:

  1. MOCK (default): loads a WebApplicationContext and provides a MOCK servlet environment. The embedded servlet container will not start when this annotation is used. If the servlet API is not on your classpath, this pattern will transparently revert back to creating a regular non web application context. It can be used with @ AutoConfigureMockMvc for application testing based on MockMvc.
  2. Random port: load an embedded web application context and provide a real servlet environment. The embedded servlet container starts and listens on a random port.
  3. Defined port: loads an embedded web application context and provides a real servlet environment. The embedded servlet container starts and listens on the defined port (that is, from application.properties or the default port 8080).
  4. NONE: loads ApplicationContext using SpringApplication, but does not provide any servlet environment (emulation or otherwise).

We need a complete container when testing websocket, so we can choose random port or defined port.

4.2 test

To facilitate testing, we use springboottest.webenvironment.defined? Port to listen for fixed ports.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@AutoConfigureMockMvc
@Slf4j
class CompositeControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private WebApplicationContext webApplicationContext;

    @Before
    public void before(){
        mockMvc= MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
    }

// ... and so on.

    /**
     * Create WebSocket client for testing
     * @throws Exception
     */
    @Test
    void websocketClient() throws Exception{
        MyWebSocketClient myWebSocketClient = new MyWebSocketClient(new URI("ws://localhost:port/xxxx/user1"));
        myWebSocketClient.connect();
        while (!WebSocket.READYSTATE.OPEN.equals(myWebSocketClient.getReadyState())){
            log.info("WebSocket Client connection, please wait...");
            Thread.sleep(500);
        }
        Map<String,String> requestMap=new HashMap<>();
        requestMap.put("HEART_CHECK","{\"module\":\"HEART_CHECK\",\"message\":\"Request heartbeat\"}");
        requestMap.put("KEY1","VALUE1");
        requestMap.put("KEY2","VALUE2");
        requestMap.put("KEY3","VALUE3");

        for(String key: requestMap.keySet()){
            myWebSocketClient.send(requestMap.get(key));
        }
        //Test onError, onMessage, onClose
        // ... and so on.
        myWebSocketClient.close();
    }
}

OK, in the final sonar test report, we can see that the WebSocket code is basically covered, and the unit test coverage has increased to 90%, and my task has been achieved.

References

1.spring boot integrates Websocket notes

https://yq.aliyun.com/articles/637898?spm=a2c4e.11153940.0.0.537f1d36hwhtLi

Tags: Javascript Spring Session Java JSON

Posted on Tue, 24 Mar 2020 06:40:05 -0400 by PurpleMonkey