全双工通信协议:WebSockets+STOMP

前言

WebSocket协议定义了两种类型的消息(文本和二进制),但是它们的内容是未定义的。STOMP(Streaming Text Oriented Messaging Protocol)是一种简单的、基于文本的消息传递协议,提供了一组命令和消息格式,用于在客户端和服务端之间发送和接收消息。客户端可以通过连接到消息代理(如ActiveMQRabbitMQ等)来发送和接收消息。STOMP协议支持多种编程语言和平台,因此可以在不同的环境中使用。也可以实现一对多的发布/订阅模式。它还提供了一些高级特性,如事务支持、消息持久化等。

STOMP是一种基于帧的协议,其帧以HTTP为模型。下面的列表显示了STOMP帧的结构:

COMMAND
header1:value1
header2:value2

Body^@

客户端可以使用SEND或者SUBSCRIBE发送或订阅消息的命令,以及destination描述消息内容和接收人的标题。在STOMP规范中,目的地的含义是故意保持不透明的。它可以是任何字符串,完全由STOMP服务器来定义它们支持的目的地的语义和语法。然而,目的地通常是类似路径的字符串,其中/topic/..意味着发布-订阅(一对多)和/queue/意味着点对点(一对一)的消息交换。

STOMP服务器可以使用MESSAGE命令向所有订户广播消息。以下示例显示了服务器向订阅的客户端发送股票报价:

MESSAGE
message-id:nxahklf6-1
subscription:sub-1
destination:/topic/price.stock.MMM

{"ticker":"MMM","price":129.45}^@

服务器不能发送未经请求的消息。来自服务器的所有消息都必须响应特定的客户端订阅,并且服务器消息的订阅头必须与客户端订阅的id头匹配。

与使用原始WebSockets相比,使用STOMP作为子协议可以让Spring框架和Spring Security提供更丰富的编程模型。

启动STOMP

STOMP over WebSocket支持在spring-messagingspring-websocket模块中可用。一旦你有了这些依赖,你就可以在WebSocket上公开一个STOMP端点,如下面的例子所示:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfigurer implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")//注册端点,客户端可以使用该端点与服务器建立 WebSocket 连接。
                    .withSockJS()//启用sockjs
            ;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //指定了消息发送的前缀,即发送的消息会以“/app”开头
        registry.setApplicationDestinationPrefixes("/app");
        //指定了消息代理的目标前缀,即服务器会将以“/topic”和“/queue”开头的消息发送到所有订阅了对应前缀的客户端。
        registry.enableSimpleBroker("/topic", "/queue");
    }
}

要从浏览器连接,对于STOMP,您可以使用stomp-js/stompjs这是维护最活跃的JavaScript库。示例如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
    });
</script>
</body>
</html>

WebSocket传输

STOMP WebSocket传输属性可以自定义,如下所示:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
		//服务器端接收的消息最大长度
		registry.setMessageSizeLimit(4 * 8192);
		//首次消息到达的时间限制
		registry.setTimeToFirstMessage(30000);
	}

}

消息流

spring-messaging传递模块包含对消息传递应用程序的基本支持,这些应用程序起源于Spring Integration,后来被提取并合并到Spring框架中,以便在许多Spring项目和应用程序场景中更广泛地使用。

Java配置(即,@EnableWebSocketMessageBroker)和XML名称空间配置(即,<websocket:message-broker>)使用前面的组件来组装消息工作流。下图显示了启用简单内置消息代理时使用的组件:

在这里插入图片描述

当从WebSocket连接接收消息时,它们被解码为STOMP帧,转换为Spring消息表示,并发送到clientInboundChannel进行进一步处理。例如,目的标头以/app开头的STOMP消息可能被路由到带注释的控制器中的@MessageMapping方法,而/topic/queue消息可能被直接路由到消息代理。

处理来自客户端的STOMP消息的带注释的@Controller可以通过brokerChannel向消息代理发送消息,代理通过clientOutboundChannel将消息广播给匹配的订阅者。相同的控制器也可以在响应HTTP请求时执行相同的操作,因此客户机可以执行HTTP POST,然后@PostMapping方法可以向消息代理发送消息以广播给订阅的客户机。

我们可以通过一个简单的例子来跟踪流程。考虑下面的例子,它设置了一个服务器:

@Controller
public class MyController {

    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        return modelAndView;
    }

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public String greeting(String greeting) {
        return "hello world";
    }
}
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfigurer implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")//注册端点
                    .withSockJS()//启用sockjs
            ;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //指定了消息发送的前缀,即发送的消息会以“/app”开头
        registry.setApplicationDestinationPrefixes("/app");
        //指定了消息代理的目标前缀,你也可以直接指定目的地的路径,而无需添加前缀。
        registry.enableSimpleBroker("/topic", "/queue");
    }

}

前端代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        //订阅
        stompClient.subscribe('/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
    });
    //关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var name = document.getElementById('name').value;
        //发送消息
        stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
    //监听服务器关闭
    socket.onclose = () => {
        // 在这里处理服务器断开连接的逻辑
        console.log('服务器断开连接');
        // 可以尝试重新连接或进行其他操作
    };
