WebSocket
这篇解决什么
公告、站内信、IM、客服要把事件立刻推到浏览器,不能靠用户刷新列表。读完能连上 /infra/ws、写一个 WebSocketMessageListener,并用 WebSocketMessageSender / WebSocketSenderApi 按用户或 Session 下发。
默认端口 48080。握手走 ws://127.0.0.1:48080/infra/ws?token=xxx,不走 /admin-api。Starter 基于 Spring WebSocket,单体进程里由 infra-server 持有连接。
示意图:浏览器连框架握手;业务模块通过 Sender 或 Listener 进出同一条通道。
为什么用 Spring WebSocket
Netty 也能做长连接,但学习成本和运维面更大。现有封装已经覆盖鉴权、Session、按 type 分发和多进程广播,够公告、IM、客服这类场景。
组件位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| WebSocket Starter | @EnableWebSocket,注册路径和 Sender | ruoyi-office/yudao-framework/yudao-spring-boot-starter-websocket/ |
| 自动配置 | YudaoWebSocketAutoConfiguration | .../websocket/config/YudaoWebSocketAutoConfiguration.java |
| 配置项 | 路径、发送器类型 | .../websocket/config/WebSocketProperties.java |
| 开关与路径 | 默认开,路径 /infra/ws,发送器 local | yudao-server/.../application.yaml 的 yudao.websocket |
| 依赖入口 | 只有 infra 引入 Starter | yudao-module-infra-server/pom.xml |
| 测试页 | 单聊 / 群聊调试 | ruoyi-office-vben/apps/web-antd/src/views/infra/webSocket/index.vue |
| 拼地址 | 站点根路径 + token 查询参数 | .../src/utils/websocket.ts |
yudao.websocket.enable=false 会整套关掉自动配置。WebSocketMessageSender 变成可选 Bean,监听器里必须 @Autowired(required = false)。
连接鉴权
浏览器 WebSocket 握手不能方便地带 Authorization 头。前端把刷新令牌拼到查询串:/infra/ws?token=xxx。
TokenAuthenticationFilter 先按 Header、再按参数名 token 取令牌,校验后写入 SecurityContext。LoginUserHandshakeInterceptor 再把 LoginUser 放进 Session attributes;取不到用户直接拒绝握手。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 查询参数名 | yudao.security.token-parameter,默认 token | yudao-spring-boot-starter-security/.../SecurityProperties.java |
| 取令牌 | Header 优先,空则读 Parameter | .../SecurityFrameworkUtils.obtainAuthorization |
| 过滤器 | 校验 OAuth2 令牌,写入登录用户 | .../TokenAuthenticationFilter.java |
| 握手拦截 | 无 LoginUser 返回 false | .../websocket/core/security/LoginUserHandshakeInterceptor.java |
| 路径放行 | /infra/ws 对 Security 免登录 | .../WebSocketAuthorizeRequestsCustomizer.java |
| 读用户 | id / userType / tenantId | .../WebSocketFrameworkUtils.java |
permitAll 只让握手请求进过滤器链,不等于匿名可连。拦截器仍要求已认证。
管理端用 refreshToken,不用短寿命的 accessToken:长连接期间没法走常规刷新接口。buildWebSocketUrl('/infra/ws', refreshToken) 会改成 ws: / wss: 并写入 token。
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
return false;
}
WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
return true;令牌失效连接会立刻断
过期、错误或空的 token 过不了握手。前端要在登录成功后再 open,登出时 close。不要指望连上之后再补鉴权。
Session
每条浏览器连接对应一个 WebSocketSession。建立时包一层 ConcurrentWebSocketSessionDecorator(发送限时 5 秒、缓冲 100KB),再交给 WebSocketSessionManagerImpl 按 Session id 和「用户类型 + 用户编号」建索引。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 管理接口 | 增删、按 id / 用户查 | .../session/WebSocketSessionManager.java |
| 内存实现 | ConcurrentHashMap + 按用户列表 | .../session/WebSocketSessionManagerImpl.java |
| 装饰器 | 连接登记、关闭移除 | .../session/WebSocketSessionHandlerDecorator.java |
按用户类型拉列表时,若当前线程有 tenantId,会丢掉其他租户的 Session。推「全部在线管理员」时,先保证租户上下文正确。
同一用户可以多开标签页:getSessionList(userType, userId) 返回该用户全部连接,send 会逐条下发。
消息格式与接收
文本帧是 JSON:type 决定监听器,content 再反序列化成监听器泛型。对 Spring MVC 的类比:type 相当于映射路径,WebSocketMessageListener 相当于 Controller 方法。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 帧对象 | type + content | .../message/JsonWebSocketMessage.java |
| 分发器 | 按 type 找监听器,并切租户 | .../handler/JsonWebSocketMessageHandler.java |
| 监听接口 | getType() + onMessage | .../listener/WebSocketMessageListener.java |
| 示例监听 | 上行 demo-message-send | yudao-module-infra-server/.../DemoWebSocketMessageListener.java |
| 示例入参 | toUserId、text | .../websocket/message/DemoSendMessage.java |
示意图:心跳直接回;业务帧按 type 分发,并带上 Session 里的租户。
空帧丢弃。type 对不上监听器只打错误日志,不抛给浏览器。处理时用 Session 上的 tenantId 包一层 TenantUtils.execute,避免工作线程租户为空。
自己加监听器:实现 WebSocketMessageListener<T>,getType() 与前端 type 一致,注册成 Spring Bean。JsonWebSocketMessageHandler 启动时收集全部监听器。
@Override
public void onMessage(WebSocketSession session, DemoSendMessage message) {
Long fromUserId = WebSocketFrameworkUtils.getLoginUserId(session);
if (message.getToUserId() != null) {
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(),
message.getToUserId(), "demo-message-receive", toMessage);
return;
}
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(),
"demo-message-receive", toMessage);
}
@Override
public String getType() {
return "demo-message-send";
}前端测试页发出的帧:
const jsonMessage = JSON.stringify({
type: 'demo-message-send',
content: JSON.stringify({
text: sendText.value,
toUserId: sendUserId.value === 'all' ? undefined : sendUserId.value,
}),
});
send(jsonMessage);心跳用字面量 ping
四个字符 ping 不走 JSON。useWebSocket 打开 heartbeat: true 时,后端回 pong。不要给心跳再包一层 type。
IM 也走同一条 /infra/ws:ImMessageSendMessageListener 等按自己的 type 进 ImConversationService。测试页会忽略 im. 前缀,避免和正式聊天抢消息。
消息推送
下行统一走 WebSocketMessageSender。三套重载:指定用户、指定用户类型(该类型全部在线)、指定 Session。sendObject 先把对象转 JSON,再放进帧的 content。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 发送接口 | send / sendObject | .../sender/WebSocketMessageSender.java |
| 本地落点 | 查 Session 后 TextMessage | .../sender/AbstractWebSocketMessageSender.java |
| 跨模块 API | Feign,路径 /rpc-api/infra/websocket/send | yudao-module-infra-api/.../WebSocketSenderApi.java |
| API 实现 | 转调 WebSocketMessageSender | yudao-module-infra-server/.../WebSocketSenderApiImpl.java |
infra 进程里直接注入 WebSocketMessageSender。system、其它模块不要引 Starter,改注入 WebSocketSenderApi,并在 RpcConfiguration 的 @EnableFeignClients 里声明。单体启动时本地实现同样生效。
公告推送是 HTTP 上行、WebSocket 下行:
@PostMapping("/push")
public CommonResult<Boolean> push(@RequestParam("id") Long id) {
NoticeDO notice = noticeService.getNotice(id);
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), "notice-push", notice);
return success(true);
}站内信渠道落库后,再 sendObject(userType, userId, "notify", payload),失败只打日志,不影响已写入的站内信。前端测试页处理 demo-message-receive 和 notice-push。
匹配不到 Session 时 AbstractWebSocketMessageSender 只打 debug,接口仍返回成功。离线用户收不到实时帧,业务若要补历史,自己查库或走拉取接口。
集群发送
WebSocketSession 只活在建立连接的那一个 Java 进程。多实例时,A 进程上的用户要推给连在 B 上的用户,必须先广播到消息中间件,各进程再查自己的 Session。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
local | 只查本进程 Session | .../sender/local/LocalWebSocketMessageSender.java |
redis | Redis 频道广播后再本地发送 | .../sender/redis/RedisWebSocketMessageSender.java |
rocketmq | Topic + Consumer Group | .../sender/rocketmq/ |
kafka | Topic + 随机 groupId 后缀 | .../sender/kafka/ |
rabbitmq | Topic Exchange + 随机队列后缀 | .../sender/rabbitmq/ |
默认 yudao.websocket.sender-type: local。切到 redis 时还要打开 Redis 消息队列;其它类型同理,先有对应中间件再改配置。
yudao:
websocket:
enable: true
path: /infra/ws
sender-type: local # local / redis / rocketmq / kafka / rabbitmqRedis / MQ 的 Consumer 调用的是抽象类五参数 send,直接扫本机 Session,不会再次投递,避免死循环。
多实例不要留 local
local 只适合单进程。-P cloud 或复制了多个 yudao-server 时,连在别的节点上的用户会静默收不到。先改 sender-type,再扩实例。
两种用法
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 纯 WebSocket | 上行 Listener,下行 Sender | 测试页 + DemoWebSocketMessageListener |
| HTTP + WebSocket | HTTP 改数据,再 WebSocketSenderApi 推 | NoticeController.push、NotifyChannelAdapter |
上行:浏览器把数据给后端,WebSocket 或 HTTP 都行。下行:后端推浏览器,只能 WebSocket。
单体里两种都能用。微服务下连接只挂在 infra-server:网关把 /infra/ws、/infra/ws/** 指到 infra-server。其它服务用 HTTP 处理业务,再经 WebSocketSenderApi 让 infra 推。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 网关路由 | infra-websocket → infra-server | ruoyi-office/yudao-gateway/.../application.yaml |
| system RPC | 声明 WebSocketSenderApi | yudao-module-system-server/.../RpcConfiguration.java |
| 商城客服 | 同一条 /infra/ws | web-antd/src/views/mall/promotion/kefu/index.vue |
新业务优先 HTTP 上行:校验、事务、权限和现有 Controller 一致,WebSocket 只负责把结果推给在线用户。需要低延迟互发(IM 输入、客服会话)再写 Listener。
调试入口:基础设施 → WebSocket。公告「推送」只打给当前在线的管理端连接,可在测试页看 notice-push。
配置与操作
改 yudao.websocket.sender-type 后重启。演示页在 基础设施 → WebSocket。新业务优先 HTTP 上行,Socket 只推结果。
开启见 框架层。
