Skip to content

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,注册路径和 Senderruoyi-office/yudao-framework/yudao-spring-boot-starter-websocket/
自动配置YudaoWebSocketAutoConfiguration.../websocket/config/YudaoWebSocketAutoConfiguration.java
配置项路径、发送器类型.../websocket/config/WebSocketProperties.java
开关与路径默认开,路径 /infra/ws,发送器 localyudao-server/.../application.yaml 的 yudao.websocket
依赖入口只有 infra 引入 Starteryudao-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,默认 tokenyudao-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。

java
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-sendyudao-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 启动时收集全部监听器。

java
@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";
}

前端测试页发出的帧:

ts
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
跨模块 APIFeign,路径 /rpc-api/infra/websocket/sendyudao-module-infra-api/.../WebSocketSenderApi.java
API 实现转调 WebSocketMessageSenderyudao-module-infra-server/.../WebSocketSenderApiImpl.java

infra 进程里直接注入 WebSocketMessageSender。system、其它模块不要引 Starter,改注入 WebSocketSenderApi,并在 RpcConfiguration 的 @EnableFeignClients 里声明。单体启动时本地实现同样生效。

公告推送是 HTTP 上行、WebSocket 下行:

java
@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
redisRedis 频道广播后再本地发送.../sender/redis/RedisWebSocketMessageSender.java
rocketmqTopic + Consumer Group.../sender/rocketmq/
kafkaTopic + 随机 groupId 后缀.../sender/kafka/
rabbitmqTopic Exchange + 随机队列后缀.../sender/rabbitmq/

默认 yudao.websocket.sender-type: local。切到 redis 时还要打开 Redis 消息队列;其它类型同理,先有对应中间件再改配置。

yaml
yudao:
  websocket:
    enable: true
    path: /infra/ws
    sender-type: local # local / redis / rocketmq / kafka / rabbitmq

Redis / MQ 的 Consumer 调用的是抽象类五参数 send,直接扫本机 Session,不会再次投递,避免死循环。

多实例不要留 local

local 只适合单进程。-P cloud 或复制了多个 yudao-server 时,连在别的节点上的用户会静默收不到。先改 sender-type,再扩实例。

两种用法 ​

名称说明仓库路径
纯 WebSocket上行 Listener,下行 Sender测试页 + DemoWebSocketMessageListener
HTTP + WebSocketHTTP 改数据,再 WebSocketSenderApi 推NoticeController.push、NotifyChannelAdapter

上行:浏览器把数据给后端,WebSocket 或 HTTP 都行。下行:后端推浏览器,只能 WebSocket。

单体里两种都能用。微服务下连接只挂在 infra-server:网关把 /infra/ws、/infra/ws/** 指到 infra-server。其它服务用 HTTP 处理业务,再经 WebSocketSenderApi 让 infra 推。

名称说明仓库路径
网关路由infra-websocket → infra-serverruoyi-office/yudao-gateway/.../application.yaml
system RPC声明 WebSocketSenderApiyudao-module-system-server/.../RpcConfiguration.java
商城客服同一条 /infra/wsweb-antd/src/views/mall/promotion/kefu/index.vue

新业务优先 HTTP 上行:校验、事务、权限和现有 Controller 一致,WebSocket 只负责把结果推给在线用户。需要低延迟互发(IM 输入、客服会话)再写 Listener。

调试入口:基础设施 → WebSocket。公告「推送」只打给当前在线的管理端连接,可在测试页看 notice-push。

配置与操作 ​

改 yudao.websocket.sender-type 后重启。演示页在 基础设施 → WebSocket。新业务优先 HTTP 上行,Socket 只推结果。

开启见 框架层。

相关篇 ​

联系我们

获取报价、演示和二开方案

微信咨询二维码

微信咨询

17156169080

添加时备注「RuoYi Office」

在线体验商业版