</script>
</body>
</html>

上述代码中,通过stompClient.connect()连接服务器,stompClient.subscribe()订阅消息,stompClient.disconnect()关闭连接,stompClient.send()发送消息到服务器,你可以使用socket.onclose()监听服务器关闭状态。效果展示

在这里插入图片描述

前面的示例支持以下流程:

  • 通过 registerStompEndpoints() 方法注册一个 STOMP 端点,并指定其路径为 /stomp,客户端连接到localhost:8080/stomp而且,一旦WebSocket连接建立,STOMP帧就开始在其上传输。

  • 客户端发送一个带有目的报头/topic/greetingSUBSCRIBE帧。收到并解码后,消息将发送到clientInboundChannel,然后路由到存储客户端订阅的消息代理。

  • 客户端发送一个SEND帧到/app/greeting/app前缀有助于将它路由到带注释的控制器。在/app前缀被剥离之后,目的地的剩余/greeting部分被映射到GreetingController中的@MessageMapping方法。

  • GreetingController返回的值被转换成一个Spring Message,带有基于返回值的有效负载和/topic/greeting的默认目的地标头(从输入目的地派生,/app/topic取代)。结果消息被发送到brokerChannel,并由消息代理处理。

  • 消息代理找到所有匹配的订阅者,并通过clientOutboundChannel消息从这里被编码为STOMP帧并在WebSocket连接上发送。

注释控制器

应用程序可以使用带注释的@Controller处理来自客户端的消息的类。这样的类可以声明@MessageMapping, @SubscribeMapping,以及@ExceptionHandler方法。

配置文件如下:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfigurer implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")//注册端点
                    .withSockJS()//启用sockjs
                    .setHeartbeatTime(5000)
            ;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //指定了消息发送的前缀,即发送的消息会以“/app”开头
        registry.setApplicationDestinationPrefixes("/app");
		//指定了消息代理的目标前缀,你也可以直接指定目的地的路径,而无需添加前缀。
        registry.enableSimpleBroker("/topic", "/queue");
    }

}

(1)@MessageMapping注释:它可以应用于控制器方法,用于指定该方法应处理特定类型的WebSocket消息。当客户端通过WebSocket连接发送消息时,消息将由Spring WebSocket处理程序接收并路由到适当的@MessageMapping方法。在该方法中,您可以处理消息并生成响应,然后将其发送回客户端。

示例代码如下:

@Controller
public class MyController {

    @MessageMapping("/hello")
    public Greeting greeting(String message) throws Exception {
        return "hello world";
    }
}

客户端通过stompClient.send()发送消息,前端代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
    });
    function sendName() {
        var name = document.getElementById('name').value;
        stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
    }
</script>
</body>
</html>

在上面的示例中,指示该方法应处理路径为"/hello"WebSocket消息。当接收到这样的消息时,该方法将调用并传递消息作为参数。

(2)@SendTo("/topic/greetings")注释:指定响应应该发送到名为“/topic/greetings”的主题。这意味着所有已订阅该主题的客户端都将收到响应。

示例代码如下:

@Controller
public class MyController {

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(String message) throws Exception {
        return "hello world";
    }
}

其他类似@SendTo的注解,例如@SendToUser@SendToAll,这些注解使您可以将响应发送回特定的用户或所有连接的用户。

前端代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
    });

    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var name = document.getElementById('name').value;
        stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
</script>
</body>
</html>

上述代码,客户端通过stompClient.subscribe()订阅消息,将订阅的消息显示在前端<div id="message">标签中。

(3)@SubscribeMapping注释:用于处理客户端订阅的请求,它是在客户端发送订阅请求时触发的。@SubscribeMapping注解可用于指定由Spring WebSocket处理程序调用的方法,以生成初始数据响应。

configureMessageBroker()方法中,我们指定了消息发送的前缀为/app,即发送的消息会以/app开头。

示例代码如下:

@Controller
public class MyController {

    @SubscribeMapping("/greetings")
    public String handleSubscription() {
        // 处理订阅请求
        System.out.println("发送订阅请求,第一次进来");
        return "Welcome!";
    }
}

客户端通过stompClient.subscribe()订阅两个消息,前端代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
        stompClient.subscribe('/app/greetings', function (greeting) {
            console.log(greeting)
        });
    });

</script>
</body>
</html>

@MessageMapping不同,@SubscribeMapping不接收任何参数。这是因为@SubscribeMapping注解的目的是提供初始数据响应而不是处理传入的消息,客户端只需要发送一次订阅请求即可接收到服务器返回的消息。

(4)@MessageExceptionHandler注释:用于处理WebSocket消息异常的注解。它可以应用于控制器中的方法,用于捕获和处理在处理WebSocket消息时发生的异常。当处理WebSocket消息时,如果发生异常(例如,验证失败、数据格式错误等),可以使用@MessageExceptionHandler注解指定一个方法来处理该异常,并返回适当的响应给客户端。

示例代码如下:

@Controller
public class MyController {

    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        return modelAndView;
    }

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public void greeting(String greeting) {
        throw new IllegalArgumentException("这是一个错误信息");
    }
}
@ControllerAdvice
public class WebSocketExceptionHandler {
    @MessageExceptionHandler(Exception.class)
    @SendTo("/topic/errors")
    public String handleException(Exception ex) {
        System.out.println("错误:"+ex.getMessage());
        return ex.getMessage();
    }
}

上面的示例中,获处理WebSocket消息时发生的任何异常。@SendToUser("/queue/errors")注解指定了将响应发送给引发异常的用户的目标位置。这意味着只有引发异常的用户将接收到该错误消息。

客户端通过stompClient.subscribe()订阅消息,前端代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
        //订阅错误地址
        stompClient.subscribe('/topic/errors', function (error) {
            console.log(error)
        });
    });
    function sendName() {
        var name = document.getElementById('name').value;
        stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
    }
</script>
</body>
</html>

效果展示

在这里插入图片描述

发送消息

如果您想从应用程序的任何部分向连接的客户端发送消息,该怎么办?任何应用程序组件都可以向brokerChannel。最简单的方法是注入一个SimpMessagingTemplate并用它来发送信息。通常,您会按类型注入它,如下例所示:

@Controller
public class MyController {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        return modelAndView;
    }
    @GetMapping(value = "/back")
    @ResponseBody
    public ModelAndView back(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("back");
        return modelAndView;
    }

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public String greeting(String greeting) {
        return "hello world";
    }


    @PostMapping("/sendMessage")
    @ResponseBody
    public void handleSubscription(String message) {
        simpMessagingTemplate.convertAndSend("/topic/greetings",message);
    }
}

客户端页面代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="name" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
    });
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
</script>
</body>
</html>

后台前端代码如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>这是一个后台页面</h1>
<input type="text" id="textInput" placeholder="Enter text...">
<button onclick="submitText()">Submit</button>


<script>
    function submitText() {
        var text = document.getElementById("textInput").value;
        console.log(text)
        // 创建一个新的XMLHttpRequest对象
        var xhr = new XMLHttpRequest();

        // 指定请求的方法、URL和是否异步处理
        xhr.open("POST", "/sendMessage");
        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
        // 注册回调函数来处理响应
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                // 处理成功响应
                console.log("Text submitted successfully!");
            }
        };

        // 发送请求
        xhr.send("message="+text);
    }
</script>
</body>
</html>

效果展示
在这里插入图片描述

代理

  • 简单代理

内置的简单消息代理处理来自客户端的订阅请求,将它们存储在内存中,并向具有匹配目的地的连接客户端广播消息。代理支持类似路径的目的地,包括对Ant风格的目的地模式的订阅。

如果配置了任务调度器,简单代理支持STOMP心跳。要配置调度程序,您可以声明自己的调度程序。或者,您可以使用内置WebSocket配置中自动声明的一个,但是,您将需要@Lazy为了避免内置WebSocket配置和您的WebSocketMessageBrokerConfigurer。例如:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfigurer implements WebSocketMessageBrokerConfigurer {
    private TaskScheduler messageBrokerTaskScheduler;

    @Autowired
    public void setMessageBrokerTaskScheduler(@Lazy TaskScheduler taskScheduler) {
        this.messageBrokerTaskScheduler = taskScheduler;
    }
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")//注册端点
                    .withSockJS()//启用sockjs
                    .setHeartbeatTime(5000)//设置心跳值
                    .setTaskScheduler(messageBrokerTaskScheduler)//置了消息代理的任务调度器
            ;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //指定了消息发送的前缀,即发送的消息会以“/app”开头
        registry.setApplicationDestinationPrefixes("/app");
        //指定了消息代理的目标前缀,即服务器会将以“/topic”和“/queue”开头的消息发送到所有订阅了对应前缀的客户端。
        registry.enableSimpleBroker("/topic", "/queue");
    }

}
  • 外部代理

简单代理非常适合入门,但只支持STOMP命令的一个子集(它不支持ack、收据和其他一些特性),依赖于一个简单的消息发送循环,不适合集群。作为替代方案,您可以升级应用程序以使用功能齐全的消息代理。enableStompBrokerRelay()基于代理的消息代理中继配置选项。使用这个选项,您可以配置Spring WebSocket与外部消息代理进行通信,并利用代理的高级功能,如消息路由、负载均衡和可靠性保证。您需要提供代理服务器的详细信息,包括主机名、端口号、虚拟主机和认证信息等。这个选项适用于在大型分布式系统中使用专业的消息代理,如ActiveMQ、RabbitMQ等。

以下配置示例启用了一个全功能代理:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfigurer implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/stomp")//注册端点
                    .withSockJS()//启用sockjs
            ;
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //指定了消息发送的前缀,即发送的消息会以“/app”开头
        registry.setApplicationDestinationPrefixes("/app");
        //指定了消息代理的目标前缀,即服务器会将以“/topic”和“/queue”开头的消息发送到所有订阅了对应前缀的客户端。
        registry.enableStompBrokerRelay("/topic", "/queue")
                .setRelayHost("localhost")
                .setRelayPort(61613)
                .setClientLogin("username")
                .setClientPasscode("password");
    }

}
  • 连接到代理

