一佳技术

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.enabledfalse是否启用(唯一开关,未启用零装配)
socketio.hostname0.0.0.0监听地址
socketio.port9092监听端口(独立于应用 HTTP 端口)
socketio.path/socket.io握手路径,需与前端 io(url, { path }) 一致
socketio.namespace/命名空间
socketio.authRequiredfalse握手强制鉴权;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 条件注册 RedisStoreFactoryEasySocketioConfiguration.java
SocketioPropertiessocketio.* 六项配置绑定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-socketiocompile2.0.14Socket.IO 服务端引擎
netty-bomimport4.1.130.Final模块内锁定 netty 版本,防父 POM 降级
kernel-commoncompile父 POMJsonMapper(SocketClient 序列化)
spring-boot-startercompile父 POM自动配置基础
spring-data-redisoptional父 POM集群模式 Store 扩展点
socket.io-clientoptional2.1.2SocketClient 客户端能力(使用方显式引入)
org.json:jsonoptional20240303io.socket 序列化协议所需

六、关键设计决策

决策内容理由
SmartLifecycle + FactoryBeanServerAdapter 实现 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 升级计划)