WebSocket的相关实践指导
这个笔记用于指导ws协议的相关实践,本身是对tcp协议里大致提了ws一些的补充,这里可以链接过去[[TCP协议]]
SignalR
SignalR 是微软推出的一个实时通信库,核心目标是让服务器能主动推送消息给客户端,而不需要客户端反复轮询。客户端和服务器协商后,自动选择可用的最高优先级方式,开发者不需要关心底层细节。
对于多标签共享一条,也就是SharedWorker,SignalR是唯一现成的库。
// workers/chat.shared.ts —— 用 dist/webworker/signalr.js,打包器会按 worker 条件解析
const conn = new HubConnectionBuilder().withUrl('/hubs/chat').withAutomaticReconnect().build()
await conn.start()
const ports: MessagePort[] = []
self.onconnect = (e: MessageEvent) => {
const port = e.ports[0]
ports.push(port)
port.onmessage = ({ data }) => {
if (data.type === 'send') conn.send('SendMessage', data.user, data.text)
}
port.start()
}
// 一条连接扇出给所有标签页
conn.on('ReceiveMessage', (user: string, text: string) => {
for (const p of ports) p.postMessage({ type: 'ReceiveMessage', user, text })
})
后端部分可能长这样:
// Hubs/ChatHub.cs —— 一个类就是一组可被客户端调用的方法
public sealed class ChatHub : Hub
{
// 客户端 conn.invoke('SendMessage', user, text) 落到这里
public Task SendMessage(string user, string text)
=> Clients.All.SendAsync("ReceiveMessage", user, text);
public Task<string> Ping() => Task.FromResult("pong"); // 有返回值,客户端 await 得到 "pong"
// group 是白送的,不用自己维护连接表
public Task JoinRoom(string room)
=> Groups.AddToGroupAsync(Context.ConnectionId, $"room:{room}");
public Task SendToRoom(string room, string text)
=> Clients.Group($"room:{room}").SendAsync("ReceiveMessage", "room", text);
}
// Program.cs
builder.Services.AddSignalR(o =>
{
o.KeepAliveInterval = TimeSpan.FromSeconds(15); // 默认值,服务端发 {"type":6} ping
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30); // 默认 2x,30s 内无数据判死
o.MaximumReceiveMessageSize = 32 * 1024; // 单条上限 32KB,超了直接断
});
app.MapHub<ChatHub>("/hubs/chat");
我们在前端的代码就长这样,你可以在composables里完成:
import { HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'
let conn: HubConnection | null = null // 模块级单例,别在组件里各建一条
export function useChatHub() {
const status = ref(HubConnectionState.Disconnected)
const messages = shallowRef<ChatMessage[]>([])
async function connect() {
if (conn) return conn
conn = new HubConnectionBuilder()
.withUrl('/hubs/chat') // 第一跳是 POST /hubs/chat/negotiate,不是 WS
.withAutomaticReconnect() // 源码默认退避 [0, 2s, 10s, 30s],之后放弃
.configureLogging(LogLevel.Warning)
.build()
// 服务端 -> 客户端:这个 key 必须和服务端 SendAsync("ReceiveMessage") 的字符串完全一致
conn.on('ReceiveMessage', (user: string, text: string) => {
messages.value = [...messages.value, { user, text }]
})
conn.onreconnecting(e => console.warn('reconnecting', e?.message))
conn.onreconnected(async () => {
// 重连后 connectionId 变了,之前 join 的 group 全部丢失,必须重新 join
await conn!.invoke('JoinRoom', 'lobby')
})
conn.onclose(e => { status.value = HubConnectionState.Disconnected })
await conn.start()
status.value = conn.state
await conn.invoke('JoinRoom', 'lobby')
return conn
}
// invoke 等返回值(RPC 语义);send 发完就走,少一个 RTT 的等待
const sendMessage = (user: string, text: string) =>
conn?.state === HubConnectionState.Connected
? conn.send('SendMessage', user, text)
: Promise.resolve()
// 服务端 IAsyncEnumerable<T> / ChannelReader<T> 持续推,客户端订阅
function subscribeCounter() {
const sub = conn!.stream<number>('Counter', 10)
sub.subscribe({ next: n => console.log(n), complete: () => {}, error: e => console.error(e) })
return () => sub.dispose()
}
onScopeDispose(() => { conn?.stop(); conn = null })
return { status, messages, connect, sendMessage, subscribeCounter }
}
SignalR 官方带 dist/webworker/signalr.js,源码里的 document 都写成 window.document 且有 Platform.isBrowser 守卫,在没有 window 的 Worker 里不会炸。代价:Platform.isBrowser 为 false 后不再监听窗口 freeze 事件,你会在日志里少一条"标签页被冻结导致断连"的线索。
和vueuse相比,signalr的优势在于 hub rpc、多传输降级、group、backplane这些在服务端侧,vueuse一个都不提供,也都是SignalR的立身之本。
hub可以叫做集线器,是服务端的核心类,负责连接管理和消息分发。backplane是signalr用于多服务器横向扩展的核心组件,当你应用部署在多台服务器上的时候,它负责让所有服务器之间能够互相转发消息,确保连接到不同服务器的客户端也能收到广播。group是signalr中用于管理链接分组的核心机制,可以让你只向订阅了某类消息的客户端推送,而不是广播给所有人。
Hub RPC
它不是"透明地把本地方法调用传过去",而是方法名当字符串传、服务端反射查表找回来。这一句话解释了它:
- 服务端查表用的是
StringComparer.OrdinalIgnoreCase,所以invoke('SendMessage')和invoke('sendMessage')都能命中;但客户端conn.on('ReceiveMessage')是 JS 对象键,精确匹配。这个不对称是最常踩的坑。 - 方法名既然是字符串,重构就没有编译期保护——这才有了强类型 Hub:
public interface IChatClient { Task ReceiveMessage(string user, string text); }
public class ChatHub : Hub<IChatClient> // 泛型参数一加,字符串魔法全消失
{
public Task SendMessage(string user, string text)
=> Clients.All.ReceiveMessage(user, text); // 改名不再静默失效
[HubMethodName("say")] // 对客户端暴露成 'say'
public Task Say(string text) => Clients.All.ReceiveMessage("me", text);
public Task<string> Ping() => Task.FromResult("pong"); // 客户端 await 拿得到
// 反向调用客户端并等结果 —— 客户端也要写「有返回值」的 handler
public Task<string> AskClient(string id) => Clients.Client(id).InvokeAsync<string>("GetInput");
}
- Hub 是 transient 的。官方文档原文:"Hubs are transient: Don't store state in a property of the hub class. Each hub method call is executed on a new hub instance." 所以下面这段是经典的错:
public class BadHub : Hub { private int _count; } // 每次调用都是新实例,永远是 0
// 对的:连接级状态放 Context.Items(同一连接跨多次调用保留)
public class GoodHub : Hub {
public Task Add() {
var n = (int)(Context.Items["count"] ?? 0) + 1;
Context.Items["count"] = n;
return Clients.Caller.SendAsync("Count", n);
}
}
Hub 方法参数会先从 DI 解析(.NET 7 起的破坏性变更,DisableImplicitFromServicesParameters 可关),所以 SendMessage(string a, string b, AppDbContext db) 里的 db 是从容器来的,不是客户端传的;流式则是 IAsyncEnumerable<T> 对客户端的 conn.stream<T>()。
多传输降级
这个能力的前提是:SignalR 的"连接"是逻辑概念,不绑定任何一种 HTTP 形态:
多传输降级的机制其实是"先在应用层假装一条连接,再挑具体怎么实现"。
{
"negotiateVersion": 1,
"connectionToken": "…", ← 后续请求都带着它,服务端据此找回这条逻辑连接
"availableTransports": [
{ "transport": "WebSockets", "transferFormats": ["Text","Binary"] },
{ "transport": "ServerSentEvents","transferFormats": ["Text"] },
{ "transport": "LongPolling", "transferFormats": ["Text","Binary"] }
]
}
客户端按这个列表从上往下试,谁先通用谁。几个由此推出的结论:
- SSE 只能 Text,所以二进制协议(MessagePack)会自动把 SSE 排除掉。
- LongPolling 是"每个响应就是一次假连接":服务端把请求挂住(默认
PollTimeout90 秒),有数据就吐出来,客户端拿到后立刻再发一个。所以一条逻辑连接会变成成百上千个 HTTP 请求。 - 正因为这个,negotiate 拿到的 token 必须和后续每次请求落到同一台服务器——这就是 sticky sessions 是硬要求的原因,不是可选项。即使挂了 backplane 也仍然需要(backplane 只解决跨机广播,不解决路由)。
- 有个反直觉的代价:WS 成功时那次 negotiate 是白花的一个 RTT。所以才有
skipNegotiation: true,但它只在锁定HttpTransportType.WebSockets时可用——你没法"跳过协商再降级"。
Group
它和"用户"是两种交叉的聚合维度:
group 是四样东西里最被误解的一个「服务端推送时的目标集合」- 客户端不能监听一个 group,只有服务端能往 group 推。客户端只能"加入/离开"。
- 而且客户端不能直接操作 group——
Groups.AddToGroupAsync是服务端 API,必须由你包一个 Hub 方法暴露出去。这是权限边界:否则任何人都能把自己塞进任意房间。
public class ChatHub : Hub
{
// 客户端 conn.invoke('Join', 'room:1') —— 必须经你自己的方法,这是唯一的入口
public async Task Join(string room)
{
await Groups.AddToGroupAsync(Context.ConnectionId, room); // 一定要 await
}
public Task Leave(string room) => Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
// 连接建立时自动进组 —— 重连也会重新触发,这是它最可靠的地方
public override async Task OnConnectedAsync()
{
if (Context.UserIdentifier is { } uid)
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{uid}");
await base.OnConnectedAsync();
}
public Task Push(string room) => Task.WhenAll(
Clients.Group(room).SendAsync("Msg", "给整个组"),
Clients.OthersInGroup(room).SendAsync("Msg", "组里除我之外"),
Clients.User("u1").SendAsync("Msg", "u1 的全部连接"),
Clients.Caller.SendAsync("Msg", "只回给我自己")
);
}
group 成员关系是内存态、不持久化。应用重启就全没了——但因为 OnConnectedAsync 在每次(重)连时都会跑,把 join 逻辑写在那里就等于自动重建了。这也是它比"手动维护一张组表"省心的地方。
backplane
它解决的是上面那个"映射存在哪"的直接后果:
backplane 的根源问题进程内内存Clients.Allbuilder.Services.AddSignalR()
.AddStackExchangeRedis("redis:6379"); // Hub 代码一行不改
它换掉的是 HubLifetimeManager 这个抽象的实现(DefaultHubLifetimeManager 内存版 → RedisHubLifetimeManager),而不是让你改业务代码。这个抽象是全篇的关键:所有"谁来记住谁在哪"的逻辑都被它收口了。
Vueuse实践
VueUse 是一套基于 Composition API 的 Vue 工具函数集合,@vueuse/core 是它的核心包,提供了大量开箱即用的组合式函数。具有纯函数式、响应式、SSR友好、类型完整、体积小等优点。覆盖了状态、元素、浏览器、传感器、网络、时间、工具等方面。
useWebSocket 只对应上面「客户端 → 原生 WebSocket」这一列,服务端、信封、group 全要自己写:
// app/composables/useChatSocket.ts
import { useWebSocket } from '@vueuse/core' // 经 @vueuse/nuxt 已自动导入,这行可省
interface Envelope { type: 'chat' | 'ping' | 'pong'; user?: string; text?: string }
export function useChatSocket(url: string) {
const messages = shallowRef<ChatMessage[]>([])
const { status, data, send, open, close, ws } = useWebSocket<Envelope>(url, {
// 这两个默认都是 false,必须显式开(SignalR 恰好相反,默认就开)
autoReconnect: {
retries: 10,
delay: n => Math.min(1000 * 2 ** n, 30_000), // 1s -> 2s -> 4s ... 封顶 30s
onFailed: () => console.warn('重连放弃'),
},
heartbeat: {
message: JSON.stringify({ type: 'ping' }),
responseMessage: JSON.stringify({ type: 'pong' }),
interval: 25_000, // 卡在 nginx 60s 的一半
pongTimeout: 5_000, // 5s 等不到 pong 就重连
},
immediate: false, // 先不连,由页面决定(比如等登录态就绪)
onMessage(_, ev) {
const msg = JSON.parse(ev.data) as Envelope
if (msg.type === 'pong') return // 心跳回执不进业务队列
messages.value = [...messages.value, msg as ChatMessage]
},
onDisconnected: (_, ev) => console.warn('closed', ev.code, ev.reason), // 1006 = 异常断开
})
function sendChat(user: string, text: string) {
if (status.value !== 'OPEN') return false
// 第二参 useBuffer=true:未连接时排队,连上后补发
send(JSON.stringify({ type: 'chat', user, text }), true)
return true
}
onScopeDispose(close) // 组件卸载自动断,避免"走后门"的连接
return { status, data, messages, open, close, ws, sendChat }
}
data 是「最新一条」,不是队列。** 它是个 ShallowRef<T | null>,只保留最后收到的消息。你以为它会累积?不会。要历史就必须自己在 onMessage 里攒(上面就是这么做的)。
**autoReconnect 和 heartbeat 默认都是 false。 SignalR 是默认开、默认有值;VueUse 是默认关。不显式配,断线了它不会自己回来,也不会发心跳。
heartbeat 发的是应用层消息,不是协议层 ping。因为浏览器 JS 拿不到协议 ping(这里绕不开)。所以你的服务端必须能识别 {"type":"ping"} 并回 {"type":"pong"}**——否则 pongTimeout 一到就误判断线,然后开始无限重连风暴。这是最容易炸的一处