STOMP代理中继维护到代理的单个“系统”TCP连接。此连接仅用于来自服务器端应用程序的消息,而不用于接收消息。您可以为此连接配置STOMP凭据(即STOMP帧登录和密码头)。您可以配置发送和接收心跳的间隔(默认为每次10秒)。如果失去与代理的连接,代理中继将继续尝试每5秒重新连接一次,直到成功为止。

消息代理中继(Message Broker Relay)是一种在不同的消息代理之间转发消息的机制。它允许在分布式系统中使用不同的消息代理实例进行通信,以实现消息的传递和交换。

消息代理中继的工作原理如下:
消息发送方将消息发送到源消息代理。
源消息代理将消息转发给消息代理中继。
消息代理中继接收到消息后,根据预先配置的规则将消息传递给目标消息代理。
目标消息代理接收到消息后,将消息传递给消息接收方。

默认情况下,STOMP代理中继总是连接到相同的主机和端口,如果连接丢失,则根据需要重新连接。如果您希望在每次尝试连接时提供多个地址,您可以配置地址的提供者,而不是固定的主机和端口。下面的例子展示了如何做到这一点:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.enableStompBrokerRelay("/queue/", "/topic/").setTcpClient(createTcpClient());
		registry.setApplicationDestinationPrefixes("/app");
	}

    private ReactorNettyTcpClient<byte[]> createTcpClient() {
        // 创建编解码器
        StompReactorNettyCodec codec = new StompReactorNettyCodec();
        // 设置要连接的服务器地址
        InetSocketAddress address = new InetSocketAddress("localhost", 8080);
        return new ReactorNettyTcpClient<>(tcpClient -> tcpClient.bindAddress(()->address),codec);
    }
}

使用setTcpClient()方法创建并配置了一个ReactorNettyTcpClient来处理底层的TCP连接。

点作为分隔符

当消息被路由到@MessageMapping方法时,它们与AntPathMatcher匹配。默认情况下,模式将使用斜杠(/)作为分隔符。这在web应用程序中是一个很好的约定,类似于HTTP url。但是,如果您更习惯于消息传递约定,则可以切换到使用点(.)作为分隔符。

以下示例显示了如何在Java配置中实现这一点:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		//修改了默认的路径匹配方式
		registry.setPathMatcher(new AntPathMatcher("."));
		registry.enableStompBrokerRelay("/queue", "/topic");
		registry.setApplicationDestinationPrefixes("/app");
	}
}

此后,控制器可以使用点(.)作为中的分隔符@MessageMapping方法,如下例所示:

@Controller
@MessageMapping("red")
public class RedController {

	@MessageMapping("blue.{green}")
	public void handleGreen(@DestinationVariable String green) {
		// ...
	}
}

客户端现在可以向/app/red.blue.green123.发送消息。

证明

每个STOMP over WebSocket消息会话都以HTTP请求开始。这可以是升级到WebSocket的请求(即WebSocket握手),或者在SockJS回退的情况下,是一系列SockJS HTTP传输请求。

许多web应用程序已经有了适当的身份验证和授权来保护HTTP请求。通常,通过Spring Security使用某种机制(如登录页面、HTTP基本身份验证或其他方式)对用户进行身份验证。

因此,对于WebSocket握手或SockJS HTTP传输请求,通常已经有一个通过HttpServletRequest#getUserPrincipal()访问的经过身份验证的用户。Spring自动将该用户与为其创建的WebSocketSockJS会话关联起来,随后,通过用户头将该会话上传输的所有STOMP消息关联起来。

STOMP协议在CONNECT帧上有登录头和密码头。它们最初是为TCP上的STOMP设计的,也是为STOMP所需要的然而,对于WebSocket上的STOMP,默认情况下,Spring忽略STOMP协议级别的身份验证头,并假设用户已经在HTTP传输级别进行了身份验证。

Spring Security OAuth支持基于令牌的安全性,包括JSON Web令牌(JWT)。您可以将其用作Web应用程序中的身份验证机制,包括在WebSocket交互上的STOMP

Spring记录并保存经过身份验证的用户,并将其与同一会话上的后续STOMP消息关联起来。下面的例子展示了如何注册一个自定义身份验证拦截器:

@Configuration
@EnableWebSocketMessageBroker
public class MyConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new ChannelInterceptor() {
			@Override
			public Message<?> preSend(Message<?> message, MessageChannel channel) {
				StompHeaderAccessor accessor =
						MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
				if (StompCommand.CONNECT.equals(accessor.getCommand())) {
					Authentication user = ... ; // access authentication header(s)
					accessor.setUser(user);
				}
				return message;
			}
		});
	}
}

Spring Security提供WebSocket子协议授权,它使用ChannelInterceptor根据消息中的用户头对消息进行授权。此外,Spring Session提供WebSocket集成,确保用户的HTTP会话在WebSocket会话仍处于活动状态时不会过期。

用户目的地

