精通Spring Boot 3 : 10. 使用 Spring Boot 进行消息通信 (6)

基于 Spring Boot 的 WebSockets

WebSockets 是我最喜欢的技术之一,因为它可以实现近乎实时的事件处理。WebSockets 是一种通信协议,提供基于 TCP 的全双工通道。WebSocket 协议定义了文本和二进制两种消息类型,通常与子协议的定义一起使用,以便发送和接收消息。

这个协议中最有趣的部分之一是客户端与服务器之间的握手过程。客户端首先发送一个带有特殊头部的 HTTP 请求,这样可以通过 TCP 使用客户端和服务器之间的唯一哈希密钥建立直接连接。随后,通信可以通过发送和接收 WebSocket 数据帧开始。这就是幕后发生的情况:

Client request:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZSB3aGljaCBrZXk=
Server response:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzh5fWusIqFw=

在本节中,我们将使用 Spring 的 STOMP(简单文本导向消息协议)支持(作为子协议),并探讨 Spring WebSocket 如何作为 STOMP 中介,为使用 Spring Boot 的客户端提供服务。

在用户应用中添加 WebSockets

要在用户应用程序中添加 WebSockets,只需添加 spring-boot-starter-websocket 依赖项,这样 Spring Boot 就会自动配置所有必要的默认设置,从而提供一个 WebSocket 通信框架,让您能够轻松创建事件驱动和实时应用程序。

我们需要添加一些额外的依赖项,因此请打开 build.gradle 文件。请查看清单 10-28。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.webjars:webjars-locator-core'
    implementation 'org.webjars:sockjs-client:1.5.1'
    implementation 'org.webjars:stomp-websocket:2.3.4'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.postgresql:postgresql'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // Web
    implementation 'org.webjars:bootstrap:5.2.3'
    implementation 'org.webjars:jquery:3.7.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}

列表 10-28 的 build.gradle 文件

列表 10-28 显示我们只使用了 spring-boot-starter-websocket,所有的 web 依赖都包含在 websocket 依赖中,因此无需单独声明。此外,我们还使用了 sockjs-client 和 stomp-websocket,这将有助于用户界面(客户端)部分。我们将发送 JSON 消息,因此还需要 jackson-datatype-jsr310 依赖。

接下来,打开或创建 UserSocket 类。请参阅列表 10-29。

package com.apress.users.web.socket;
import lombok.AllArgsConstructor;
import org.springframework.messaging.core.MessageSendingOperations;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@AllArgsConstructor
@Component
public class UserSocket {
    private MessageSendingOperations<String> messageSendingOperations;
    public void userLogSocket(Map<String,Object> event){
        Map<String, Object> map = new HashMap<>(){{
            put("event",event);
            put("version","1.0");
            put("time",LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        }};
        this.messageSendingOperations.convertAndSend("/topic/user-logs",map);
    }
}

文件 10-29src/main/java/apress/com/users/web/socket/UserSocket.java

UserSocket 类中一个重要的元素是 MessageSendingOperations 接口,它属于 spring-message 依赖中的 org.springframework.messaging.core 包。这个接口提供了一种通用的消息发送方式,无论使用何种技术,都可以将其视为一种低级通信。你可以根据需要使用任何协议——实际上,Spring JMS 和 Spring AMQP 分别利用这个接口来专门化 JmsMessageOperations 和 RabbitMessageOperations 接口,以实现消息发送。

你可以看到我们发送的只是一个 Map 作为消息或事件;实际上你可以发送任何内容,但我想向你展示你可以使用基本的类,比如 Map。

接下来,打开或创建 UserSocketConfiguration 类。正如你所想,我们需要进行一些配置。请参见清单 10-30。

package com.apress.users.web.socket;
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 UserSocketConfiguration implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/logs").withSockJS();
    }
}

文件路径:src/main/java/apress/com/users/web/socket/UserSocketConfiguration.java(列表 10-30)

