Spring Boot Knew: WEB application message push that something

Reading Objects: This article is for beginners and children's shoes SpringBoot reading of SpringBoot interest.

Background: WEB application development in the enterprise, in order to better user experience & enhance the speed of response, will often request a number of time-consuming and laborious (Excel import or export, complex calculations, etc.) performed asynchronously of treatment. An important issue of this is that how to notify the user task status , common methods are roughly divided into two categories four kinds:

  • HTTP Polling client pull
  • HTTP Long-Polling client pull
  • Server-Sent Events (SSE) server push
  • WebSocket server push

1. Polling short polling

It is a very simple implementation. By the client is a timing task constantly have to repeat request server, so as to acquire a new message, the message server provides a single or a plurality of requests occur since the last chronologically.

Polling

The advantages of short polling is very obvious, it is simple. When the data in the two directions are very small, and the request is not very closely spaced, this method can be very effective. For example, news review information may be updated once every half a minute, it is possible for the user.

It was also very obvious shortcomings, once we have real-time requirements of data is very high, in order to ensure timely delivery of messages, request interval must be shortened, in this case, will exacerbate waste server resources and reduce the availability of services. Another drawback is that when a small number of messages, there will be a lot of requestto do useful work, and thus also lead to a waste of server resources.

2. Long-Polling long poll

Long polling official definition is:

The server attempts to "hold open" (notimmediately reply to) each HTTP request, responding only when there are events to deliver. In this way, there is always a pending request to which the server can reply for the purpose of delivering events as they occur, thereby minimizing the latency in message delivery.

If the Pollingway of comparison, will find Long-Pollingadvantages hold open HTTP request by reducing the unnecessary request.

General steps are:

  1. waiting for a response to the client request and server.
  2. The server will request blocking, and continue to check for new messages. Return immediately if there are new messages generated during this period. Otherwise, it has been waiting to 请求超时.
  3. When the client 获取到新消息or 请求超时, message processing and initiate the next request.

Long Polling

Long-PollingOne drawback is the waste of server resources, because it Polling's all part of the same passive acquisition , you need to constantly request to the server. In the case of high concurrency, server performance is a severe test.

Note : Because of the above two ways to achieve 2 are relatively simple, so we here do not code demonstrates. Next we focus on what Server-Sent Eventsand WebSocket.

3. Demo Overview

下面我们将通过一个下载文件的案例进行演示SSEWebSocket的消息推送,在这之前,我们先简单说一下我们项目的结构,整个项目基于SpringBoot 构建。

首先我们定义一个供前端访问的APIDownloadController

@RestController
public class DownloadController {
    private static final Logger log = getLogger(DownloadController.class);
    @Autowired
    private MockDownloadComponent downloadComponent;  

    @GetMapping("/api/download/{type}")
    public String download(@PathVariable String type, HttpServletRequest request) {  // (A)
        HttpSession session = request.getSession();
        String sessionid = session.getId();
        log.info("sessionid=[{}]", sessionid);
        downloadComponent.mockDownload(type, sessionid);  // (B)
        return "success"; // (C)
    }
}
  • (A) type参数用于区分使用哪种推送方式,这里为sse,ws,stomp这三种类型。
  • (B) MockDownloadComponent用于异步模拟下载文件的过程。
  • (C) 因为下载过程为异步化,所以该方法不会被阻塞并立即向客户端返回success,用于表明下载开始

DownloadController中我们调用MockDownloadComponentmockDownload()的方法进行模拟真正的下载逻辑。

@Component
public class MockDownloadComponent {
    private static final Logger log = LoggerFactory.getLogger(DownloadController.class);