应用程序可以发送针对特定用户的消息,SpringSTOMP支持识别前缀为/user/为了这个目的。例如,客户端可能会订阅/user/queue/position-updates目的地。UserDestinationMessageHandler处理该目的地并将其转换为用户会话特有的目的地。

在这里插入图片描述

消息处理方法可以通过@SendToUser注释(在类级别上也支持共享公共目的地)将消息发送给与正在处理的消息相关联的用户,如下面的示例所示;

@Controller
public class MyController {

    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        return modelAndView;
    }

    @MessageMapping("/hello")
    @SendToUser("/topic/greetings")
    public String greeting(String message) {
        JSONObject parse = JSONObject.parse(message);
        return parse.getString("content");
    }
}

客户端可以订阅 "/user/topic/greetings" 目的地来接收结果。前端代码如下:

<!DOCTYPE html>
<html lang="en"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="content" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {
        // 连接成功后的回调函数
        console.log('连接成功: ' + frame);
        //订阅
        stompClient.subscribe('/user/topic/greetings', function (greeting) {
            console.log(greeting)
            showGreeting(greeting.body);
        });
    });
    //关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var content = document.getElementById('content').value;
        //发送消息
        stompClient.send("/app/hello", {}, JSON.stringify({ 'content': content}));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
</script>
</body>
</html>

效果展示
在这里插入图片描述

如果用户有多个会话,默认情况下,将针对指定目的地订阅的所有会话。然而,有时可能需要只针对发送正在处理的消息的会话。你可以通过将broadcast属性设置为false来实现,如下面的例子所示:

@Controller
public class MyController {

	@MessageExceptionHandler
	@SendToUser(destinations="/queue/errors", broadcast=false)
	public ApplicationError handleException(MyBusinessException exception) {
		// ...
		return appError;
	}
}

虽然用户目的地通常意味着经过身份验证的用户,但这并不是严格要求的。没有与经过身份验证的用户关联的WebSocket会话可以订阅用户目的地。在这种情况下,@SendToUser注释的行为与broadcast=false完全相同(也就是说,只针对发送正在处理的消息的会话)。

消息的顺序

来自代理的消息被发布到clientOutboundChannel,从那里它们被写入WebSocket会话。由于通道由ThreadPoolExecutor支持,消息在不同的线程中处理,客户机接收到的结果序列可能与发布的确切顺序不匹配。

要启用有序发布,设置setPreservePublishOrder标志如下:

@Configuration
@EnableWebSocketMessageBroker
public class MyConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	protected void configureMessageBroker(MessageBrokerRegistry registry) {
		//设置了消息代理的发布顺序保留为true
		registry.setPreservePublishOrder(true);
	}

}

这同样适用于来自客户端的消息,这些消息被发送到clientInboundChannel,从那里根据它们的目的地前缀对它们进行处理。因为该通道由ThreadPoolExecutor消息是在不同的线程中处理的,因此处理的顺序可能与接收的顺序不完全一致。

@Configuration
@EnableWebSocketMessageBroker
public class MyConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		//设置了Stomp端点的接收顺序保留为true
		registry.setPreserveReceiveOrder(true);
	}
}

事件

几个ApplicationContext事件被发布,并且可以通过实现SpringApplicationListener接口来接收:

(1)BrokerAvailabilityEvent:指示代理何时可用或不可用。虽然“简单”代理在启动时立即可用,并且在应用程序运行期间保持可用,但STOMP“代理中继”可能会失去与全功能代理的连接(例如,如果重新启动代理)。

@Component
public class MyBrokerAvailabilityListener implements ApplicationListener<BrokerAvailabilityEvent> {

    @Override
    public void onApplicationEvent(BrokerAvailabilityEvent event) {
        boolean isBrokerAvailable = event.isBrokerAvailable();
        
        // 根据消息代理的可用性状态执行相应的逻辑
        if (isBrokerAvailable) {
            // 消息代理可用,执行相应操作
        } else {
            // 消息代理不可用,执行相应操作
        }
    }
}

(2)SessionConnectEvent:当接收到新的STOMP CONNECT时发布,以指示新客户端会话的开始。事件包含表示连接的消息,包括会话ID、用户信息(如果有的话)和客户端发送的任何自定义头。

@Component
public class MySessionConnectListener implements ApplicationListener<SessionConnectEvent> {

    @Override
    public void onApplicationEvent(SessionConnectEvent event) {
        String sessionId = event.getSessionId();
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        Object rawPayload = event.getPayload();
        
        // 根据WebSocket会话连接的信息执行相应的逻辑
        // 例如身份验证、授权等
        // ...
    }
}

(3)SessionConnectedEvent:在SessionConnectEvent发生后不久发布,此时代理已经发送STOMP CONNECTED帧来响应CONNECT。此时,可以认为STOMP会话已经完全建立。

(4)SessionSubscribeEvent:主要用于通知应用程序有关WebSocket会话订阅目的地的信息。

(5)SessionUnsubscribeEvent:主要用于通知应用程序有关WebSocket会话取消订阅目的地的信息。