UserSocketConfiguration 类包含以下内容:

  • @Configuration:我们需要将这个类标记为应用启动时的配置部分,因此需要使用这个注解。
  • 我们需要这个注解,因为我们将通过 WebSocket 使用更高级的消息子协议进行基于代理的消息传递;换句话说,我们将充当处理 WebSocket 上 STOMP 协议的代理。
  • WebSocketMessageBrokerConfigurer:这个接口非常实用,因为它定义了通过简单协议(如 STOMP)配置消息处理的方法。通过这个实现,我们可以定义消息转换器、自定义返回值处理器、STOMP 端点,以及使用 spring-messaging 核心中的 MessageChannel 等功能。
  • 消息代理注册:这是 ConfigureMessageBroker 方法实现中的一个参数,帮助配置消息代理选项。在这种情况下,我们将代理的目标前缀设置为 /topic,应用程序的目标前缀设置为 /app。
  • StompEndpointRegistry:StompEndpointRegistry 参数是用于注册 STOMP over WebSocket 端点的协议,在此情况下指的是/logs 端点。

这就完成了后端部分。没错,就是这样!接下来,我们来看看用户界面。

在用户应用中使用 WebSocket 客户端接收事件

接下来,打开或创建一个名为 app.js 的文件,该文件将包含客户端代码。请参见列表 10-31。

let stompClient = null;
function connect() {
      let socket = new SockJS('/logs');
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (frame) {
                console.log('Connected: ' + frame);
                stompClient.subscribe('/topic/user-logs', function (response) {
                        console.log(response);
                        showLogs(response.body);
                });
    });
}
function showLogs(message) {
    $("#logs").append("<tr><td>" + message + "</td></tr>");
}
$(function () {
   connect();
});

列表 10-31 源代码:src/main/resources/static/js/app.js

app.js 文件是一个普通的 JavaScript 文件,我们在其中使用了 jQuery 库。我们使用了来自 socks-client JavaScript 依赖的 SockJS 类。SockJS 类使用了我们在 UserSocketConfiguration 类中定义的 /logs 端点(清单 10-30)。我们还创建了 Stomp 客户端,这使我们能够连接并订阅任何传入的消息到 /topic/user-logs 目标;这在 UserSocketConfiguration 类和 UserSocket 类中进行了配置(清单 10-29),当时我们调用了 convertAndSend 方法。一旦获取到用户事件,我们会使用 showLogs 方法并将其附加到 #logs HTML 元素(这是一个 <div> 元素)。</div>

接下来,打开或创建 index.html 文件。请参阅清单 10-32。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" type="text/css"
          href="webjars/bootstrap/5.2.3/css/bootstrap.min.css">
    <script src="/webjars/jquery/3.7.1/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/1.5.1/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/2.3.4/stomp.min.js"></script>
    <script src="/js/app.js"></script>
    <title>Welcome - Users App</title>
</head>
<body class="d-flex h-100 text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
    <header class="mb-auto">
        <div>
            <h3 class="float-md-start mb-0">Users</h3>
        </div>
    </header>
    <main class="px-3">
        <h1>Simple Users Rest Application</h1>
        <p class="lead">This is a simple Users app where you can access any information from a user</p>
        <p class="lead">
            <a href="/users">Get All Users</a>
        </p>
    </main>
    <footer class="mt-auto text-black-50">
        <p>Powered by Spring Boot 3</p>
        <table>
            <div id="logs">
            </div>
        </table>
    </footer>
</div>
</body>
</html>

列表 10-32 源文件:src/main/resources/static/index.html

列表 10-32 显示,我们正在使用嵌入在 org.webjars 依赖中的 jQuery、sockjs 和 stomp 库。同时,我们使用一个 id 为 logs 的<div>元素,用于附加从服务器接收到的用户事件消息。</div>

使用 WebSockets 启动用户应用程序