    @Async // (A)
    public void mockDownload(String type, String sessionid) {
        for (int i = 0; i < 100; i++) {
            try {
                TimeUnit.MILLISECONDS.sleep(100); // (B)

                int percent = i + 1;
                String content = String.format("{\"username\":\"%s\",\"percent\":%d}", sessionid, percent); // (C)
                log.info("username={}'s file has been finished [{}]% ", sessionid, percent);

                switch (type) { // (D)
                    case "sse":
                        SseNotificationController.usesSsePush(sessionid, content);
                        break;
                    case "ws":
                        WebSocketNotificationHandler.usesWSPush(sessionid, content);
                        break;
                    case "stomp":
                        this.usesStompPush(sessionid, content);
                        break;
                    default:
                        throw new UnsupportedOperationException("");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • (A) 我们使用@Async让使其异步化
  • (B) 模拟下载耗时。
  • (C) 消息的格式为{"username":"abc","percent":1}
  • (D) 根据不同的type选择消息推送方式。

4. Server-Sent Events

SSE 是W3C定义的一组API规范,这使服务器能够通过HTTP将数据推送到Web页面,它具有如下特点:

  • 单向半双工:只能由server向client推送消息
  • 基于http:数据被编码为“text/event-stream”内容并使用HTTP流机制进行传输
  • 数据格式无限制:消息只是遵循规范定义的一组key-value格式&UTF-8编码的文本数据流,我们可以在消息payload中可以使用JSON或者XML或自定义数据格式。
  • http 长连接: 消息的实际传递是通过一个长期存在的HTTP连接完成的,消耗资源更少
  • 简单易用的API

Server-Sent Events

浏览器支持情况:
support browser

Note:IE 浏览器可通过第三方JS库进行支持SSE

4.1 SpringBoot 中使用SSE

从Spring 4.2开始支持SSE规范,我们只需要在Controller中返回SseEmitter对象即可。

Note:Spring 5 中提供了Spring Webflux 可以更加方便的使用SSE,但是为更贴近我们的实际项目,所以文本仅演示使用Spring MVC SSE。

我们在服务器端定义一个SseNotificationController用于和客户端处理和保存SSE连接. 其endpoint/api/sse-notification

@RestController
public class SseNotificationController {

    public static final Map<String, SseEmitter> SSE_HOLDER = new ConcurrentHashMap<>(); // (A)

    @GetMapping("/api/sse-notification")
    public SseEmitter files(HttpServletRequest request) {
        long millis = TimeUnit.SECONDS.toMillis(60);
        SseEmitter sseEmitter = new SseEmitter(millis); // (B)

        HttpSession session = request.getSession();
        String sessionid = session.getId();

        SSE_HOLDER.put(sessionid, sseEmitter); 
        return sseEmitter;
    }

    /**
     * 通过sessionId获取对应的客户端进行推送消息
     */
    public static void usesSsePush(String sessionid, String content) {  // (C)
        SseEmitter emitter = SseNotificationController.SSE_HOLDER.get(sessionid);
        if (Objects.nonNull(emitter)) {
            try {
                emitter.send(content);
            } catch (IOException | IllegalStateException e) {
                log.warn("sse send error", e);
                SseNotificationController.SSE_HOLDER.remove(sessionid);
            }
        }
    }

}
  • (A) SSE_HOLDER保存了所有客户端的SseEmitter,用于后续通知对应客户端。
  • (B) 根据指定超时时间创建一个SseEmitter对象, 它是SpringMVC提供用于操作SSE的类。
  • (C) usesSsePush()提供根据sessionId向对应客户端发送消息。发送只需要调用SseEmittersend()方法即可。

至此服务端已经完成,我们使用Vue编写客户端Download.html进行测试。核心代码如下:

     usesSSENotification: function () {
                var tt = this;
                var url = "/api/sse-notification";
                var sseClient = new EventSource(url);  // (A)
                sseClient.onopen = function () {...}; // (B)

                sseClient.onmessage = function (msg) {   // (C)
                    var jsonStr = msg.data;
                    console.log('message', jsonStr);
                    var obj = JSON.parse(jsonStr);
                    var percent = obj.percent;
                    tt.sseMsg += 'SSE 通知您:已下载完成' + percent + "%\r\n";
                    if (percent === 100) {
                        sseClient.close();  // (D)
                    }
                };
                sseClient.onerror = function () {
                    console.log("EventSource failed.");
                };
            }
  • (A) 开启一个新的 SSE connection 并访问 /api/sse-notification
  • (B) 当连接成功时的callback。
  • (C) 当有新消息时的callback。
  • (D) 当下载进度为100%时,关闭连接。

效果演示
SSE DEMO

4. WebSocket

WebSocket 类似于标准的TCP连接,它是IETF(RFC 6455)定义的通过TCP进行实时全双工通信一种通信方式,这意味这它的功能更强大,常用于如股票报价器,聊天应用。

相比于SSE,它不仅可以双向通信,而且甚至还能处理音频/视频等二进制内容。

Note:使用WebSocket,在高并发情况下,服务器将拥有许多长连接。这对网络代理层组件及WebSocket服务器都是一个不小的性能挑战,我们需要考虑其负载均衡方案。同时连接安全等问题也不容忽视。

4.1 Spring WebSocket (低级API)

Spring 4提供了一个新的Spring-WebSocket模块,用于适应各种WebSocket引擎,它与Java WebSocket API标准(JSR-356)兼容,并且提供了额外的增强功能。

Note: 对于应用程序来说,直接使用WebSocket API会大大增加开发难度,所以Spring为我们提供了 STOMP over WebSocket 更高级别的API使用WebSocket。在本文中将会分别演示通过low level API及higher level API进行演示。

如果想在SpringBoot中使用WebSocket,首先需要引入spring-boot-starter-websocket依赖

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

然后就可以配置相关信息,我们先通过low level API进行演示。

首先需要自定义一个WebSocketNotificationHandler用于处理WebSocket 的连接及消息处理。我们只需要实现WebSocketHandler或子类TextWebSocketHandler BinaryWebSocketHandler

public class WebSocketNotificationHandler extends TextWebSocketHandler {

    private static final Logger log = getLogger(WebSocketNotificationHandler.class);

    public static final Map<String, WebSocketSession> WS_HOLDER= new ConcurrentHashMap<>();  // (A)

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {   // (B)
        String httpSessionId = (String) session.getAttributes().get(HttpSessionHandshakeInterceptor.HTTP_SESSION_ID_ATTR_NAME);
        WS_HOLDER.put(httpSessionId, session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("handleTextMessage={}", message.getPayload()); 
    }

    public static void usesWSPush(String sessionid, String content) {    // (C)
        WebSocketSession wssession = WebSocketNotificationHandler.WS_HOLDER.get(sessionid);
        if (Objects.nonNull(wssession)) {
            TextMessage textMessage = new TextMessage(content);
            try {
                wssession.sendMessage(textMessage);
            } catch (IOException | IllegalStateException e) {
                WebSocketNotificationHandler.SESSIONS.remove(sessionid);
            }
        }
    }
}
  • (A) WS_HOLDER用于保存客户端的WebSocket Session
  • (B) 重写afterConnectionEstablished()方法,当连接建立之后,按sessionIdWebSocket Session保存至WS_HOLDER,用于后续向client推送消息。
  • (C) 根据sessionId获取对应WebSocket Session,并调用WebSocket SessionsendMessage(textMessage)方法向client发送消息。

使用@EnableWebSocket开启WebSocket,并实现WebSocketConfigurer进行配置。

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

        WebSocketNotificationHandler notificationHandler = new WebSocketNotificationHandler(); 

        registry.addHandler(notificationHandler, "/ws-notification") // (A)
                .addInterceptors(new HttpSessionHandshakeInterceptor())  // (B)
                .withSockJS();  // (C)
    }
}
  • (A) 将我们自定义的WebSocketNotificationHandler注册至WebSocketHandlerRegistry.
  • (B) HttpSessionHandshakeInterceptor是一个内置的拦截器,用于传递HTTP会话属性到WebSocket会话。当然你也可以通过HandshakeInterceptor接口实现自己的拦截器。
  • (C) 开启SockJS的支持,SockJS的目标是让应用程序使用WebSocket API时,当发现浏览器不支持时,无需要更改任何代码,即可使用非WebSocket替代方案,尽可能的模拟WebSocket。关于SockJS的更多资料,可参考https://github.com/sockjs/sockjs-client

server端至此就基本大功告成,接下来我们来完善一下client端Download.html,其核心方法如下:

usesWSNotification: function () {
                var tt = this;
                var url = "http://localhost:8080/ws-notification";
                var sock = new SockJS(url);   // (A)
                sock.onopen = function () {
                    console.log('open');
                    sock.send('test');
                };

                sock.onmessage = function (msg) {   // (B)
                    var jsonStr = msg.data;

                    console.log('message', jsonStr);

                    var obj = JSON.parse(jsonStr);
                    var percent = obj.percent;
                    tt.wsMsg += 'WS 通知您:已下载完成' + percent + "%\r\n";
                    if (percent === 100) {
                        sock.close();
                    }
                };

                sock.onclose = function () { 
                    console.log('ws  close');
                };
            }
  • (A) 首先需要在项目中引入SockJS Client , 并根据指定URL创建一个SockJS对象。
  • (B) 当有新消息时的callback,我们可以在该方法中处理我们的消息。

效果演示
WebSocket

4.2 STOMP over WebSocket (高级API)

WebSocket虽然定义了两种类型的消息,文本和二进制,但是针对消息的内容没有定义,为了更方便的处理消息,我们希望Client和Server都需要就某种协议达成一致,以帮助处理消息。那么,有没有已经造好的轮子呢?答案肯定是有的。这就是STOMP。

STOMP是一种简单的面向文本的消息传递协议,它其实是消息队列的一种协议, 和AMQP,JMS是平级的。 只不过由于它的简单性恰巧可以用于定义WS的消息体格式。虽然STOMP是面向文本的协议,但消息的内容也可以是二进制数据。同时STOMP 可已使用任何可靠的双向流网络协议,如TCP和WebSocket,目前很多服务端消息队列都已经支持了STOMP, 比如RabbitMQ, ActiveMQ等。

它结构是一种基于帧的协议,一帧由一个命令,一组可选的Header和一个可选的Body组成。

COMMAND
header1:value1
header2:value2

Body^@

客户端可以使用SENDSUBSCRIBE命令发送或订阅消息。 通过destination标记述消息应由谁来接收处理,形成了类似于MQ的发布订阅机制。
Spring Boot Knew: WEB application message push that something

STOMP的优势也非常明显,即:

  1. 不需要创建自定义消息格式
  2. 我们可以使用现有的stomp.js客户端
  3. 可以实现消息路由及广播
  4. 可以使用第三方成熟的消息代理中间件,如RabbitMQ, ActiveMQ等

最重要的是,Spring STOMP 为我们提供了能够像Spring MVC一样的编程模型,减少了我们的学习成本。

下面将我们的DEMO稍作调整,使用Spring STOMP来实现消息推送,在本例中我们使用SimpleBroker模式,我们的应用将会内置一个STOMP Broker,将所有信息保存至内存中。
Spring Boot Knew: WEB application message push that something

具体代码如下:

@Configuration
@EnableWebSocketMessageBroker  // (A)
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/ws-stomp-notification")
                .addInterceptors(httpSessionHandshakeInterceptor())   // (B)
                .setHandshakeHandler(httpSessionHandshakeHandler())  // (C)
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app")  // (D)
                .enableSimpleBroker("/topic", "/queue");  // (E)
    }

    @Bean
    public HttpSessionHandshakeInterceptor httpSessionHandshakeInterceptor() {
        return new HttpSessionHandshakeInterceptor();
    }

    @Bean
    public HttpSessionHandshakeHandler httpSessionHandshakeHandler() {
        return new HttpSessionHandshakeHandler();
    }

}
  • (A) 使用@EnableWebSocketMessageBroker注解开启支持STOMP
  • (B) 创建一个拦截器,用于传递HTTP会话属性到WebSocket会话。
  • (C) 配置一个自定义的HttpSessionHandshakeHandler,其主要作用是按sessionId标记识别连接。
  • (D) 设置消息处理器路由前缀,当消息的destination/app开头时,将会把该消息路由到server端的对应的消息处理方法中。(在本例中无实际意义)
  • (E) 设置客户端订阅消息的路径前缀

HttpSessionHandshakeHandler代码如下:

public class HttpSessionHandshakeHandler extends DefaultHandshakeHandler {

    @Override
    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String sessionId = (String) attributes.get(HttpSessionHandshakeInterceptor.HTTP_SESSION_ID_ATTR_NAME);
        return new HttpSessionPrincipal(sessionId);

    }
}

当我们需要向client发送消息时,只需要注入SimpMessagingTemplate对象即可,是不是感觉非常熟悉?! 没错,这种Template模式和我们日常使用的RestTemplate JDBCTemplate是一样的。
我们只需要调用SimpMessagingTemplateconvertAndSendToUser()方法即可向对应用户发送消息了。