(6)SessionDisconnectEvent:主要用于通知应用程序有关WebSocket会话断开连接的信息。

拦截

事件为STOMP连接的生命周期提供通知,但不是为每个客户机消息提供通知。应用程序还可以注册ChannelInterceptor来拦截处理链中任何部分的任何消息。下面的例子展示了如何拦截来自客户端的入站消息:

public class MyWebSocketInterceptor implements ChannelInterceptor {
    
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        // 在消息发送前执行逻辑操作,比如日志记录、安全认证、性能监控等
        // 如果不希望继续传递消息,可以返回null或者一个修改后的新消息对象
        return message;
    }

    @Override
    public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
        // 在消息发送完成后执行逻辑操作
    }
    
    @Override
    public boolean preReceive(MessageChannel channel) {
        // 在接收消息前执行逻辑操作,比如权限控制、消息过滤等
        // 如果不希望继续接收消息,可以返回false
        return true;
    }
    
    @Override
    public Message<?> postReceive(Message<?> message, MessageChannel channel) {
        // 在接收消息后执行逻辑操作,比如消息转换、数据处理等
        // 如果不希望继续传递消息,可以返回null或者一个修改后的新消息对象
        return message;
    }

    @Override
    public void afterReceiveCompletion(Message<?> message, MessageChannel channel, Exception ex) {
        // 在接收消息完成后执行逻辑操作
    }
}
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Autowired
    private MyWebSocketInterceptor myWebSocketInterceptor;

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(myWebSocketInterceptor);
    }
}

STOMP客户端

Spring提供了一个STOMP over WebSocket客户端和一个STOMP over TCP客户端。

WebSocketStompClientSpring框架提供的一个用于与WebSocket服务器进行通信的客户端。接下来,您可以建立一个连接,并为STOMP会话提供一个处理程序,如下例所示:

public class Test {
    public static void main(String[] args) {
        List<Transport> transports = new ArrayList<>();
        transports.add(new WebSocketTransport(new StandardWebSocketClient()));
//        WebSocketClient webSocketClient = new StandardWebSocketClient(); //WebSocket方式支持ws和wss
        SockJsClient webSocketClient = new SockJsClient(transports);
        WebSocketStompClient stompClient = new WebSocketStompClient(webSocketClient);
        StompSessionHandler sessionHandler = new MyStompSessionHandler();
        // 连接到WebSocket服务器,并在连接成功后执行回调方法
        stompClient.connect("http://localhost:8088/stomp", sessionHandler);
    }
}

当会话准备就绪可以使用时,会通知处理程序,如下例所示:

public class MyStompSessionHandler extends StompSessionHandlerAdapter {
    @Override
    public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
        // 在连接成功后执行逻辑操作,比如订阅消息
        session.subscribe("/topic/greetings", new MyStompFrameHandler());
        // 发送消息
        session.send("/app/hello", "hello world");
    }

    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        // 处理接收到的消息
        System.out.println("Received message: " + payload);
    }
}

一旦会话建立,任何有效负载都可以发送,并与配置的MessageConverter进行序列化,如下面的示例所示:

session.send("/app/hello", "hello world");

你也可以订阅目的地。订阅方法需要订阅消息的处理程序,并返回可用于取消订阅的订阅句柄。对于每个接收到的消息,处理程序可以指定目标对象类型,有效负载应该被反序列化,如下面的示例所示:

public class MyStompFrameHandler implements StompFrameHandler {

    @Override
    public Type getPayloadType(StompHeaders headers) {
        // 返回消息体的类型
        return String.class;
    }

    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        // 处理接收到的消息
        System.out.println("Received message: " + payload);
    }
}

要启用STOMP心跳,您可以使用TaskScheduler配置WebSocketStompClient,并可选择自定义心跳间隔(写不活动为10秒,这会导致发送心跳,读不活动为10秒,这会关闭连接)。

表演

谈到性能,没有什么灵丹妙药。许多因素会影响它,包括消息的大小和数量、应用程序方法是否执行需要阻塞的工作,以及外部因素(如网络速度和其他问题)。

在消息传递应用程序中,消息通过由线程池支持的异步执行通道传递。配置这样的应用程序需要对通道和消息流有很好的了解。

最明显的起点是配置支持clientInboundChannelclientOutboundChannel。默认情况下,两者都被配置为可用处理器数量的两倍。以下示例显示了一种可能的配置:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
		registration
		.setSendTimeLimit(15 * 1000)//发送消息的最大时间限制
		.setSendBufferSizeLimit(512 * 1024)//发送缓冲区的最大大小限制
		.setTimeToFirstMessage(30000);//建立WebSocket连接后接收第一条消息的超时时间,这里设置为30秒
	}
}

监视

当你使用@EnableWebSocketMessageBroker<websocket:message-broker>时,关键的基础设施组件会自动收集统计数据和计数器,这些数据和计数器提供了对应用程序内部状态的重要洞察。该配置还声明了一个WebSocketMessageBrokerStats类型的bean,它在一个地方收集所有可用信息,并在默认情况下每30分钟在INFO级别记录一次。这个bean可以通过Springmbeanexporters导出到JMX,以便在运行时查看(例如,通过JDKjconsole)。以下是现有资料的总结:

客户端WebSocket会话

(1)当前的:指示当前有多少客户端会话,该计数进一步细分为WebSocketHTTP流和轮询SockJS会话。

(2)总计:指示已建立的会话总数。

(3)STOMP帧:已处理的CONNECTCONNECTEDDISCONNECT帧的总数,表示在STOMP级别上连接的客户端数量。请注意,当会话异常关闭或客户端关闭而不发送DISCONNECT帧时,DISCONNECT计数可能会更低。

(4)TCP连接:指示有多少代表客户端WebSocket会话的TCP连接建立到代理。这应该等于客户端WebSocket会话数+ 1个用于从应用程序内部发送消息的额外共享“系统”连接。

异常关闭

(1)连接失败:已建立但在60秒内未收到任何消息后关闭的会话。这通常是代理或网络问题的指示。

(2)超过发送限制:会话在超过配置的发送超时或发送缓冲区限制后关闭,这可能发生在速度较慢的客户端(请参阅前一节)。

(3)传输错误:会话在传输错误后关闭,例如无法读取或写入WebSocket连接或HTTP请求或响应。

客户端入站通道:来自支持clientInboundChannel的线程池的统计信息,可以洞察传入消息处理的运行状况。在这里排队的任务表明应用程序可能太慢而无法处理消息。如果存在I/O绑定任务(例如,缓慢的数据库查询、对第三方REST APIHTTP请求等),请考虑增加线程池大小。

客户端出站通道:来自支持clientOutboundChannel的线程池的统计信息,该线程池可洞察向客户机广播消息的运行状况。在这里排队的任务表明客户机速度太慢,无法使用消息。解决这个问题的一种方法是增加线程池大小,以容纳预期的并发慢速客户端数量。另一个选项是减少发送超时和发送缓冲区大小限制(参见前一节)。

SockJS任务调度程序:来自用于发送心跳的SockJS任务调度程序的线程池的统计信息。注意,当在STOMP级别上协商心跳时,SockJS心跳是禁用的。

测试

当您使用SpringSTOMP-over-WebSocket支持时,有两种主要的方法来测试应用程序。第一种是编写服务器端测试来验证控制器及其带注释的消息处理方法的功能。第二种是编写完整的端到端测试,包括运行客户机和服务器。

服务器端测试最简单的形式是编写控制器单元测试。然而,这还不够有用,因为控制器做的很多事情都依赖于它的注释。纯单元测试根本无法测试这一点。

理想情况下,被测试的控制器应该在运行时被调用,就像使用Spring MVC Test框架测试处理HTTP请求的控制器的方法一样——也就是说,不运行Servlet容器,而是依靠Spring框架来调用带注释的控制器。

案例一:发送指定用户消息

WebSocket文章中讲解过此案例,现在我们使用STOMP方式在进行讲解。