现在,我们准备好实际演示使用 WebSockets 的用户应用程序了。请从命令行或您的 IDE 启动用户应用程序,然后在浏览器中输入 http://localhost:8080。接着,打开网页开发者控制台,所有浏览器都有这个功能。

网络开发者控制台应显示类似于图 10-17 的内容。


如图 10-17 所示,您应该能看到自己已连接到服务器以及目标/topic/user-logs。现在,如果您通过命令行添加和删除用户:

curl -i -s -d '{"name":"Dummy","email":"dummy@email.com","password":"aw2s0meR!","userRole":["INFO"],"active":true}' \
-H "Content-Type: application/json" \
http://localhost:8080/users
curl -i -s -XDELETE http://localhost:8080/users/dummy@email.com

你应该在网页开发者控制台中看到类似于图 10-18 的内容。

图 10-18 展示了添加和删除用户的结果。请注意,消息是以 JSON 格式发送的。

在我的复古应用中使用 WebSockets 处理事件

我学生们最常问的问题之一是,如果我们可以从其他应用程序获取数据,那么我们能否在我的复古应用中使用 WebSockets 来接收用户事件?当然可以,我们来试试吧。

如果你是从零开始,可以按照前面章节的相同流程进行。请确保添加 spring-boot-starter-websocket 依赖项,以及我们在这个项目中使用的其他依赖项。你可以使用 10-messaging-websocket/myretro 文件夹中的代码。

