easy-socketio 实时推送组件设计
本文档描述 easy-socketio 组件的设计与实现。该组件基于 netty-socketio 2.0.14 做 Spring Boot 封装,解决三个问题:
SocketIOServer生命周期托管、握手鉴权与房间协议的统一约定、分布式部署下复用 spring-data-redis 的轻量 Store 扩展。组件默认关闭(socketio.enabled=true才装配),未配置的服务零影响。
一、背景与问题
1.1 直接使用 netty-socketio 的痛点
netty-socketio 是成熟的 Socket.IO Java 服务端实现,但原生使用方式面向手工组装,与 Spring Boot 生态存在四类摩擦:
| 原生痛点 | 说明 | easy-socketio 方案 |
|---|---|---|
| 生命周期手工管理 | SocketIOServer.start()/stop() 时机需手工控制,容易与 Spring 容器启停错位 | SmartLifecycle + FactoryBean 托管给容器 |
| 鉴权/房间/事件每项目重复组装 | 握手校验、房间加入、事件注册全部手写 | AuthListener SPI + RoomAdmin 房间协议 + BaseHandler 自动注册 |
| 集群 Store 依赖重 | 官方 RedissonStoreFactory 会拖入 Redisson 全家桶,与已有 Redis 配置割裂 | RedisStoreFactory 复用应用的 RedisTemplate + RedisMessageListenerContainer |
| 无 Spring 整合 | 无自动配置,配置项散落 | spring.factories 自动配置 + SocketioProperties |
1.2 与 easy-notification 的关系
middlewares/easy-notification 是面向产品的实时通知中心(业务语义层),easy-socketio 是它的底层长连接服务组件(能力层)。easy-notification 已基于本组件重写:服务器装配/鉴权/房间协议/Redis 分布式均复用组件层,中间件只保留推送 API(NotificationPusher)与 chat 事件业务。
二、组件定位与边界
定位:Spring Boot 下的 Socket.IO 服务端组件——自动配置 + 生命周期托管 + 握手鉴权 SPI + 房间协议 + 分布式 Store 扩展。
边界:
| 做 | 不做 |
|---|---|
自动配置(socketio.enabled 门控,默认零影响) | 不做业务推送服务(Packet 仅载荷约定,推送逻辑在消费方) |
SmartLifecycle 生命周期托管,SocketIOServer 暴露为 Bean | 不做前端 SDK(直接用官方 socket.io-client v4) |
AuthListener 握手鉴权 SPI + 房间订阅校验 | 不做 SSL 终结/网关暴露(部署层职责) |
| 三级房间协议(user/broadcast/product)+ subscribe/unsubscribe | 不做消息可靠投递/离线补偿(不缓存离线消息) |
BaseHandler topic 事件处理器自动注册 | 不做协议引擎定制 |
RedisStoreFactory 分布式扩展(Redis Hash 会话 + Pub/Sub 广播) | 不做 memcached/mongo 等其它 store 实现 |
约束:
- 默认关闭:
socketio.enabled=true才装配,未配置的服务不启动端口、不创建 Bean - netty 版本锁定:netty-socketio 2.0.14 依赖 netty 4.1.130.Final(
HttpDecoderConfig),模块内dependencyManagement引入 netty-bom 防父 POM 版本管理降级导致运行时ClassNotFoundException - EIO4 时序:netty-socketio 2.0.14 实测 connect 回调可能先于 auth 回调执行,user 房间加入必须在 auth 回调完成
- 集群模式前提:应用已配置
RedisMessageListenerContainer+RedisTemplate,且 RedisTemplate 的 value 序列化器需保留类型信息(见 USAGE FAQ)
三、总体架构
flowchart TD
subgraph "Auto Configuration"
A1["EasySocketioConfiguration<br/>socketio.enabled=true 才装配"]
A2["SocketioProperties<br/>socketio.* 配置绑定"]
A3["RedisStoreConfiguration<br/>ConditionalOnClass(RedisMessageListenerContainer)"]
A1 --> A2
A3 --> A1
end
subgraph "Core Lifecycle"
B1["ServerAdapter<br/>FactoryBean + SmartLifecycle"]
B2["SocketIOServer<br/>暴露为 Spring Bean 供注入推送"]
B3["RoomAdmin<br/>subscribe/unsubscribe 房间协议"]
B1 --> B2
B1 --> B3
end
subgraph "SPI 扩展点"
C1["AuthListener<br/>握手鉴权 + 订阅校验"]
C2["BaseHandler<br/>topic 事件处理器"]
end
subgraph "Store 扩展"
D1["RedisStoreFactory<br/>Redis Hash 会话 + Pub/Sub 广播"]
D2["In-Memory Store<br/>netty-socketio 默认单机模式"]
end
A1 --> B1
C1 --> B1
C2 --> B1
A3 --> D1
D1 --> B1
D2 --> B2
连接建立与房间加入流程(EIO4 协议):
flowchart TD
A["客户端握手<br/>auth 载荷 token 或 URL token 参数"] --> B{"AuthListener.authenticate"}
B -->|"返回 userCode"| C["client.set(userCode)<br/>加入 user:{code} 房间"]
B -->|"返回 null 且 authRequired=true"| D["拒绝连接 unauthorized"]
B -->|"返回 null 且 authRequired=false"| E["anonymous 只进 broadcast"]
C --> F["连接成功<br/>加入 broadcast 房间"]
E --> F
F --> G["客户端 emit subscribe room"]
G --> H{"canSubscribe 校验"}
H -->|"通过"| I["joinRoom(product:{code})"]
H -->|"拒绝"| J["拒绝并记日志"]
装配规则:EasySocketioConfiguration 是唯一入口;RedisStoreConfiguration 是嵌套条件配置——类路径存在 RedisMessageListenerContainer 且容器中已有该 Bean 时注册 RedisStoreFactory(集群模式),否则不注册(单机内存模式)。业务代码对两种模式零感知。
四、核心 API 草图
4.1 配置属性(SocketioProperties)
| 属性 | 默认值 | 说明 |
|---|---|---|
socketio.enabled | false | 是否启用(唯一开关,未启用零装配) |
socketio.hostname | 0.0.0.0 | 监听地址 |
socketio.port | 9092 | 监听端口(独立于应用 HTTP 端口) |
socketio.path | /socket.io | 握手路径,需与前端 io(url, { path }) 一致 |
socketio.namespace | / | 命名空间 |
socketio.authRequired | false | 握手强制鉴权;false 时未通过鉴权的连接以 anonymous 身份只进 broadcast 房间 |
4.2 AuthListener(握手鉴权 SPI)
public interface AuthListener {
/** 校验连接令牌,通过返回 userCode,失败返回 null */
String authenticate(String token);
/** 房间订阅权限校验,默认放行 */
default boolean canSubscribe(String userCode, String room) {
return true;
}
}
由消费方实现并注册为 Bean(如复用 AuthorizeService)。token 来源优先级:client v4 auth 载荷 { "token": "xxx" } → URL 参数 ?token=xxx。
4.3 BaseHandler(业务事件处理器基类)
/** 一个子类对应一个客户端可调用的事件 */
public abstract class BaseHandler<E extends Event> implements DataListener<E> {
protected BaseHandler(String topic, Class<E> eventClass);
protected abstract void handle(SocketIOServer server, SocketIOClient client, E msg);
}
/** 事件载荷标记接口 */
public interface Event { }
/** 定向投递载荷:目标房间/客户端 + 消息体 */
public class Packet<M extends Event> {
private Set<String> rooms; // 目标房间
private Set<String> clients; // 目标客户端 sessionId
private String sender; // 发送方标识
private M message; // 消息体
}
所有 BaseHandler Bean 由 ServerAdapter 统一注册:namespace.addEventListener(topic, eventClass, handler)。
4.4 RoomAdmin(房间协议)
| 房间 | 加入方式 | 用途 |
|---|---|---|
user:{userCode} | 鉴权通过自动加入 | 点对点推送 |
broadcast | 连接成功自动加入 | 全员广播 |
product:{code} | 客户端显式 emit("subscribe"),经 canSubscribe 校验 | 业务对象级订阅(如单据动态) |
RoomAdmin.roomUser(userCode); // "user:U001"
RoomAdmin.roomProduct(code); // "product:PRD-001"
// 协议事件常量:subscribe / unsubscribe,载荷 RoomRequest { room }
4.5 命名空间解析(ServerAdapter.resolveNamespace)
/** 默认命名空间 "/" 在 netty-socketio 内部名为空串(构造时预创建) */
public static SocketIONamespace resolveNamespace(SocketIOServer server, String namespace);
推送方必须经此方法获取命名空间。直接 server.getNamespace("/") 会创建一个新命名空间,导致监听与投递错位。
4.6 SocketClient(客户端门面)
服务端到服务端通信封装(EIO4),典型用途:与独立部署的 easy-notification 交互、连通性探测、事件双向订阅:
// 连接(token 经 auth 载荷传递,对应服务端 AuthListener.authenticate;匿名传 null)
SocketClient client = SocketClient.connect("http://host:9092", token);
// 发送事件(POJO/Map 经 JsonMapper 序列化为 JSON 对象)
client.emit("chat", Map.of("room", "product:P1", "content", "hello"));
// 订阅事件(载荷自动反序列化)与连接生命周期
client.on("order.paid", OrderPaidPayload.class, payload -> ...);
client.on(Socket.EVENT_CONNECT, args -> ...);
client.close(); // 断开(自动重连由 io.socket-client 内建,默认开启)
依赖 io.socket:socket.io-client + org.json:json(均 optional,client 使用方显式引入,server 侧零负担)。
五、包结构与核心类
easy-socketio/
└── io/matrix/socketio/
├── autoconfig/
│ ├── EasySocketioConfiguration.java # 自动配置入口(enabled 门控 + Redis 嵌套条件)
│ └── SocketioProperties.java # socketio.* 配置属性
├── core/
│ ├── ServerAdapter.java # 生命周期适配器(FactoryBean + SmartLifecycle + 鉴权/房间/事件组装)
│ ├── AuthListener.java # 握手鉴权 SPI
│ ├── RoomAdmin.java # 房间协议实现 + 房间名静态工具
│ └── RoomRequest.java # subscribe/unsubscribe 载荷 { room }
├── client/
│ └── SocketClient.java # socket.io 客户端门面(connect/emit/on/close,服务端间通信)
├── handler/
│ ├── BaseHandler.java # topic 事件处理器基类(自动注册)
│ ├── Event.java # 事件载荷标记接口
│ └── Packet.java # 定向投递载荷(rooms/clients/sender/message)
└── store/
├── RedisStoreFactory.java # 集群模式 Store 工厂
├── RedisPubSubStore.java # Redis Pub/Sub 跨节点广播(nodeId 回环抑制)
└── RedisStore.java # Redis Hash 会话存储(Store_{sessionId})
| 类 | 职责 | 文件 |
|---|---|---|
| EasySocketioConfiguration | 自动配置入口:装配 ServerAdapter,嵌套 Redis 条件注册 RedisStoreFactory | EasySocketioConfiguration.java |
| SocketioProperties | socketio.* 六项配置绑定 | SocketioProperties.java |
| ServerAdapter | 生命周期托管 + 握手鉴权组装 + 房间协议挂载 + BaseHandler 注册 | ServerAdapter.java |
| AuthListener | 握手鉴权与订阅校验 SPI,消费方实现 | AuthListener.java |
| RoomAdmin | 三级房间协议 + subscribe/unsubscribe 处理 | RoomAdmin.java |
| BaseHandler | 业务 topic 事件处理器基类 | BaseHandler.java |
| Packet | 定向投递载荷约定 | Packet.java |
| RedisStoreFactory | 集群 Store 工厂(会话 + 广播) | RedisStoreFactory.java |
| RedisPubSubStore | 跨节点 Pub/Sub 广播,nodeId 过滤自身消息 | RedisPubSubStore.java |
| RedisStore | 会话元数据 Redis Hash 存储 | RedisStore.java |
依赖:
| 依赖 | scope | 版本 | 说明 |
|---|---|---|---|
netty-socketio | compile | 2.0.14 | Socket.IO 服务端引擎 |
netty-bom | import | 4.1.130.Final | 模块内锁定 netty 版本,防父 POM 降级 |
kernel-common | compile | 父 POM | JsonMapper(SocketClient 序列化) |
spring-boot-starter | compile | 父 POM | 自动配置基础 |
spring-data-redis | optional | 父 POM | 集群模式 Store 扩展点 |
socket.io-client | optional | 2.1.2 | SocketClient 客户端能力(使用方显式引入) |
org.json:json | optional | 20240303 | io.socket 序列化协议所需 |
六、关键设计决策
| 决策 | 内容 | 理由 |
|---|---|---|
| SmartLifecycle + FactoryBean | ServerAdapter 实现 SmartLifecycle(start/stop 跟随容器)并作为 FactoryBean<SocketIOServer> 暴露服务器实例 | 生命周期与 Spring 容器对齐;消费方直接注入 SocketIOServer 推送 |
| 默认关闭 | @ConditionalOnProperty(socketio.enabled=true) | 未使用的服务不占端口、不建 Bean,符合组件”零影响”原则 |
| 鉴权前置到握手 | addAuthTokenListener 在握手阶段完成校验,失败直接 AuthTokenResult(false) | 不给无效连接分配会话资源;anonymous 场景由 authRequired=false 显式放行 |
| user 房间在 auth 回调加入 | EIO4 下 connect 可能先于 auth 执行(2.0.14 实测);connect 回调仅做幂等兜底 | 保证鉴权成功即刻进入个人房间,不依赖回调顺序 |
| 命名空间统一解析 | resolveNamespace 将 "/" 映射为内部名 ""(构造时预创建) | 避免 getNamespace("/") 新建命名空间导致监听/投递错位 |
| 三级房间协议 | user:{code}(个人)/ broadcast(全员)/ product:{code}(显式订阅 + 校验) | 覆盖点对点、全员、业务对象三种推送粒度,业务对象房间按需订阅降低无效流量 |
| 集群开关自动化 | 以 RedisMessageListenerContainer Bean 存在性自动切换单机/集群,业务零改动 | 复用应用已有 Redis 基础设施,不引入 Redisson;回环用 nodeId 过滤抑制 |
| BaseHandler 约定优于配置 | 一个子类一个 topic,ObjectProvider 收集自动注册 | 消费方只写处理器类,不碰服务器组装 |
| netty-bom 模块内锁定 | dependencyManagement import netty-bom 4.1.130.Final | 父 POM 版本管理降级 netty 会触发 HttpDecoderConfig ClassNotFoundException,且该问题只在运行期暴露 |
七、已知问题与待改进
| 问题 | 严重度 | 说明 |
|---|---|---|
| easy-notification 重写完成 | — | 历史问题(netty 版本冲突致其暂离构建)已解决:easy-notification 已基于本组件重写并恢复构建,冲突根源(旧 netty-socketio 1.7.19 直接依赖)已消除 |
| 未纳入 kernel-dependencies BOM | 中 | 消费方引用需显式版本;待补 BOM 条目 io.matrix:easy-socketio:${revision} |
| 集群模式无自动化测试 | 中 | SocketioConnectivityTest 只覆盖单机连通性(EIO4 握手 + broadcast 推送);集群回归需真实 Redis,暂靠人工验证 |
| RedisTemplate 序列化器约束 | 中 | 集群 Pub/Sub 消息经 redisTemplate.getValueSerializer() 序列化/反序列化,序列化器需保留类型信息(JDK 序列化或带 defaultTyping 的 Jackson),否则跨节点反序列化失败 |
RedisStoreFactory.createMap 未实现 | 低 | 源码核查 netty-socketio 2.0.14 未调用,返回 null(TODO 标记) |
Pub/Sub 频道名 LBD_PUB_SUB_ 前缀 | 低 | 历史遗留命名,建议后续统一为 socketio:pubsub: 风格 |
| SB3 迁移待办 | 低 | spring.factories 需迁移为 AutoConfiguration.imports(见 SB3 升级计划) |