一、项目目录

首先看一下这个简易的 SpringBoot 项目的目录:

spring boot 网页 springboot网页浏览数_spring

我首先用 SpringBoot Initializer 创建一个简单的 Demo,然后在 Demo 上进行修改,这样更便捷。

二、下载js

这两个js不是我写的,是我从网上下载的:

2.1 sockjs.min.js

SockJS是一个浏览器JavaScript库,提供类似WebSocket的对象。SockJS为您提供了一个连贯的、跨浏览器的Javascript API,它在浏览器和web服务器之间创建了一个低延迟、全双工、跨域的通信通道。

来自 Github 上的开源项目 sockjs-client,也可以通过 http://sockjs.org 跳转。
进入 dist 文件夹 可以下载到 sockjs.min.js。

2.2 stomp.min.js

对于STOMP,许多应用程序已经使用了jmesnil/stomp-websocket库(也称为stomp.js),该库功能齐全,已经在生产中使用多年,但已不再维护。

他有官方文档 STOMP Over WebSocket

spring boot 网页 springboot网页浏览数_Spring_02

点击查看 stomp.min.js

三、Demo介绍

主要功能是统计网页在线人数。注意,该 Demo 仅支持单Web服务器的统计,不支持集群统计。
每打开一个标签页 http://localhost:8080/index.html 在线人数就会+1,并且每当人数变化,会通知所有已经打开的网页更新在线人数。

3.1 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.7</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>websocket-stomp</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>websocket-stomp</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>1.8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

为了支持 WebSocket,我们引入了依赖:

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

3.2 WebSocketConfig

package com.example.websocketstomp.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpointWisely").withSockJS();
    }

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

3.3 WebSocketConnCounter

package com.example.websocketstomp.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.atomic.LongAdder;

@Component
public class WebSocketConnCounter {

    private LongAdder connections = new LongAdder();

    @Autowired
    private SimpMessagingTemplate template;

    public void increment() {
        connections.increment();
        template.convertAndSend("/topic/getResponse", String.valueOf(connections.sum()));
    }

    public void decrement() {
        connections.decrement();
        template.convertAndSend("/topic/getResponse", String.valueOf(connections.sum()));
    }

    public long onlineUsers() {
        return connections.sum();
    }
}

3.4 WebSocketConnectListener

package com.example.websocketstomp.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectEvent;

@Component
public class WebSocketConnectListener implements ApplicationListener<SessionConnectEvent> {

    private WebSocketConnCounter counter;

    @Autowired
    public WebSocketConnectListener(WebSocketConnCounter counter) {
        this.counter = counter;
    }

    @Override
    public void onApplicationEvent(SessionConnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = accessor.getSessionId();
        System.out.println("sessionId:" + sessionId + "已连接");
        counter.increment();
    }
}

3.5 WebSocketDisconnectListener

package com.example.websocketstomp.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
public class WebSocketDisconnectListener implements ApplicationListener<SessionDisconnectEvent> {

    private WebSocketConnCounter counter;

    @Autowired
    public WebSocketDisconnectListener(WebSocketConnCounter counter) {
        this.counter = counter;
    }

    @Override
    public void onApplicationEvent(SessionDisconnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = accessor.getSessionId();
        System.out.println("sessionId:" + sessionId + "已断开");
        counter.decrement();
    }
}

3.6 WebSocketController

package com.example.websocketstomp.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

@Controller
public class WebSocketController {

    @Autowired
    private WebSocketConnCounter connCounter;

    /**
     * 用于初始化数据
     * 初次连接返回数据
     * 只执行一次
     **/
    @SubscribeMapping("welcome")
    public String welcome() {
        return String.valueOf(connCounter.onlineUsers());
    }
}

3.7 index.html

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
        "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <title>Welcome</title>
</head>
<body>
    <h1>Welcome to homepage!</h1>
    <p>Online Users: <b id="online-users">0</b></p>
</body>
<script type="text/javascript" src="/js/stomp.min.js"></script>
<script type="text/javascript" src="/js/sockjs.min.js"></script>
<script type="text/javascript">
    function init(){
        connect(1);
    }
    init();
    function connect(empNo) {
        var socket = new SockJS('/endpointWisely'); //1
        var stompClient = Stomp.over(socket);
        stompClient.connect({empNo: empNo}, function (frame) {
            console.log('Connected: ' + frame);
            stompClient.subscribe('/topic/getResponse', function (response) { //2
                var elem = document.getElementById("online-users");
                elem.textContent = response.body;
            });

            // 刚连接的时候执行,初始化数据,只执行一次
            stompClient.subscribe('/app/welcome', function (response) {
                var elem = document.getElementById("online-users");
                elem.textContent = response.body;
            });
        });

        //监听窗口关闭
        window.onbeforeunload = function (event) {
            socket.close()
        }
    }
</script>
</html>

四、开发时遇到的问题

4.1 sockjs /info 404

当时出现这个问题时的错误代码:

@SpringBootApplication
@ComponentScan(value = "com.example.websocketstomp.controller")
public class WebsocketStompApplication {

	public static void main(String[] args) {
		SpringApplication.run(WebsocketStompApplication.class, args);
	}

}

PS: 当时还没有类去注入 SimpMessagingTemplate : @Autowired private SimpMessagingTemplate template;

然后可以正常启动,但是访问 http://localhost:8080/index.html 时,在线人数一直是 0。按 F12 查看 Network 时也有 404 报错:

spring boot 网页 springboot网页浏览数_spring_03

参考 sockjs 请求/info 应该返回什么?,主要原因是没把 WebSocketConfig 扫描进来!因此也可以这样修改

@SpringBootApplication
@ComponentScan(value = {"com.example.websocketstomp.controller", "com.example.websocketstomp.config"})
public class WebsocketStompApplication {

	public static void main(String[] args) {
		SpringApplication.run(WebsocketStompApplication.class, args);
	}

}

PS: 去掉 @ComponentScan 这一行也是可行的。

4.2 首次打开首页时,人数为0

导致这个问题的原因,一个在 html 页面中:

// 刚连接的时候执行,初始化数据,只执行一次
stompClient.subscribe('/app/welcome', function (response) {
  var elem = document.getElementById("online-users");
  elem.textContent = response.body;
});

因为 WebSocket 首次连接时,stompClient.subscribe('/topic/getResponse', function (response) { } 可能发生在服务端的 template.convertAndSend("/topic/getResponse", String.valueOf(connections.sum())); 之后,导致第一次接收不到在线人数的消息。因此需要订阅 /app/welcome,同时在服务端响应它,在 WebSocketController 中用 @SubscribeMapping 注解。

关于 Java中 SubscribeMapping与MessageMapping的区别

@SubscribeMapping的主要应用场景是实现请求-回应模式。在请求-回应模式中,客户端订阅某一个目的地,然后预期在这个目的地上获得一个一次性的响应。 这种请求-回应模式与HTTP GET的请求-响应模式的关键区别在于HTTPGET请求是同步的,而订阅的请求-回应模式则是异步的,这样客户端能够在回应可用时再去处理,而不必等待。

@MessageMapping的主要应用场景是一次订阅,多次获取结果。只要之前有订阅过,后台直接发送结果到对应的路径,则多次获取返回的结果。

参考文档

Spring Framework 参考文档(WebSocket STOMP)SpringBoot+sockjs client+stompjs实现websocketSpring Boot系列十六 WebSocket简介和spring boot集成简单消息代理