打开 build.gradle 文件,参见列表 10-33。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
    mavenCentral()
}
dependencies {
    implementation ‘org.springframework.boot:spring-boot-starter-websocket’
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation ‘com.fasterxml.jackson.datatype:jackson-datatype-jsr310’
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.postgresql:postgresql'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}

列表 10-33 build.gradle 文件

接下来,打开或创建事件类和用户事件类。请参阅列表 10-34 和 10-35。

package com.apress.myretro.web.socket;
import lombok.Data;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
@Data
public class Event {
    private String version;
@JsonFormat(shape=JsonFormat.Shape.STRING,pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime time;
    private UserEvent event;
}

10-34 src/main/java/com/apress/myretro/web/socket/Event.java

package com.apress.myretro.web.socket;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.time.LocalDateTime;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class UserEvent {
    private String email;
    private boolean active;
    private String action;
    @JsonFormat(shape=JsonFormat.Shape.STRING,pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime datetime;
}

10-35 src/main/java/com/apress/myretro/web/socket/UserEvent.java

我们需要创建 Event 和 UserEvent 类,因为我们正在发送复合事件消息。此外,我们需要使用 @JsonFormat 注解,以确保从用户服务中获取标准的数据时间格式。

接下来,打开或创建消费者,即 UserSocketClient 类。请参阅清单 10-36。

package com.apress.myretro.web.socket;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class UserSocketClient extends StompSessionHandlerAdapter {
    @Override
    public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
        log.info("Client connected: headers {}", connectedHeaders);
        session.subscribe("/topic/user-logs", this);
    }
    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        log.info("Client received: payload {}, headers {}", payload, headers);
    }
    @Override
    public void handleException(StompSession session, StompCommand command,
                                StompHeaders headers, byte[] payload, Throwable exception) {
        log.error("Client error: exception {}, command {}, payload {}, headers {}",
                exception.getMessage(), command, payload, headers);
    }
    @Override
    public void handleTransportError(StompSession session, Throwable exception) {
        log.error("Client transport error: error {}", exception.getMessage());
    }
}

文件路径:10-36src/main/java/com/apress/myretro/web/socket/UserScoketClient.java

UserSocketClient 类非常重要,因为我们需要处理会话、管理异常并接收消息(有效载荷);可以将这个类视为获取 WebSocket 帧信息的底层方式。让我们来一起回顾一下:

  • 我们需要将这个类作为 Spring bean,因此必须使用 @Component 注解对其进行标记。
  • StompSessionHandlerAdapter:这是一个抽象适配器,没有默认实现,因为通常您需要处理会话和套接字帧。这个抽象类实现了 StompSessionHandler 接口,因此您需要定义如何处理来自帧或会话的异常和错误,以及在连接成功后(afterConnected 方法)需要执行的其他操作。
  • StompSession:该接口将在后台实现,并依赖于提供的配置。此接口用于发送或接收消息以及创建订阅。在这种情况下,afterConnected 方法使用 StompSession 订阅 /topic/users-logs 主题。
  • StompHeaders:这与我们在前面章节中看到的 Headers 消息相同。这个类将实现一个 MultiValueMap 接口,包含一些有用的信息,如内容类型、内容长度、收据、主机等。这些属性对于协议是必需的。

接下来,打开或创建 UserSocketMessageConverter 类。请参阅第 10-37 号列表。

package com.apress.myretro.web.socket;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class UserSocketMessageConverter implements MessageConverter {
    public Object fromMessage(Message<?> message, Class<?> targetClass) {
        ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
        Event userEvent = null;
        try {
            String m = new String((byte[]) message.getPayload());
            userEvent = mapper.readValue(m, Event.class);
        }catch(Exception ex){
            log.error("Cannot Deserialize - {}",ex.getMessage());
        }
        return userEvent;
    }
    @Override
    public Message<?> toMessage(Object payload, MessageHeaders headers) {
        throw new UnsupportedOperationException();
    }
}

10-37 src/main/java/com/apress/myretro/web/socket/UserSocketMessageConverter.java

UserSocketMessageConverter 类包含以下内容:

  • 我们需要将这个自定义的 MessageConverter 作为 Spring bean,因为我们稍后会注册它,因此需要用 @Component 注解来标记它。
  • 正如您所知,这就是我们创建自定义 MessageConverter 的方法。我们只需实现 fromMessage 方法,在该方法中注册 JavaTimeModule,以便在反序列化时处理 LocalDateTime 类型。

接下来,打开或创建 UserSocketConfiguration 类。请参阅清单 10-38。

package com.apress.myretro.web.socket;
import com.apress.myretro.config.RetroBoardProperties;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.stomp.StompSessionHandler;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import org.springframework.web.socket.sockjs.client.RestTemplateXhrTransport;
import org.springframework.web.socket.sockjs.client.SockJsClient;
import org.springframework.web.socket.sockjs.client.Transport;
import org.springframework.web.socket.sockjs.client.WebSocketTransport;
import java.util.ArrayList;
import java.util.List;
@AllArgsConstructor
@Configuration
public class UserSocketConfiguration {
    RetroBoardProperties retroBoardProperties;
    @Bean
    public WebSocketStompClient webSocketStompClient(WebSocketClient webSocketClient, UserSocketMessageConverter userSocketMessageConverter,
                                                     StompSessionHandler userSocketClient) {
        WebSocketStompClient webSocketStompClient = new WebSocketStompClient(webSocketClient);
        webSocketStompClient.setMessageConverter(userSocketMessageConverter);
        webSocketStompClient.connect(retroBoardProperties.getUsersService().getHostname() + retroBoardProperties.getUsersService().getBasePath(), userSocketClient);
        return webSocketStompClient;
    }
    @Bean
    public WebSocketClient webSocketClient() {
        List<Transport> transports = new ArrayList<>();
        transports.add(new WebSocketTransport(new StandardWebSocketClient()));
        transports.add(new RestTemplateXhrTransport());
        return new SockJsClient(transports);
    }
}

文件路径:src/main/java/com/apress/myretro/web/socket/UserSocketConfiguration.java(列表 10-38)

UserSocketConfiguration 类将帮助我们建立与用户应用的连接。我们需要一个客户端,这里是一个 WebSocket/STOMP 客户端,让我们来详细了解一下这个类:

  • RetroBoardProperties:这个类用于绑定外部属性,以便我们可以保存远程服务器(主机名)及其路径(基本路径)信息。在这种情况下,我们需要在 application.properties 文件中或作为环境变量来声明这些属性(这取决于你的选择)。
  • StompSessionHandler:这个类定义了自定义的 StompSessionHandler,在这里是 UserSocketClient 类(见清单 10-34);这个类是 WebSocketStompClient 构造函数所必需的。
  • WebSocketClient:这个接口至关重要,因为它负责发起 WebSocket 请求。它需要一个传输列表,这些传输将负责管理协议——在这种情况下,是基于 RestTemplate 的 SockJS,我们之前已经讨论过。
  • WebSocketStompClient:该类使用 STOMP over WebSocket 协议,并通过 WebSocketClient 进行连接。这是协议处理程序的核心,生命周期从这里开始。它的构造函数需要会话处理程序(见清单 10-34),并且这个类通过调用 connect 方法并传递 URI(基于 RetroBoardProperties 值的远程服务器)来连接 WebSocket 服务器,连接方式为 WebSocketClient。

在 UserSocketConfiguration 中,我们使用了 RetroBoardProperties 类来绑定在 application.properties 文件中声明的属性。因此,请打开或创建 application.properties 文件。请参见清单 10-39。

# Port
server.port=${PORT:8081}
# Data
spring.h2.console.enabled=true
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
#spring.jpa.show-sql=true
# My Retro Properties
myretro.users-service.hostname=http://localhost:8080
myretro.users-service.base-path=/logs

列表 10-39 源文件:src/main/resources/application.properties

列表 10-39 展示了连接到用户应用程序中的 WebSockets/STOMP 服务器所需的 users-service.*属性。

通过 WebSockets/Stomp 运行我的复古应用

现在是时候同时运行这两个应用程序,看看它们如何相互作用了。请确保用户应用程序已经启动并在 8080 端口上运行。接下来,启动我的复古应用程序。一旦它运行起来,请查看用户应用程序的控制台;最后的输出应该类似于以下内容:

WebSocketSession[1 current WS(1)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 6], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 1], sockJsScheduler[pool size = 8, active threads = 1, queued tasks = 2, completed tasks = 428]
WebSocketSession[1 current WS(1)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 6], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 1], sockJsScheduler[pool size = 8, active threads = 1, queued tasks = 2, completed tasks = 861]