  private void usesStompPush(String sessionid, String content) {
        String destination = "/queue/download-notification";
        messagingTemplate.convertAndSendToUser(sessionid, destination, content);
    }

在浏览器端,client可以使用stomp.js和sockjs-client进行如下连接:

usesStompNotification: function () {
                var tt = this;
                var url = "http://localhost:8080/ws-stomp-notification";
                // 公共topic
                // var notificationTopic = "/topic/download-notification";
                // 点对点广播
                var notificationTopic = "/user/queue/download-notification"; // (A)

                var socket = new SockJS(url);
                var stompClient = Stomp.over(socket);

                stompClient.connect({}, function (frame) {
                    console.log("STOMP connection successful");

                    stompClient.subscribe(notificationTopic, function (msg) {   // (B)
                        var jsonStr = msg.body;

                        var obj = JSON.parse(jsonStr);
                        var percent = obj.percent;
                        tt.stompMsg += 'STOMP 通知您:已下载完成' + percent + "%\r\n";
                        if (percent === 100) {
                            stompClient.disconnect()
                        }

                    });

                }, function (error) {
                    console.log("STOMP protocol error " + error)
                })
            }
  • (A) 如果想针对特定用户接收消息,我们需要以/user/为前缀,Spring STOMP会把以/user/为前缀的消息交给UserDestinationMessageHandler进行处理并发给特定的用户,当然这个/user/是可以通过WebSocketBrokerConfig进行个性化配置的,为了简单起见,我们这里就使用默认配置,所以我们的topic url就是/user/queue/download-notification
  • (B) 设置stompClient消息处理callback进行消息处理。

效果演示
STOMP

5 总结

在文中为大家简单讲解了几种常用的消息推送方案,并通过一个下载案例重点演示了SSEWebSocket这两种server push模式的消息推送。当然还有很多细节并没有在文中说明,建议大家下载源码对照参考。
Spring Boot Knew: WEB application message push that something

Compared to these types of models, small series that if our needs just push messages to the client , then the use of SSEhigher cost, Long-Pollingfollowed. Use WebSocketthere is a feeling of chickens with a sledgehammer, and to our system brings more complexity, more harm than good, it is not recommended. And Pollingalthough the implementation of the simplest and strongest compatibility, but its efficiency is too low, it is not recommended. Of course, if you have other ideas, welcome to discuss the message exchange.

Example Source text: https://github.com/leven-space/SpringBootNotification.git

If you find this article useful, please leave your little

Guess you like

Origin blog.51cto.com/14479714/2425775