一佳技术

easy-socketio 使用指南

easy-socketio 是基于 netty-socketio 2.0.14 的 Spring Boot Socket.IO 服务端组件:自动配置 + 生命周期托管 + 握手鉴权 SPI + 房间协议 + 可选 Redis 集群模式。本文覆盖从引入到集群部署的全部使用场景,设计细节见 design.md。


1. 引入依赖

<dependency>
    <groupId>io.matrix</groupId>
    <artifactId>easy-socketio</artifactId>
    <version>${revision}</version>
</dependency>

注意:easy-socketio 暂未纳入 kernel-dependencies BOM,当前需显式指定版本(待补 BOM 条目后可省略)。

前端使用官方 socket.io-client v4(EIO4 协议):

<script src="https://cdn.socket.io/4.x/socket.io.min.js"></script>

2. 最小配置启用

组件默认关闭,socketio.enabled=true 才会装配并启动端口:

socketio:
  enabled: true
  port: 9092
  # hostname: 0.0.0.0     # 监听地址,默认全网卡
  # path: /socket.io       # 握手路径,需与前端 io(url, { path }) 一致
  # namespace: /           # 命名空间
  # authRequired: false    # 是否强制握手鉴权

启用后 SocketIOServer 会作为 Spring Bean 暴露,随容器启停(SmartLifecycle),无需手工 start/stop。

3. 实现握手鉴权 SPI

实现 AuthListener 并注册为 Bean:

@Component
public class AppAuthListener implements AuthListener {

    @Override
    public String authenticate(String token) {
        // 校验 token,通过返回 userCode,失败返回 null
        return authorizeService.verify(token);
    }

    @Override
    public boolean canSubscribe(String userCode, String room) {
        // 房间订阅权限校验,按业务房间语义判定;默认放行
        return true;
    }
}

前端连接时携带 token,两种方式任选(auth 载荷优先):

// 方式一:auth 载荷(client v4 推荐)
const socket = io("http://localhost:9092", { auth: { token: "xxx" } });
// 方式二:URL 参数
const socket = io("http://localhost:9092?token=xxx");

鉴权结果:

情形结果
authenticate 返回 userCode自动加入 user:{userCode} 个人房间
返回 null 且 authRequired=true连接被拒绝(unauthorized)
返回 null 且 authRequired=false以 anonymous 身份连接,只进 broadcast 房间

4. 编写业务事件处理器

一个 BaseHandler 子类对应一个客户端可调用的事件(topic),注册为 Bean 即自动挂载:

// 1. 定义事件载荷(实现 Event 标记接口)
@Data
public class TraceEvent implements Event {
    private String orderNo;
}

// 2. 编写处理器
@Component
public class TraceHandler extends BaseHandler<TraceEvent> {

    public TraceHandler() {
        super("trace", TraceEvent.class);   // topic 与事件类型
    }

    @Override
    protected void handle(SocketIOServer server, SocketIOClient client, TraceEvent msg) {
        // 客户端 emit("trace", {...}) 触发
    }
}

客户端调用:

socket.emit("trace", { orderNo: "SF123456" });

5. 服务端推送

直接注入 SocketIOServer 推送。命名空间必须经 ServerAdapter.resolveNamespace 获取(默认 / 在 netty-socketio 内部名为空串,直接 getNamespace("/") 会新建命名空间导致监听与投递错位):

@Service
public class PushService {

    private final SocketIOServer server;

    public PushService(SocketIOServer server) {
        this.server = server;
    }

    /** 点对点:推送到用户个人房间 */
    public void pushToUser(String userCode, String topic, Object msg) {
        ServerAdapter.resolveNamespace(server, "/")
                .getRoomOperations(RoomAdmin.roomUser(userCode))
                .sendEvent(topic, msg);
    }

    /** 全员广播 */
    public void broadcast(String topic, Object msg) {
        ServerAdapter.resolveNamespace(server, "/")
                .getRoomOperations(RoomAdmin.ROOM_BROADCAST)
                .sendEvent(topic, msg);
    }
}

客户端接收:

socket.on("notice", (msg) => { /* ... */ });

定向投递载荷 Packet(rooms/clients/sender/message)是业务侧到推送侧的传输约定,供上层推送服务(如 easy-notification)解析后调用 sendEvent。

6. 房间协议

三级房间,前两级自动加入,业务对象房间由客户端显式订阅:

房间加入方式用途
user:{userCode}鉴权通过自动加入点对点推送
broadcast连接成功自动加入全员广播
product:{code}客户端显式订阅(经 canSubscribe 校验)业务对象级订阅(如单据动态)

客户端订阅/退订:

socket.emit("subscribe", { room: "product:PRD-001" });
socket.emit("unsubscribe", { room: "product:PRD-001" });

服务端推送业务房间:

ServerAdapter.resolveNamespace(server, "/")
        .getRoomOperations(RoomAdmin.roomProduct("PRD-001"))
        .sendEvent("notice", msg);

7. 集群部署(分布式房间)

无需改业务代码。应用已有 Redis 时(容器中存在 RedisMessageListenerContainer + RedisTemplate Bean),组件自动切换为分布式模式:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
模式触发条件行为
单机(默认)无 RedisMessageListenerContainernetty-socketio 内存房间
集群存在 RedisMessageListenerContainerRedisStoreFactory:会话存 Redis Hash(Store_{sessionId}),跨节点广播走 Redis Pub/Sub,nodeId 过滤抑制回环

多节点部署注意:

  1. RedisTemplate value 序列化器必须保留类型信息(JDK 序列化,或带 defaultTyping 的 Jackson),Pub/Sub 消息按 PubSubMessage 类型反序列化,普通 JSON 序列化器会失败
  2. 负载均衡建议对 /socket.io 配置 sticky session(主要影响 polling 降级场景;WebSocket 升级后连接本身固定节点)
  3. 组件不缓存离线消息,可靠投递/离线补偿由上层业务设计

8. 客户端能力(SocketClient)

服务端到服务端通信(如业务服务连接独立部署的 easy-notification、连通性探测)。依赖 optional,使用方需显式引入:

<dependency>
    <groupId>io.socket</groupId>
    <artifactId>socket.io-client</artifactId>
    <version>2.1.2</version>
</dependency>
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20240303</version>
</dependency>
// 连接(token 经 auth 载荷传递,对应服务端 AuthListener.authenticate;匿名传 null)
SocketClient client = SocketClient.connect("http://host:9092", token);

client.emit("chat", Map.of("room", "product:PRD-001", "content", "hello"));  // POJO/Map 自动 JSON 序列化
client.on("order.paid", OrderPaidPayload.class, payload -> ...);              // 订阅并反序列化
client.on(Socket.EVENT_CONNECT, args -> ...);                                 // 连接生命周期
client.close();                                                               // 断开(自动重连内建,默认开启)

9. 配置项

配置默认值说明
socketio.enabledfalse是否启用(唯一开关,未启用零装配)
socketio.hostname0.0.0.0监听地址
socketio.port9092监听端口(独立于应用 HTTP 端口)
socketio.path/socket.io握手路径,需与前端 io(url, { path }) 一致
socketio.namespace/命名空间
socketio.authRequiredfalse握手强制鉴权;false 时 anonymous 只进 broadcast

10. 常见问题 FAQ

Q1:为什么调试时 connect 回调先于 auth 执行? EIO4 协议时序问题(netty-socketio 2.0.14 实测)。组件已处理:user 房间在 auth 回调内立即加入,connect 回调仅做幂等兜底,业务无感。

Q2:推送发出去了但客户端收不到? 按序排查:① 命名空间是否经 resolveNamespace 获取(直接 getNamespace("/") 会错位);② 房间名是否一致(user: / product: 前缀拼写);③ topic 是否一致;④ 集群模式下确认 Redis 配置生效(见 Q3)。

Q3:集群下收不到其它节点的广播? ① 确认容器中存在 RedisMessageListenerContainer Bean(分布式模式的唯一开关);② 检查 RedisTemplate value 序列化器是否保留类型信息——普通 JSON 序列化器反序列化 PubSubMessage 会失败,且异常可能被监听线程吞掉,建议先用 JDK 序列化验证。

Q4:socket.io 独立端口 9092 会撞应用端口吗? 不会。netty-socketio 启动独立 HTTP 服务,不经过 Spring MVC,与应用 HTTP 端口互不干扰;跨域由 socket.io 协议握手处理,不受应用 CORS 配置影响。

Q5:如何对连接做更细的房间权限控制? 实现 AuthListener.canSubscribe(userCode, room),按房间语义(如 product: 前缀解析业务码)判定,返回 false 即拒绝订阅并记录日志。