文章目录
通信协议框架Socket.IO基础特性
1. Maven依赖的添加
<netty-socketio.version>2.0.6</netty-socketio.version>
<dependency>
<groupId>com.corundumstudio.socketio</groupId>
<artifactId>netty-socketio</artifactId>
<version>${netty-socketio.version}</version>
</dependency>
2. 基础消息收发场景
本节介绍了在客户端和服务器之间进行基础消息传递的场景。这包括简单的客户端到服务器以及服务器到客户端的消息推送。
2.1 Nacos配置的更新
# netty-socketio 配置
socketio:
# 域名
host: ws.echatsoftuat.com
# 端口
port: 9092
# 连接数大小
workCount: 100
# 允许客户请求
allowCustomRequests: true
# 协议升级超时时间(毫秒),默认10秒。HTTP握手升级为ws协议超时时间
upgradeTimeout: 1000000
# Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
pingTimeout: 6000000
# Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
pingInterval: 25000
# 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
maxFramePayloadLength: 1048576
# 设置http交互最大内容长度
maxHttpContentLength: 1048576
2.2 Spring boot相关代码实现
spring boot启动配置类
package com.rainbowred.websocket.service;
import com.corundumstudio.socketio.SocketIOServer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
/**
* @author wangyitao
* @date 2023/11/23.
*/
@SpringBootApplication(scanBasePackages = {"com.rainbowred.websocket.service", "com.rainbowred.commons"})
@MapperScan("com.rainbowred.commons.mybatis.mapper")
public class WebsocketServiceApplication {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(WebsocketServiceApplication.class, args);
SocketIOServer server = context.getBean(SocketIOServer.class);
server.start();
}
@EventListener
public void onApplicationEvent(ContextClosedEvent event) {
SocketIOServer socketioServer = event.getApplicationContext().getBean(SocketIOServer.class);
if (socketioServer != null) {
socketioServer.stop();
}
}
}
socketio启动配置类
package com.rainbowred.websocket.service.socketio;
import com.corundumstudio.socketio.SocketIOServer;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.handler.MessageEventHandler;
import com.rainbowred.websocket.service.socketio.listener.ConnectionListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author wangyitao
* @date 2024/1/5.
*/
@Configuration
@Slf4j
public class SocketioConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Value("${socketio.maxFramePayloadLength}")
private int maxFramePayloadLength;
@Value("${socketio.maxHttpContentLength}")
private int maxHttpContentLength;
@Autowired
ConnectionListener connectionListener;
@Autowired
MessageEventHandler messageEventHandler;
@Bean
public SocketIOServer socketioServer() {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
// 配置域名和端口
config.setHostname(host);
config.setPort(port);
// 开启socket端口复用
com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
socketConfig.setReuseAddress(true);
config.setSocketConfig(socketConfig);
// 连接数大小
config.setWorkerThreads(workCount);
// 允许客户请求
config.setAllowCustomRequests(allowCustomRequests);
// 协议升级超时时间(毫秒),默认10秒,HTTP握手升级为ws协议超时时间
config.setUpgradeTimeout(upgradeTimeout);
// Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
config.setPingTimeout(pingTimeout);
// Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
config.setPingInterval(pingInterval);
// 设置HTTP交互最大内容长度
config.setMaxFramePayloadLength(maxFramePayloadLength);
// 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
config.setMaxHttpContentLength(maxHttpContentLength);
SocketIOServer server = new SocketIOServer(config);
// 使用事件处理器
server.addConnectListener(connectionListener.connectListener());
server.addDisconnectListener(connectionListener.disconnectListener());
server.addEventListener(WebsocketConstants.EVENT_MESSAGE, String.class, messageEventHandler.messageListener());
return server;
}
}
connect监听器
package com.rainbowred.websocket.service.socketio.listener;
import com.corundumstudio.socketio.listener.ConnectListener;
import com.corundumstudio.socketio.listener.DisconnectListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* ws链接监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class ConnectionListener {
/**
* clent创建链接
*
* @return
*/
public ConnectListener connectListener() {
return client -> {
log.info("Client connected:{}", client.getSessionId());
};
}
/**
* clent断开链接
*
* @return
*/
public DisconnectListener disconnectListener() {
return client -> {
log.info("Client disconnected:{}", client.getSessionId());
};
}
}
message事件处理器
package com.rainbowred.websocket.service.socketio.handler;
import com.corundumstudio.socketio.listener.DataListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 消息事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class MessageEventHandler {
/**
* 消息监听器
*
* @return
*/
public DataListener<String> messageListener() {
return (client, message, ackRequest) -> {
log.info("Received message from client:{}, message:{}", client.getSessionId(), message);
};
}
}
2.3 使用Postman创建客户端并发送消息
2.4 服务器向客户端发送消息
新增controller模拟服务器消息
@PostMapping("/message")
public HttpResult<Void, Void> socketioSendMessage(@RequestBody String message) {
log.info("socketio send message:{}", message);
socketioServer.getBroadcastOperations().sendEvent("message", message);
return HttpResult.success();
}
}
3. 房间(Room)
Socket.IO的房间(Room)是一种强大的特性,用于实现分组通信,使得消息可以发送给特定的一个或一组客户端。这在需要将通信限制在特定用户组(如聊天室、协作会议或多人游戏)的应用中非常有用。
3.1 房间的工作原理
- 创建与加入:在Socket.IO中,并不需要显式创建房间。当一个客户端请求加入一个还不存在的房间时,该房间会自动被创建。客户端可以同时加入多个房间。
- 消息发送:一旦加入房间,客户端可以接收发送到该房间的所有消息。服务器可以向特定房间的所有客户端发送消息,而不是广播给所有连接的客户端。
- 离开房间:客户端可以随时离开房间,此后将不会再接收到该房间的消息。
3.2 Spring boot相关代码实现
新增room事件处理器
package com.rainbowred.websocket.service.socketio.handler;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.DataListener;
import com.rainbowred.commons.constants.Constants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* room事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class RoomEventHandler {
/**
* client进入room监听器
*
* @return
*/
public DataListener<String> joinRoomListener() {
return (client, roomName, ackRequest) -> {
client.joinRoom(roomName);
log.info("Client:{}, joined room: {}", client.getSessionId(), roomName);
};
}
/**
* clent离开room监听器
*
* @return
*/
public DataListener<String> leaveRoomListener() {
return (client, roomName, ackRequest) -> {
client.leaveRoom(roomName);
log.info("Client:{}, left room: {}", client.getSessionId(), roomName);
};
}
}
更新message事件处理器
package com.rainbowred.websocket.service.socketio.handler;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.DataListener;
import com.rainbowred.commons.constants.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 消息事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class MessageEventHandler {
/**
* 消息监听器
*
* @return
*/
public DataListener<String> messageListener(SocketIOServer server) {
return (client, message, ackRequest) -> {
String roomName = extractRoomNameFromMessage(message);
String actualMessage = extractActualMessage(message);
if (StringUtils.isEmpty(roomName) || StringUtils.isEmpty(actualMessage)) {
log.info("Received message from client:{}, message:{}", client.getSessionId(), message);
return;
}
server.getRoomOperations(roomName).sendEvent("message", actualMessage);
log.info("Message send in room:{}, message:{}", roomName, actualMessage);
};
}
/**
* 从消息中提取房间名称。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 房间名称
*/
public static String extractRoomNameFromMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
return message.split(":")[0];
}
/**
* 从消息中提取实际消息内容。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 消息内容
*/
public static String extractActualMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
String[] parts = message.split(":", 2);
return parts.length > 1 ? parts[1] : null;
}
}
更新socketio启动配置类
package com.rainbowred.websocket.service.socketio;
import com.corundumstudio.socketio.SocketIOServer;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.handler.MessageEventHandler;
import com.rainbowred.websocket.service.socketio.handler.RoomEventHandler;
import com.rainbowred.websocket.service.socketio.listener.ConnectionListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author wangyitao
* @date 2024/1/5.
*/
@Configuration
@Slf4j
public class SocketioConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Value("${socketio.maxFramePayloadLength}")
private int maxFramePayloadLength;
@Value("${socketio.maxHttpContentLength}")
private int maxHttpContentLength;
@Autowired
ConnectionListener connectionListener;
@Autowired
MessageEventHandler messageEventHandler;
@Autowired
RoomEventHandler roomEventHandler;
@Bean
public SocketIOServer socketioServer() {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
// 配置域名和端口
config.setHostname(host);
config.setPort(port);
// 开启socket端口复用
com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
socketConfig.setReuseAddress(true);
config.setSocketConfig(socketConfig);
// 连接数大小
config.setWorkerThreads(workCount);
// 允许客户请求
config.setAllowCustomRequests(allowCustomRequests);
// 协议升级超时时间(毫秒),默认10秒,HTTP握手升级为ws协议超时时间
config.setUpgradeTimeout(upgradeTimeout);
// Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
config.setPingTimeout(pingTimeout);
// Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
config.setPingInterval(pingInterval);
// 设置HTTP交互最大内容长度
config.setMaxFramePayloadLength(maxFramePayloadLength);
// 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
config.setMaxHttpContentLength(maxHttpContentLength);
SocketIOServer server = new SocketIOServer(config);
// 使用事件处理器
server.addConnectListener(connectionListener.connectListener());
server.addDisconnectListener(connectionListener.disconnectListener());
server.addEventListener(WebsocketConstants.EVENT_MESSAGE, String.class, messageEventHandler.messageListener(server));
server.addEventListener(WebsocketConstants.EVENT_JOINROOM, String.class, roomEventHandler.joinRoomListener());
server.addEventListener(WebsocketConstants.EVENT_LEAVEROOM, String.class, roomEventHandler.leaveRoomListener());
return server;
}
}
3.3 使用Postman创建客户端
创建客户端1,并加入room1
创建客户端2,并加入room1
创建客户端3,并加入room2
客户端1,发送消息
原因分析:Room(房间)通常用于更细粒度的分组,比如一个聊天应用中的不同聊天室。与命名空间不同,房间不需要事先创建。当客户端请求加入一个房间时,如果该房间不存在,Socket.IO 会自动创建它。这为动态的客户端分组提供了便利,无需服务器端预先定义。
4. 命名空间(Namespace)
Socket.IO的命名空间(Namespace)是一种将Socket.IO服务器的连接分割成不同的逻辑分区或通道的机制。命名空间允许您在同一个物理服务器上构建多个独立的通信通道,每个通道都有自己的事件和房间,但共享同一个底层连接。这对于需要处理多种不同类型通信的复杂应用非常有用。
4.1 命名空间的工作原理
- 默认命名空间:在Socket.IO中,默认的命名空间是/。如果没有指定命名空间,所有的连接都会自动使用这个默认命名空间。
- 自定义命名空间:可以通过在服务器上定义新的命名空间来创建额外的通信通道。每个命名空间都像一个小型的Socket.IO服务器,拥有自己的事件监听器和房间。
4.2 命名空间和房间的关系
Socket.IO中的命名空间(Namespace)和房间(Room)虽然都用于组织和分隔客户端的通信,但它们在概念和用途上有着明显的不同。
4.2.1 命名空间(Namespace)
命名空间类似于服务器上的不同通信通道或端点。每个命名空间都是一个独立的Socket.IO实例,具有自己的连接、事件、中间件和房间。
- 用途:命名空间通常用于将应用分割成逻辑上独立的部分,比如不同用户类型(如普通用户、管理员)、不同功能模块(如聊天、通知系统)。
- 连接:客户端必须显式连接到特定的命名空间,每个命名空间在连接层面是隔离的。
- 示例:如果你的应用既有公共聊天室,又有管理控制台,可以为它们创建两个不同的命名空间(例如/chat和/admin)。
4.2.2 房间(Room)
房间是命名空间内的一个概念,用于进一步组织和分割命名空间内的客户端。房间不需要预先创建,当客户端加入一个不存在的房间时,该房间会自动被创建。
- 用途:房间通常用于在同一个命名空间内分组客户端,使得可以向特定的客户端子集广播消息,例如在一个聊天应用中的不同聊天室。
- 隔离级别:房间是命名空间内的逻辑分隔。一个命名空间可以有多个房间,但房间不能跨越不同的命名空间。
- 示例:在/chat命名空间内,可以有多个房间,如room1, room2等,代表不同的聊天话题或群组。
4.3 架构图
4.4 Spring boot相关代码实现
新增namespace事件处理器
package com.rainbowred.websocket.service.socketio.handler;
import com.corundumstudio.socketio.SocketIONamespace;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.namespace.Namespace;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 命名空间事件处理器
*
* @author wangyitao
* @date 2024/1/9.
*/
@Component
@Slf4j
public class NamespaceEventHandler {
/**
* 注册命名空间处理器
*
* @param server
* @param name
*/
public void registerNamespaceHandlers(SocketIOServer server, String name) {
SocketIONamespace namespace = server.addNamespace(name);
// 处理连接事件
namespace.addConnectListener(client -> {
log.info("Client:{}, connect namespace: {}", client.getSessionId(), namespace.getName());
});
// 处理断开连接事件
namespace.addDisconnectListener(client -> {
log.info("Client:{}, disconnect namespace: {}", client.getSessionId(), namespace.getName());
});
// 可以添加更多的事件监听器
}
/**
* 注销命名空间处理器
*
* @param server
* @param name
*/
public void unregisterNamespaceHandlers(SocketIOServer server, String name) {
server.removeNamespace(name);
}
}
更新socketio启动配置类
package com.rainbowred.websocket.service.socketio;
import com.corundumstudio.socketio.SocketIOServer;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.handler.MessageEventHandler;
import com.rainbowred.websocket.service.socketio.handler.NamespaceEventHandler;
import com.rainbowred.websocket.service.socketio.handler.RoomEventHandler;
import com.rainbowred.websocket.service.socketio.listener.ConnectionListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author wangyitao
* @date 2024/1/5.
*/
@Configuration
@Slf4j
public class SocketioConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Value("${socketio.maxFramePayloadLength}")
private int maxFramePayloadLength;
@Value("${socketio.maxHttpContentLength}")
private int maxHttpContentLength;
@Autowired
ConnectionListener connectionListener;
@Autowired
MessageEventHandler messageEventHandler;
@Autowired
RoomEventHandler roomEventHandler;
@Autowired
NamespaceEventHandler namespaceEventHandler;
@Bean
public SocketIOServer socketioServer() {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
// 配置域名和端口
config.setHostname(host);
config.setPort(port);
// 开启socket端口复用
com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
socketConfig.setReuseAddress(true);
config.setSocketConfig(socketConfig);
// 连接数大小
config.setWorkerThreads(workCount);
// 允许客户请求
config.setAllowCustomRequests(allowCustomRequests);
// 协议升级超时时间(毫秒),默认10秒,HTTP握手升级为ws协议超时时间
config.setUpgradeTimeout(upgradeTimeout);
// Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
config.setPingTimeout(pingTimeout);
// Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
config.setPingInterval(pingInterval);
// 设置HTTP交互最大内容长度
config.setMaxFramePayloadLength(maxFramePayloadLength);
// 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
config.setMaxHttpContentLength(maxHttpContentLength);
SocketIOServer server = new SocketIOServer(config);
// 使用事件处理器
server.addConnectListener(connectionListener.connectListener());
server.addDisconnectListener(connectionListener.disconnectListener());
server.addEventListener(WebsocketConstants.EVENT_MESSAGE, String.class, messageEventHandler.messageListener(server));
server.addEventListener(WebsocketConstants.EVENT_JOINROOM, String.class, roomEventHandler.joinRoomListener());
server.addEventListener(WebsocketConstants.EVENT_LEAVEROOM, String.class, roomEventHandler.leaveRoomListener());
// 命名空间事件处理器
namespaceEventHandler.registerNamespaceHandlers(server, WebsocketConstants.NAMESPACE_CHAT);
return server;
}
}
4.5 使用Postman创建客户端
创建客户端,ws地址为:ws.echatsoftuat.com:9092/chat
服务器日志
创建客户端,ws地址为:ws.echatsoftuat.com:9092/robot
原因分析:Namespace(命名空间)是一种逻辑分区,可以将不同类型的连接或不同业务线的连接分别管理。命名空间必须在服务器端手动创建,客户端才能加入。如果尝试加入一个未被创建的命名空间,比如 /robot,将会导致加入失败。这种设计是为了确保服务器端对于各个业务线能有明确的控制和管理。
5. 集群环境的搭建
Netty Socket.IO 默认支持集群模式,测试使用Postman创建客户端
创建客户端1,ws地址为:ws.echatsoftuat.com:9092,并加入room1
创建客户端1,ws地址为:ws.echatsoftuat.com:9094,并加入room1
客户端1和客户端2相互发送消息
6. 身份验证
6.1 握手机制
Socket.IO的握手过程是建立客户端与服务器之间通信的关键步骤,它不仅确保了连接的成功建立,还提供了一个进行身份验证和安全性检查的机会。这个过程对于验证客户端的合法性、协商连接参数、以及预防未授权访问至关重要,确保了通信的安全性和有效性。通过握手,Socket.IO能够建立一个可靠和安全的通信通道,适应不同的网络环境和客户端能力。
6.2 Spring boot相关代码实现
使用JWT进行Socket.IO握手的验证,具体实现如下
新增handshake监听器
package com.rainbowred.websocket.service.socketio.listener;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.corundumstudio.socketio.AuthorizationListener;
import com.corundumstudio.socketio.AuthorizationResult;
import com.corundumstudio.socketio.HandshakeData;
import com.rainbowred.commons.constants.Constants;
import com.rainbowred.commons.security.config.JwtConfig;
import com.rainbowred.commons.security.constants.JwtConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;
/**
* 握手验证
*
* @author wangyitao
* @date 2024/1/10.
*/
@Component
@Slf4j
public class HandshakeListener implements AuthorizationListener {
@Autowired
JwtConfig jwtConfig;
@Override
public AuthorizationResult getAuthorizationResult(HandshakeData handshakeData) {
// 从header中获取JWT token
String header = handshakeData.getHttpHeaders().get(JwtConstants.HEADER_AUTHORIZATION);
// 检查JWT token是否存在
if (header == null || !header.startsWith(JwtConstants.HEADER_BEARER)) {
return new AuthorizationResult(false);
}
String token = header.replace(JwtConstants.HEADER_BEARER, Constants.EMPTY_STR);
// 验证JWT token
if (JWTUtil.verify(token, jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8))) {
return new AuthorizationResult(true);
}
// 返回授权结果
return new AuthorizationResult(false);
}
}
更新socketio启动配置类
package com.rainbowred.websocket.service.socketio;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.store.RedissonStoreFactory;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.handler.MessageEventHandler;
import com.rainbowred.websocket.service.socketio.handler.NamespaceEventHandler;
import com.rainbowred.websocket.service.socketio.handler.RoomEventHandler;
import com.rainbowred.websocket.service.socketio.listener.ConnectionListener;
import com.rainbowred.websocket.service.socketio.listener.HandshakeListener;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author wangyitao
* @date 2024/1/5.
*/
@Configuration
@Slf4j
public class SocketioConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Value("${socketio.maxFramePayloadLength}")
private int maxFramePayloadLength;
@Value("${socketio.maxHttpContentLength}")
private int maxHttpContentLength;
@Value("${socketio.cluster.enabled}")
private boolean clusterEnabled;
@Value("${socketio.cluster.redis.sentinel.nodes}")
private String[] redisSentinelNodes;
@Value("${socketio.cluster.redis.sentinel.master}")
private String redisSentinelMaster;
@Value("${socketio.cluster.redis.password}")
private String redisPassword;
@Autowired
HandshakeListener handshakeListener;
@Autowired
ConnectionListener connectionListener;
@Autowired
MessageEventHandler messageEventHandler;
@Autowired
RoomEventHandler roomEventHandler;
@Autowired
NamespaceEventHandler namespaceEventHandler;
@Bean
public SocketIOServer socketioServer() {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
// 配置域名和端口
config.setHostname(host);
config.setPort(port);
// 开启socket端口复用
com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
socketConfig.setReuseAddress(true);
config.setSocketConfig(socketConfig);
// 设置自定义授权监听器
config.setAuthorizationListener(handshakeListener);
// 连接数大小
config.setWorkerThreads(workCount);
// 允许客户请求
config.setAllowCustomRequests(allowCustomRequests);
// 协议升级超时时间(毫秒),默认10秒,HTTP握手升级为ws协议超时时间
config.setUpgradeTimeout(upgradeTimeout);
// Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
config.setPingTimeout(pingTimeout);
// Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
config.setPingInterval(pingInterval);
// 设置HTTP交互最大内容长度
config.setMaxFramePayloadLength(maxFramePayloadLength);
// 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
config.setMaxHttpContentLength(maxHttpContentLength);
// 设置集群
if (clusterEnabled) {
Config redissonConfig = new Config();
redissonConfig.useSentinelServers()
.setMasterName(redisSentinelMaster)
.addSentinelAddress(redisSentinelNodes)
.setPassword(redisPassword);
RedissonClient redisson = Redisson.create(redissonConfig);
config.setStoreFactory(new RedissonStoreFactory(redisson));
}
SocketIOServer server = new SocketIOServer(config);
// 添加监听器
server.addConnectListener(connectionListener.connectListener());
server.addDisconnectListener(connectionListener.disconnectListener());
// 添加事件处理器
server.addEventListener(WebsocketConstants.EVENT_MESSAGE, String.class, messageEventHandler.messageListener(server));
server.addEventListener(WebsocketConstants.EVENT_JOINROOM, String.class, roomEventHandler.joinRoomListener());
server.addEventListener(WebsocketConstants.EVENT_LEAVEROOM, String.class, roomEventHandler.leaveRoomListener());
// 命名空间事件处理器
namespaceEventHandler.registerNamespaceHandlers(server, WebsocketConstants.NAMESPACE_CHAT);
return server;
}
}
6.3 使用Postman创建客户端
没有添加JWT Token,握手失败
添加JWT Token,握手成功
通信协议场景分析
1. 群组
1.1 设计目标
构建一个具有弹性的系统架构,它能够轻松适应未来任何预见或未预见的需求变化。核心理念是保持系统组件的模块化和灵活性,以便在即时消息(IM)模型下支持多样化的数据交互方式。
以 Telegram 的群组功能为例,我们可以观察到群组的参与者可能有多种角色,如普通成员、机器人、频道(Channel)和匿名成员,每种角色都有不同的交互模式和需求。
1.2 场景举例
设计方案应当能够覆盖并优化以下情景:
- 当群组仅有两位普通成员时,该场景等同于一对一的私人对话,类似于一洽的标准对话模式。
- 如果群组中包含一位普通成员和一位机器人,该交互模式将类似于一洽中的机器人自动对话。
- 当群组中有一位普通成员和一个频道时,可以将其视作留言模式,其中客服可以通过频道的后台进行回复处理。
- 若群组内有多位普通成员,形成一位访客与多位客服协助的场景,符合我们产品设计中的多客服协同服务体验。
- 如果群组中的普通成员发生更替,这就类似于一洽的对话转接功能。
- 当群组成员都是公司内部员工,支持一对一以及一对多的沟通方式时,它就转变为公司内部的即时通讯平台。
使用 Socket.IO 的 room 功能实现上述场景时,需要特别关注消息归属和数据隔离问题,以确保正确地管理和分发消息。以下是实现这些场景时的关键考虑因素:
1.3 流程图
1.4 代码实现
1.4.1 群组和房间映射
将 Telegram 群组概念映射到 Socket.IO 的 room,其中每个群组对应一个独立的 room。这样可以有效地隔离不同群组的消息流。具体实现参照上文:Socket.IO基础特性 房间(Room)
相关内容
1.4.2 角色识别与权限管理
在每个房间(room)内,系统根据用户的角色(如访客、客服、机器人、超级管理员)来控制消息的访问权限和功能权限。例如,机器人角色可能被限制只能接收和发送特定类型的消息。
所有用户角色的身份验证都通过 auth-service 来完成,并获得相应的 JWT Token,以确保安全性和一致性。
1.4.2.1 客户端身份验证的不同处理方式
对于不同的客户端,使用不同的处理器(handler)来验证其身份。以超级管理员角色为例,出于测试目的,我们使用固定的账号和密码来进行验证。
AuthenticationHandler接口
package com.rainbowred.auth.service.handler;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
/**
* @author wangyitao
* @date 2024/1/11.
*/
public interface AuthenticationHandler {
/**
*
* @param clientType
* @return
*/
boolean supports(String clientType);
/**
* 各端认证接口
*
* @param username
* @param password
* @return
* @throws AuthenticationException
*/
Authentication authenticate(String username, String password) throws AuthenticationException;
}
AdminAuthenticationHandler实现类
package com.rainbowred.auth.service.handler.impl;
import com.rainbowred.auth.service.constants.AuthConstants;
import com.rainbowred.auth.service.handler.AuthenticationHandler;
import com.rainbowred.auth.service.handler.AuthenticationHandlerManager;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.stereotype.Component;
/**
* @author wangyitao
* @date 2024/1/11.
*/
@Component
public class AdminAuthenticationHandler implements AuthenticationHandler {
@Override
public boolean supports(String clientType) {
return StringUtils.equals(clientType, AuthConstants.ClientTypeDefine.ADMIN);
}
@Override
public Authentication authenticate(String username, String password) throws AuthenticationException {
if (AuthConstants.AdminDefine.DEFAULT_USERNAME.equals(username)
&& AuthenticationHandlerManager.passwordEncoder().matches(password,
AuthenticationHandlerManager.passwordEncoder().encode(AuthConstants.AdminDefine.DEFAULT_PASSWORD))) {
return new UsernamePasswordAuthenticationToken(
username, password, AuthorityUtils.commaSeparatedStringToAuthorityList(AuthConstants.AdminDefine.DEFAULT_ROLE));
} else {
throw new BadCredentialsException("Invalid username or password");
}
}
}
1.4.2.2 修改auth-service的Spring-Security配置启动类
为适应新的验证需求,对 auth-service 的 Spring Security 配置进行相应的修改。
package com.rainbowred.auth.service;
import com.rainbowred.auth.service.handler.AuthenticationHandler;
import com.rainbowred.auth.service.handler.AuthenticationHandlerManager;
import com.rainbowred.commons.security.config.JwtConfig;
import com.rainbowred.commons.security.detail.FormAuthenticationDetailsSource;
import com.rainbowred.commons.security.detail.FormWebAuthenticationDetails;
import com.rainbowred.commons.security.filter.JwtAuthenticationFilter;
import com.rainbowred.commons.security.handler.JwtAuthenticationFailureHandler;
import com.rainbowred.commons.security.handler.JwtAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* @author wangyitao
* @date 2023/10/12.
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
JwtConfig jwtConfig;
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.addFilterBefore(new JwtAuthenticationFilter(jwtConfig), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/login", "/login.html")
.permitAll()
.anyRequest().authenticated())
.formLogin(formLogin -> formLogin
.loginPage("/login.html") // 设置您的登录页面
.loginProcessingUrl("/login") // 设置处理登录请求的URL
.failureHandler(new JwtAuthenticationFailureHandler())
.successHandler(new JwtAuthenticationSuccessHandler(jwtConfig))
.authenticationDetailsSource(webAuthDetailsSource()));
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationHandlerManager handlerManager) {
return new AuthenticationManager() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// 获取额外表单字段
FormWebAuthenticationDetails details = (FormWebAuthenticationDetails) authentication.getDetails();
String clientType = details.getClientType();
AuthenticationHandler handler = handlerManager.getHandler(clientType);
return handler.authenticate(username, password);
}
};
}
@Bean
public FormAuthenticationDetailsSource webAuthDetailsSource() {
return new FormAuthenticationDetailsSource();
}
}
1.4.2.3 修改websocket-service握手代码
在握手过程中,需要从 JWT Token 中解析用户的权限信息。
从JWT Token中解析权限
package com.rainbowred.websocket.service.socketio.listener;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.corundumstudio.socketio.AuthorizationListener;
import com.corundumstudio.socketio.AuthorizationResult;
import com.corundumstudio.socketio.HandshakeData;
import com.rainbowred.commons.constants.Constants;
import com.rainbowred.commons.security.config.JwtConfig;
import com.rainbowred.commons.security.constants.JwtConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 握手验证
*
* @author wangyitao
* @date 2024/1/10.
*/
@Component
@Slf4j
public class HandshakeListener implements AuthorizationListener {
@Autowired
JwtConfig jwtConfig;
@Override
public AuthorizationResult getAuthorizationResult(HandshakeData handshakeData) {
// 从header中获取JWT token
String header = handshakeData.getHttpHeaders().get(JwtConstants.HEADER_AUTHORIZATION);
// 检查JWT token是否存在
if (header == null || !header.startsWith(JwtConstants.HEADER_BEARER)) {
return new AuthorizationResult(false);
}
String token = header.replace(JwtConstants.HEADER_BEARER, Constants.EMPTY_STR);
// 验证JWT token
if (JWTUtil.verify(token, jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8))) {
JWT jwt = JWTUtil.parseToken(token);
List<String> authorities = (List<String>) jwt.getPayload(JwtConstants.PAYLOAD_AUTHORITIES);
// 创建带有角色信息的AuthorizationResult
Map<String, Object> storeParams = new HashMap<>();
if (!authorities.isEmpty()) {
storeParams.put(JwtConstants.PAYLOAD_AUTHORITIES, authorities);
}
return new AuthorizationResult(true, storeParams);
}
// 返回授权结果
return new AuthorizationResult(false);
}
}
1.4.2.4 设置自定义存储工厂
注意,这里存在一个关键点:需要设置自定义存储工厂
,以便获取 AuthorizationResult 中的 storeParams。在本示例中,我们采用 Redisson 作为存储工厂。
修改nacos配置
socketio:
# redis服务
redis:
sentinel:
master: mymaster
nodes: redis://172.16.3.38:7505,redis://172.16.3.38:7506,redis://172.16.3.38:7507
password: Redis12#
更新socketio启动配置类
package com.rainbowred.websocket.service.socketio;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.store.RedissonStoreFactory;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.handler.MessageEventHandler;
import com.rainbowred.websocket.service.socketio.handler.NamespaceEventHandler;
import com.rainbowred.websocket.service.socketio.handler.RoomEventHandler;
import com.rainbowred.websocket.service.socketio.listener.ConnectionListener;
import com.rainbowred.websocket.service.socketio.listener.HandshakeListener;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author wangyitao
* @date 2024/1/5.
*/
@Configuration
@Slf4j
public class SocketioConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Value("${socketio.maxFramePayloadLength}")
private int maxFramePayloadLength;
@Value("${socketio.maxHttpContentLength}")
private int maxHttpContentLength;
@Value("${socketio.redis.sentinel.nodes}")
private String[] redisSentinelNodes;
@Value("${socketio.redis.sentinel.master}")
private String redisSentinelMaster;
@Value("${socketio.redis.password}")
private String redisPassword;
@Value("${socketio.cluster.enabled}")
private boolean clusterEnabled;
@Autowired
HandshakeListener handshakeListener;
@Autowired
ConnectionListener connectionListener;
@Autowired
MessageEventHandler messageEventHandler;
@Autowired
RoomEventHandler roomEventHandler;
@Autowired
NamespaceEventHandler namespaceEventHandler;
@Bean
public SocketIOServer socketioServer() {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
// 配置域名和端口
config.setHostname(host);
config.setPort(port);
// 开启socket端口复用
com.corundumstudio.socketio.SocketConfig socketConfig = new com.corundumstudio.socketio.SocketConfig();
socketConfig.setReuseAddress(true);
config.setSocketConfig(socketConfig);
// 设置自定义授权监听器
config.setAuthorizationListener(handshakeListener);
// 连接数大小
config.setWorkerThreads(workCount);
// 允许客户请求
config.setAllowCustomRequests(allowCustomRequests);
// 协议升级超时时间(毫秒),默认10秒,HTTP握手升级为ws协议超时时间
config.setUpgradeTimeout(upgradeTimeout);
// Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
config.setPingTimeout(pingTimeout);
// Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
config.setPingInterval(pingInterval);
// 设置HTTP交互最大内容长度
config.setMaxFramePayloadLength(maxFramePayloadLength);
// 设置最大每帧处理数据的长度,防止他人利用大数据来攻击服务器
config.setMaxHttpContentLength(maxHttpContentLength);
// 设置自定义存储工厂
Config redissonConfig = new Config();
redissonConfig.useSentinelServers()
.setMasterName(redisSentinelMaster)
.addSentinelAddress(redisSentinelNodes)
.setPassword(redisPassword);
RedissonClient redisson = Redisson.create(redissonConfig);
config.setStoreFactory(new RedissonStoreFactory(redisson));
SocketIOServer server = new SocketIOServer(config);
// 添加监听器
server.addConnectListener(connectionListener.connectListener(server));
server.addDisconnectListener(connectionListener.disconnectListener(server));
// 添加事件处理器
server.addEventListener(WebsocketConstants.EVENT_MESSAGE, String.class, messageEventHandler.messageListener(server));
server.addEventListener(WebsocketConstants.EVENT_JOINROOM, String.class, roomEventHandler.joinRoomListener());
server.addEventListener(WebsocketConstants.EVENT_LEAVEROOM, String.class, roomEventHandler.leaveRoomListener());
// 命名空间事件处理器
namespaceEventHandler.registerNamespaceHandlers(server, WebsocketConstants.NAMESPACE_CHAT);
return server;
}
}
1.4.2.5 修改 websocket-service 的连接创建和销毁代码
在客户端连接创建和销毁的过程中,正确地设置和清理客户端的权限信息,以确保系统的安全性和稳定性。
package com.rainbowred.websocket.service.socketio.listener;
import com.corundumstudio.socketio.HandshakeData;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.ConnectListener;
import com.corundumstudio.socketio.listener.DisconnectListener;
import com.corundumstudio.socketio.store.RedissonStoreFactory;
import com.corundumstudio.socketio.store.Store;
import com.rainbowred.commons.security.constants.JwtConstants;
import com.rainbowred.commons.tools.utils.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* ws链接监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class ConnectionListener {
/**
* client创建链接
*
* @param server
* @return
*/
public ConnectListener connectListener(SocketIOServer server) {
return client -> {
RedissonStoreFactory storeFactory = (RedissonStoreFactory) server.getConfiguration().getStoreFactory();
Store store = storeFactory.createStore(client.getSessionId());
List<String> authorities = store.get(JwtConstants.PAYLOAD_AUTHORITIES);
client.set(JwtConstants.PAYLOAD_AUTHORITIES, authorities);
log.info("Client connected:{}, authorities:{}", client.getSessionId(), JsonUtil.toJson(authorities));
};
}
/**
* client断开链接
*
* @param server
* @return
*/
public DisconnectListener disconnectListener(SocketIOServer server) {
return client -> {
RedissonStoreFactory storeFactory = (RedissonStoreFactory) server.getConfiguration().getStoreFactory();
Store store = storeFactory.createStore(client.getSessionId());
store.del(JwtConstants.PAYLOAD_AUTHORITIES);
log.info("Client disconnected:{}", client.getSessionId());
};
}
}
1.4.2.6 修改 websocket-service 的消息监听器
根据不同角色的权限,控制消息的可见性和可操作性,以实现精细化的权限管理。
package com.rainbowred.websocket.service.socketio.handler;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.DataListener;
import com.rainbowred.commons.constants.Constants;
import com.rainbowred.commons.constants.auth.AuthConstants;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 消息事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class MessageEventHandler {
/**
* 消息监听器
*
* @return
*/
public DataListener<String> messageListener(SocketIOServer server) {
return (client, message, ackRequest) -> {
String roomName = extractRoomNameFromMessage(message);
String actualMessage = extractActualMessage(message);
if (StringUtils.isEmpty(roomName) || StringUtils.isEmpty(actualMessage)) {
log.info("Received message from client:{}, message:{}", client.getSessionId(), message);
return;
}
String role = client.get(WebsocketConstants.ROLE);
if (StringUtils.equals(role, AuthConstants.AdminDefine.DEFAULT_ROLE)) {
// 超管可以发送所有消息
} else if (StringUtils.equals(role, AuthConstants.UserDefine.DEFAULT_ROLE)) {
// user发送消息规则
} else if (StringUtils.equals(role, AuthConstants.ClientDefine.DEFAULT_ROLE)) {
// client发送消息规则
} else if (StringUtils.equals(role, AuthConstants.BotDefine.DEFAULT_ROLE)) {
// bot发送消息规则
}
server.getRoomOperations(roomName).sendEvent("message", actualMessage);
log.info("Received message from client:{}, role{}, room:{}, message:{}", client.getSessionId(), role, roomName, actualMessage);
};
}
/**
* 从消息中提取房间名称。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 房间名称
*/
public static String extractRoomNameFromMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
return message.split(":")[0];
}
/**
* 从消息中提取实际消息内容。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 消息内容
*/
public static String extractActualMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
String[] parts = message.split(":", 2);
return parts.length > 1 ? parts[1] : null;
}
}
1.4.3 消息元数据
每条消息都应该包含足够的元数据,以便识别其归属。例如,消息可以包含发送者ID、群组ID和时间戳,便于追踪每条消息的来源和目的地。
1.4.3.1 元数据定义
字段 | 类型 | 描述 |
---|---|---|
mid | String | 分布式全局唯一消息ID |
rid | String | room内消息ID,保持连续自增长 |
cid | String | client端生成消息ID |
fromUid | String | 发送者ID |
roomId | String | 房间ID |
replyTo | String | 当前回复了某条消息/引用了某条消息 |
tm | long | 时间戳(毫秒) |
message | String | 消息内容 |
1.4.3.2 Spring boot相关代码实现
新增消息元数据对象
package com.rainbowred.commons.entities.msg;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 消息元数据
*
* @author wangyitao
* @date 2024/1/12.
*/
@Data
public class MetaMessage implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* client端处理动作
* 为10进制数字传输,使用时转成二进制使用
* 起始位从最右侧开始,若指定位数为1,则表明生效:
* 0位发送消息类型,为1时标明当前消息为client发送消息
* 1为接收消息类型,为1时标明当前消息为server接收消息
* 2位表示为消息
* 3为表示为事件
* 4表示是否需要推送推送
* 5位表示是否进行远程推送
* 例子 为消息服务器发出,client接收,则是01110,10进制是14
*/
private int bin;
/**
* 分布式全局唯一消息ID
*/
private String mid;
/**
* client端生成ID
*/
private String cid;
/**
* 发送者ID
*/
private String fromUid;
/**
* 群组ID
*/
private String groupId;
/**
* 当前回复了某条消息/引用了某条消息
*/
private String replyTo;
/**
* 时间戳(毫秒)
*/
private long tm;
/**
* 消息内容
*/
private String message;
}
修改消息事件监听器
package com.rainbowred.websocket.service.socketio.handler;
import cn.hutool.core.lang.UUID;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.DataListener;
import com.rainbowred.commons.constants.Constants;
import com.rainbowred.commons.constants.auth.AuthConstants;
import com.rainbowred.commons.entities.msg.MetaMessage;
import com.rainbowred.commons.tools.utils.JsonUtil;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.strategy.MessageSendStrategy;
import com.rainbowred.websocket.service.socketio.strategy.MessageSendStrategyFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 消息事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class MessageEventHandler {
@Autowired
MessageSendStrategyFactory strategyFactory;
/**
* 消息监听器
*
* @return
*/
public DataListener<String> messageListener(SocketIOServer server) {
return (client, message, ackRequest) -> {
String roomName = extractRoomNameFromMessage(message);
String actualMessage = extractActualMessage(message);
if (StringUtils.isEmpty(roomName) || StringUtils.isEmpty(actualMessage)) {
log.info("Send message from client:{}, message:{}", client.getSessionId(), message);
return;
}
MetaMessage metaMessage = new MetaMessage();
metaMessage.setMid(UUID.fastUUID().toString().replace("-", ""));
metaMessage.setFromUid(client.getSessionId().toString());
metaMessage.setGroupId(roomName);
metaMessage.setTm(System.currentTimeMillis());
metaMessage.setMessage(actualMessage);
String role = client.get(WebsocketConstants.ROLE);
MessageSendStrategy strategy = strategyFactory.getStrategy(role);
strategy.sendMessage(server, client, roomName, metaMessage);
log.info("Send message from client:{}, role:{}, room:{}, message:{}", client.getSessionId(), role, roomName, JsonUtil.toJson(metaMessage));
};
}
/**
* 从消息中提取房间名称。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 房间名称
*/
public static String extractRoomNameFromMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
return message.split(":")[0];
}
/**
* 从消息中提取实际消息内容。
* 假设消息格式为 "roomName:messageContent"
*
* @param message 完整的消息字符串
* @return 消息内容
*/
public static String extractActualMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
String[] parts = message.split(":", 2);
return parts.length > 1 ? parts[1] : null;
}
}
新增发送消息策略
package com.rainbowred.websocket.service.socketio.strategy.impl;
import com.corundumstudio.socketio.SocketIOClient;
import com.corundumstudio.socketio.SocketIOServer;
import com.rainbowred.commons.constants.auth.AuthConstants;
import com.rainbowred.commons.entities.msg.MetaMessage;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.socketio.strategy.MessageSendStrategy;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 超管消息发送策略
*
* @author wangyitao
* @date 2024/1/12.
*/
@Component
public class AdminMessageSendStrategy implements MessageSendStrategy {
@Override
public boolean supports(String role) {
return StringUtils.equals(role, AuthConstants.AdminDefine.DEFAULT_ROLE);
}
@Override
public void sendMessage(SocketIOServer server, SocketIOClient sender, String roomName, MetaMessage message) {
server.getRoomOperations(roomName).sendEvent(
WebsocketConstants.EVENT_MESSAGE,
receiver -> receiver.getSessionId().equals(sender.getSessionId()),
message);
}
}
1.4.3.3 使用Postman创建客户端
1.4.4 群组动态管理
提供接口或机制来动态管理群组成员,包括添加、移除成员,以及更改成员角色。
客户端(client)与房间(room)的关系管理
在微服务架构中,每个服务都应该有明确的职责范围,有助于维持服务之间的松耦合并保持每个服务的职责单一。对于客户端(client)与房间(room)的关系管理问题,这里有几个考虑因素:
- 核心业务逻辑与数据的归属权:如果房间的概念和管理逻辑是业务流程的一部分,那么由核心业务服务来维护这些关系可能更合适。
- 实时通信的需求:如果房间主要用于实时通信(如聊天室),且与业务逻辑关系不大,那么通信服务负责维护关系可能更合理。
- 数据一致性与服务之间的依赖:如果其他服务也需要知道客户端与房间的关系,那么应该有一个中心化的方式来管理这些信息,以避免数据不一致的问题。
- 服务的可扩展性与可维护性:服务应该设计成易于扩展和维护,减少服务间的直接依赖可以提高系统的健壮性。
基于上述考虑,让通信服务负责实时通信的所有细节,包括客户端与房间的映射关系。这样,通信服务可以独立于核心业务服务运行,并且可以根据通信负载进行扩展。同时,核心业务服务可以通过REST API或RPC接口与通信服务进行交互,获取必要的信息或进行必要的操作。比如当核心业务流程需要将用户分配到特定的房间或在业务事件发生后更新房间状态时,核心业务服务将不直接参与房间信息的管理。相反,它仅需要将用户身份的变更信息发送到通信服务。这个过程可以通过定义好的API接口或者使用异步消息队列完成。
2. 消息确认
Socket.IO 中的消息确认机制是非常重要的,尤其在需要确保消息传递的可靠性和完整性的应用中。以下是几个关键点,说明其必要性:
- 确保消息传递:在网络通信中,由于各种原因(如网络不稳定、服务端负载高等),发送的消息可能会丢失。消息确认机制允许发送者知道他们的消息是否已经被接收方成功接收和处理。这对于需要高可靠性的实时应用尤其重要。
- 数据一致性:在需要同步状态或进行事务处理的应用中,确认机制可以确保数据的一致性。例如,在一个聊天应用中,确认机制可以用来确保消息已经被接收并显示给用户,或者在一个协作应用中,它可以确保所有参与者都看到了最新的更改。
在 Socket.IO 中实现消息确认的机制涉及到客户端和服务端之间的通信,主要分为以下两种情况:
2.1 ACK机制
在使用 Socket.IO 实现消息确认(ACK)机制时,需要特别注意其与 Cometd 的关键差异。与 Cometd 的 ACK 机制不同,Socket.IO 并没有内置计数器来关联发送的消息和接收到的确认。因此,在 Socket.IO 中,为了确保消息和其相应的确认之间可以被准确地关联,需要 Client 和 Server 共同维护一套有效的协议和标准。
这套协议应包括但不限于使用唯一的标识符(如客户端生成的 cid 生成策略详见本章第4节)来标记每条发送的消息。这样当服务端处理并响应这些消息时,它可以在确认消息(ACK)中包含相应的 cid,从而使客户端能够将收到的确认与原始消息准确匹配。此外,服务端也可以生成自己的消息标识符(如 mid 生成策略详见本章第3节),并将其包含在确认消息中,以便于进一步的消息追踪和日志记录。
通过这种方式,即使在高并发的环境下,每个消息和其确认都能被清晰地关联起来,确保了通信的可靠性和有效性。这种方法不仅提高了消息传递的准确性,还增强了系统的健壮性,使得即使在网络不稳定或负载较高的情况下,消息交换仍然可以保持一致和可靠。
2.2 客户端(Client) 发送消息给服务端(Server) 并等待确认
在这种情况下,客户端发送一个消息给服务端,并提供一个回调函数。服务端在收到并处理这个消息后,调用这个回调函数来发送一个确认回执给客户端。
2.2.1 策略
当服务端接收到一条消息后,其返回的确认消息(ACK)将包含该消息原始的 cid 以及一个由服务端生成的消息标识符(mid)。在服务端拥有序列化消息ID生成机制的情况下,mid 将包含这一序列ID。这样的设计确保了消息的发送者能够将接收到的确认准确地关联到其原始发送的消息,从而实现了可靠和一致的消息确认机制。
2.2.2 客户端 JavaScript 代码示例
let message = {
cid: "client_generated_id",
message: "消息内容"
};
socket.emit('message', message, (ack) => {
console.log('Acknowledgement from Server:', ack);
});
2.2.3 使用Postman创建客户端并发送消息
2.2.4 Spring boot相关代码示例
消息事件监听器
package com.rainbowred.websocket.service.socketio.handler;
import cn.hutool.core.lang.UUID;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.DataListener;
import com.rainbowred.commons.constants.Constants;
import com.rainbowred.commons.constants.auth.AuthConstants;
import com.rainbowred.commons.entities.msg.MetaMessage;
import com.rainbowred.commons.tools.utils.JsonUtil;
import com.rainbowred.websocket.service.constants.WebsocketConstants;
import com.rainbowred.websocket.service.entity.AckData;
import com.rainbowred.websocket.service.entity.ClientMessage;
import com.rainbowred.websocket.service.socketio.strategy.MessageSendStrategy;
import com.rainbowred.websocket.service.socketio.strategy.MessageSendStrategyFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 消息事件监听器
*
* @author wangyitao
* @date 2024/1/8.
*/
@Component
@Slf4j
public class MessageEventHandler {
@Autowired
MessageSendStrategyFactory strategyFactory;
/**
* 消息监听器
*
* @return
*/
public DataListener<String> messageListener(SocketIOServer server) {
return (client, message, ackRequest) -> {
String roomId = extractRoomId(message);
ClientMessage clientMessage = extractClientMessage(message);
if (StringUtils.isEmpty(roomId) || clientMessage == null) {
log.info("Send message from client:{}, message:{}", client.getSessionId(), message);
return;
}
MetaMessage metaMessage = new MetaMessage();
metaMessage.setMid(UUID.fastUUID().toString().replace("-", ""));
metaMessage.setCid(clientMessage.getCid());
metaMessage.setFromUid(client.getSessionId().toString());
metaMessage.setGroupId(roomId);
metaMessage.setTm(System.currentTimeMillis());
metaMessage.setMessage(clientMessage.getMessage());
String role = client.get(WebsocketConstants.ROLE);
MessageSendStrategy strategy = strategyFactory.getStrategy(role);
strategy.sendMessage(server, client, roomId, metaMessage);
if (ackRequest.isAckRequested()) {
// 创建并发送确认消息
AckData ackData = new AckData();
ackData.setCid(clientMessage.getCid());
ackData.setMid(metaMessage.getMid());
ackRequest.sendAckData(ackData);
}
log.info("Send message from client:{}, role:{}, room:{}, message:{}", client.getSessionId(), role, roomId, JsonUtil.toJson(metaMessage));
};
}
/**
* 从消息中提取房间ID。
* 假设消息格式为 "roomId:message"
*
* @param message 完整的消息字符串
* @return 房间ID
*/
public String extractRoomId(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
return message.split(":")[0];
}
/**
* 从消息中提取实际消息内容,json格式。
* 假设消息格式为 "roomId:message"
*
* @param message 完整的消息字符串
* @return ClientMessage对象
*/
public ClientMessage extractClientMessage(String message) {
if (message == null || !message.contains(Constants.SEPARATOR_COLON)) {
return null;
}
String[] parts = message.split(":", 2);
return parts.length > 1 ? JsonUtil.toBean(parts[1], ClientMessage.class) : null;
}
}
2.3 服务端(Server) 发送消息给客户端(Client) 并等待确认
在这种情况下,服务端主动发送消息给客户端,并期望从客户端接收到一个确认回执。这通常用于服务端需要验证客户端是否已经接收并处理了特定的信息或指令。
2.3.1 策略
服务端在发送消息时,应包含一个唯一的消息标识符(mid),以便客户端可以在其确认回执中引用。当客户端收到来自服务端的消息后,它会处理该消息,并在确认回执中返回相应的 mid。这样,服务端在收到确认回执时,可以通过检查 mid 来确保回执与其原始发送的消息匹配。
此外,服务端可能还会包含其他相关信息,如时间戳、消息类型或特定于应用的数据,以便客户端能够更有效地处理消息并提供相关的确认回执。这对于确保消息传递的完整性和一致性至关重要,特别是在涉及关键操作和实时数据交换的场景中。
在实现上述策略时,服务端应考虑消息的重要性和客户端的确认能力。对于关键消息,服务端可能需要实施超时机制和重试策略,以确保消息被成功交付和确认。对于不那么关键的消息,服务端可能选择使用更轻量级的确认机制,或甚至完全不要求客户端确认,以减少网络通信量和提高系统性能。
2.3.2 客户端 JavaScript 代码示例
// 监听消息
socket.on('message', function (data, ack) {
console.log('Received a message:', data);
// 发送ack确认
ack({ "mid": data.mid });
});
2.3.3 使用Postman创建客户端并发送消息
2.3.4 Spring boot相关代码示例
新增server向client发送消息service
package com.rainbowred.websocket.service.socketio.service;
import com.corundumstudio.socketio.SocketIOServer;
import com.rainbowred.commons.entities.msg.MetaMessage;
import com.rainbowred.commons.tools.utils.JsonUtil;
import com.rainbowred.websocket.service.socketio.callback.AckCallback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* server向client推送消息
*
* @author wangyitao
* @date 2024/1/17.
*/
@Service
@Slf4j
public class MessageSendService {
@Autowired
private SocketIOServer socketioServer;
/**
* 广播消息
*
* @param eventName
* @param message
*/
public void broadcastMessage(String eventName, MetaMessage message) {
socketioServer.getBroadcastOperations().sendEvent(eventName, message, new AckCallback());
}
/**
* 向指定房间广播消息
*
* @param roomId
* @param eventName
* @param message
*/
public void broadcastMessageToRoom(String roomId, String eventName, MetaMessage message) {
socketioServer.getRoomOperations(roomId).sendEvent(eventName, message, new AckCallback());
}
/**
* 向指定client发送消息
*
* @param clientId
* @param eventName
* @param message
*/
public void broadcastMessageToClient(String clientId, String eventName, MetaMessage message) {
UUID clientUuid = UUID.fromString(clientId);
socketioServer.getClient(clientUuid).sendEvent(eventName, message, new AckCallback());
}
}
新增ack回调实现类
package com.rainbowred.websocket.service.socketio.callback;
import com.rainbowred.commons.tools.utils.JsonUtil;
import com.rainbowred.websocket.service.entity.AckData;
import lombok.extern.slf4j.Slf4j;
import java.io.Serial;
import java.io.Serializable;
/**
* @author wangyitao
* @date 2024/1/17.
*/
@Slf4j
public class AckCallback extends com.corundumstudio.socketio.AckCallback<AckData> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public AckCallback() {
super(AckData.class, 5000);
}
@Override
public void onSuccess(AckData ackData) {
log.info("ack callback on success ! ackData:{}", JsonUtil.toJson(ackData));
}
}
3. MID(消息ID)
3.1 MID生成方案调研
调研结论:使用美团Leaf-snowflake方案实现
3.2 美团Leaf-snowflake环境搭建
3.2.1 下载源码
github地址:https://github.com/Meituan-Dianping/Leaf
git clone git@github.com:Meituan-Dianping/Leaf.git
git checkout feature/spring-boot-starter
3.2.2 安装私服
由于在中央仓库没有项目地址,所以需要将pom包安装到Nexus-Maven私服来统一管理
Nexus-Mave文档地址:https://note.echatsoft.com/docs/maven/maven-1f7ceoe6k2p6s
mvn clean deploy -DskipTests
3.2.3 引入依赖
<dependency>
<artifactId>leaf-boot-starter</artifactId>
<groupId>com.sankuai.inf.leaf</groupId>
<version>1.0.1-RELEASE</version>
</dependency>
3.3 美团Leaf-snowflake接入示例
3.3.1 配置文件
#美团leaf
leaf:
name: com.rainbowred.websocket.service
snowflake:
address: 172.16.3.38:2181
enable: true
port: 8833
3.3.2 Spring boot相关代码实现
package com.rainbowred.commons.uidgenerator.snowflake;
import com.sankuai.inf.leaf.service.SnowflakeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 基于美团Leaf唯一ID生成器
*
* @author wangyitao
* @date 2024/1/19.
*/
@Service
public class LeafSnowflakeService {
@Autowired
private SnowflakeService snowflakeService;
public long getId(String key) {
return snowflakeService.getId(key).getId();
}
}
3.4 测试结果
4. CID(Client端生成消息ID)
待各端补充
5. 消息已读未读
实现类似Telegram的群聊功能,其中消息的已读未读状态是针对个人而不是设备的,并且读取状态需要在用户的所有设备间同步,socketio提供的简单ACK机制无法实现这个场景。可以通过服务器来中心化管理每个用户的消息读取状态,并且当任何一个设备更改了消息的读取状态时,服务器负责将这个状态更新同步到用户的所有其他设备。
5.1 初始化用户信息
当用户第一次加入群组或者系统初始化时,可以在Redis中为每个用户创建两个值:
- last_read_mid:记录用户读到的最后一条消息ID。
- unread_count:记录用户的未读消息数量。
5.2 消息发送流程
- 客户端发送消息: 客户端(Client)通过socket.io向服务端(Server)发送一条消息。此时,消息状态被标记为“发送中”。
- 服务端ACK回执: 服务端收到消息后,向客户端发送一个ACK(Acknowledgement)回执,确认消息已成功接收。客户端在收到ACK回执后,更新消息状态为“发送成功”。
5.3 消息阅读状态跟踪
为了跟踪每条消息的阅读状态,设计了message_read_status表,该表包含以下字段:
字段 | 类型 | 描述 |
---|---|---|
id | long | 自增主键 |
mid | string | 分布式全局唯一消息ID |
uid | string | 标识消息的阅读状态关联的用户 |
read_status | int | 表示消息是否已被该用户阅读,其中0表示未读,1表示已读 |
当一个用户在群(room)中发送消息后,message_read_status表将为群中的每个用户(除了发送者)新增一条记录,初始时read_status值为0,表示消息未被阅读。
当新消息存储成功后,服务器将对应的群组中的每个成员的未读消息计数在Redis中加一。
5.4 消息已读更新机制
客户端收到消息后,并不立即将消息标记为已读。消息的已读状态更新遵循以下机制:
- 单ID已读: 当用户阅读一条特定消息时,客户端可向服务端发送请求,更新该消息的read_status为1。
- 范围ID标记已读: 若用户阅读了最新的消息,客户端可向服务端发送一个范围请求,将该消息及之前所有消息的read_status统一标记为1。
此机制允许灵活地处理消息的阅读确认,确保用户在多设备上的阅读状态能够得到同步更新。
服务器接收到用户已读消息的确认后,会在Redis中对应更新该用户的"最后阅读消息ID"和相应减少未读消息计数。
5.5 总结
- 巨量的Room和用户: 对于每条消息,系统需要为群内的每个用户(除了发送者)在message_read_status表中创建一条记录。当存在大量群聊(Room)和每个群聊内有大量用户时,这将导致数据库表迅速膨胀,存储需求巨大。
- 频繁的数据库更新: 随着用户阅读消息,系统需要频繁更新message_read_status表中的记录。这不仅增加了数据库的写入负载,还可能导致锁竞争,影响系统性能。