使用STOMP发送消息给指定用户,您可以使用SimpMessagingTemplate类来实现(前面#发送消息内容里介绍过)。

实现一对一通信的关键就是订阅地址是动态的,我们可以使用 convertAndSendh() 方法和convertAndSendToUser()方法。

  • convertAndSend() 方法

后台使用ModelAndView,跳转页面,使用uuid模拟userid,示例代码如下:

@Controller
public class MyController {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;
    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        modelAndView.addObject("userId", UUID.randomUUID().toString());
        return modelAndView;
    }
    @MessageMapping("/hello")
    public void sendMessage(String content) {
        try {
            JSONObject parse = JSONObject.parse(content);
            String userId = parse.getString("userId");
            String message = parse.getString("message");
            simpMessagingTemplate.convertAndSend("/topic/greetings/"+userId,message);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

前端页面通过stompClient.subscribe订阅动态地址/topic/greetings/[[${userId}]][[${userId}]]Thymeleaf的使用方式,后端传入的动态值。

<!DOCTYPE html>
<html lang="en"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="userid" placeholder="用户id">
    <input type="text" id="content" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<div>用户id:<span id="loadUserId" th:text="${userId}"></span></div>

<button onclick="disconnect()">关闭连接</button>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    function connect(){
        stompClient.connect({}, function(frame) {
            // 连接成功后的回调函数
            console.log('连接成功: ' + frame);
            //订阅
            stompClient.subscribe('/topic/greetings/[[${userId}]]', function (greeting) {
                console.log(greeting)
                showGreeting(greeting.body);
            });
        });
    }
    //关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var userId = document.getElementById('userid').value;
        var message = document.getElementById('content').value;
        //发送消息
        stompClient.send("/app/hello", {}, JSON.stringify({'message': message,'userId': userId}));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
    connect();
</script>
</body>
</html>

效果展示

在这里插入图片描述

  • convertAndSendToUser()方法

(1)如果你使用的是Spring Security结合WebSocket,可以使用以下代码:

@Controller
public class MyController {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;
    
    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        modelAndView.addObject("userId", UUID.randomUUID().toString());
        return modelAndView;
    }
    @MessageMapping("/hello")
    public void sendMessage(String content) {
        try {
            JSONObject parse = JSONObject.parse(content);
            String userId = parse.getString("userId");
            String message = parse.getString("message");
            simpMessagingTemplate.convertAndSendToUser(userId,"/queue/greetings",message);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

通过/user/{userId}/topic/greetings订阅路径可以让特定的用户订阅消息。示例代码如下:

<!DOCTYPE html>
<html lang="en"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="userid" placeholder="用户id">
    <input type="text" id="content" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<div>用户id:<span id="loadUserId" th:text="${userId}"></span></div>

<button onclick="disconnect()">关闭连接</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    function connect(){
        stompClient.connect({}, function(frame) {
            // 连接成功后的回调函数
            console.log('连接成功: ' + frame);
            //订阅
            stompClient.subscribe(`/user/${userId}/topic/greetings`, function (greeting) {
                console.log(greeting)
                showGreeting(greeting.body);
            });
        });
    }
    //关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var userId = document.getElementById('userid').value;
        var message = document.getElementById('content').value;
        //发送消息
        stompClient.send("/app/hello", {}, JSON.stringify({'message': message,'userId': userId}));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
    connect();
</script>
</body>
</html>

(2)另一种简单的方式是通过实现SessionConnectedEvent事件监听请求头的userId,将对应的Session ID保存起来,Session IDWebSocket会话关联,并且能够正确地传递给后端服务器。然后在后端使用SimpMessagingTemplate.convertAndSendToUser()方法时,将Session ID作为用户名参数传入。

示例代码如下:

@Component
public class MySessionConnectedListener implements ApplicationListener<SessionConnectedEvent> {

    private Map<String,String> user = new HashMap<>();
    @Override
    public void onApplicationEvent(SessionConnectedEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        MessageHeaders messageHeaders = accessor.getMessageHeaders();
        GenericMessage genericMessage = messageHeaders.get("simpConnectMessage", GenericMessage.class);
        Map<String, Object> nativeHeaders = genericMessage.getHeaders().get("nativeHeaders", Map.class);
        List<String> list = (List)nativeHeaders.get("userId");
        //下面将通过前端传递一个key为userId的请求头
        String sessionId = accessor.getSessionId();
        //map存储userId和sessionId
        user.put(list.get(0), sessionId);
    }

    public String getStompHeaderAccessor(String userId){
        return user.get(userId);
    }

    public List<String> getAllUserId(){
        return user.keySet().stream().collect(Collectors.toList());
    }
}
@Controller
public class MyController {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;
    @Autowired
    private MySessionConnectedListener mySessionConnectedListener;
    @GetMapping(value = "/stomp")
    @ResponseBody
    public ModelAndView stomp(HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView("stomp");
        modelAndView.addObject("userId", UUID.randomUUID().toString());
        return modelAndView;
    }
    @MessageMapping("/hello")
    public void sendMessage(String content) {
        try {
            JSONObject parse = JSONObject.parse(content);
            String userId = parse.getString("userId");
            String message = parse.getString("message");
            //通过userId获取保存的sessionId
            String sessionId = mySessionConnectedListener.getStompHeaderAccessor(userId);
            SimpMessageHeaderAccessor header = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
            //把sessionId放到请求头中
            header.setSessionId(sessionId);
            header.setLeaveMutable(true);
            simpMessagingTemplate.convertAndSendToUser(sessionId,"/topic/greetings",message,header.getMessageHeaders());
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

前端在stompClient.connect连接时,请求头中加入userId,然后stompClient.subscribe订阅/user/topic/greetings,示例代码如下:

<!DOCTYPE html>
<html lang="en"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Stomp</title>
</head>
<body>
<div id="message"></div>
<div>
    <input type="text" id="userid" placeholder="用户id">
    <input type="text" id="content" placeholder="输入文本内容">
    <button onclick="sendName()">提交</button>
</div>
<div>用户id:<span id="loadUserId" th:text="${userId}"></span></div>

<button onclick="disconnect()">关闭连接</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
    var socket = new SockJS('http://localhost:8088/stomp');
    var stompClient = Stomp.over(socket);
    function connect(){
        stompClient.connect({'userId':'[[${userId}]]'}, function(frame) {
            // 连接成功后的回调函数
            console.log('连接成功: ' + frame);
            //订阅
            stompClient.subscribe(`/user/topic/greetings`, function (greeting) {
                console.log(greeting)
                showGreeting(greeting.body);
            });
        });
    }
    //关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        console.log("Disconnected");
    }

    function sendName() {
        var userId = document.getElementById('userid').value;
        var message = document.getElementById('content').value;
        //发送消息
        stompClient.send("/app/hello", {}, JSON.stringify({'message': message,'userId': userId}));
    }
    function showGreeting(message) {
        document.getElementById("message").innerHTML += "<p>" + message + "</p>";
    }
    connect();
</script>
</body>
</html>

效果展示

在这里插入图片描述
但这通常不符合标准的安全实践,建议还是使用已认证的用户ID来进行消息路由。

关联文章

全双工通信协议:WebSocket

  • 24
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值