这表示用户应用与我的复古应用之间已经建立了连接。现在,请使用之前的命令发送一个新用户:

curl -i -s -d '{"name":"Dummy","email":"dummy@email.com","password":"aw2s0meR!","userRole":["INFO"],"active":true}' \
-H "Content-Type: application/json" \
http://localhost:8080/users

使用此命令,您将在我的复古应用程序控制台中看到如下输出:

2023-10-27T17:01:01.811-04:00  INFO 53760 --- [lient-AsyncIO-2] c.a.myretro.web.socket.UserSocketClient  : Client received: payload Event(version=1.0, time=2023-10-27T17:00:53, event=UserEvent(email=dummy@email.com, active=false, action=ACTIVATION_STATUS, datetime=null)), headers {destination=[/topic/user-logs], content-type=[application/json], subscription=[0], message-id=[c0396b652a9f42c3884d9f4666b8115d-4], content-length=[127]}

你正在获取有效载荷,这里指的是事件类型。如果你去掉用户:

curl -I -s -XDELETE http://localhost:8080/users/dummy@email.com

你将会得到类似这样的东西:

2023-10-27T17:01:55.261-04:00  INFO 53760 --- [lient-AsyncIO-5] c.a.myretro.web.socket.UserSocketClient  : Client received: payload Event(version=1.0, time=2023-10-27T17:01:55, event=UserEvent(email=dummy@email.com, active=false, action=REMOVED, datetime=2023-10-27T17:01:55)), headers {destination=[/topic/user-logs], content-type=[application/json], subscription=[0], message-id=[c0396b652a9f42c3884d9f4666b8115d-5], content-length=[134]}

太好了!现在你知道如何在应用程序之间使用 WebSockets 了。