HTTP Upgrade 到 WebSocket
WebSocket 不是独立的协议,而是 HTTP 的升级。客户端先发送带 Upgrade: websocket 头的 HTTP 请求,服务端返回 101 Switching Protocols,之后连接转为全双工帧传输。
Reactor Netty 把这个过程封装在 .websocket() 方法中:
import reactor.netty.http.server.HttpServer;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
public class WsServer {
public static void main(String[] args) {
HttpServer.create().port(8080)
.route(routes -> routes
.ws("/ws/echo", (wsInbound, wsOutbound) -> {
// 把收到的所有文本帧原样返回
return wsOutbound.sendString(
wsInbound.receive().asString()
);
})
)
.bindNow();
System.out.println("WebSocket: ws://localhost:8080/ws/echo");
try { System.in.read(); } catch (Exception ignored) {}
}
}
.route(routes -> ...) 是比 .handle() 更正式的路由 API,支持按路径和方法匹配。.ws("/ws/echo", handler) 自动处理 HTTP upgrade 握手,把匹配路径的连接交给 WebSocket handler。
WebSocketFrame 与帧类型
WebSocket 消息被分割为帧。WebsocketInbound.receiveFrames() 返回 Flux<WebSocketFrame>;常用的 receive() 返回的是 ByteBufFlux,只有消息 payload,没有帧类型信息。要区分文本帧、二进制帧和控制帧,必须用 receiveFrames():
| 帧类型 | 含义 | 处理 |
|---|---|---|
TextWebSocketFrame | 文本数据 | .text() 读取字符串 |
BinaryWebSocketFrame | 二进制数据 | .content() 读取 ByteBuf |
CloseWebSocketFrame | 关闭连接 | 发回 Close 帧,释放资源 |
PingWebSocketFrame | 心跳探测 | 自动回复 Pong(Reactor Netty 默认) |
PongWebSocketFrame | 心跳响应 | 通常无需处理 |
区分帧类型
.ws("/ws/typed", (in, out) -> {
return out.sendString(
in.receiveFrames()
.map(frame -> {
// 帧对象在本回调返回后由 Reactor Netty 自动释放:这里只读内容,不要手动 release
if (frame instanceof TextWebSocketFrame text) {
return "TEXT:" + text.text();
}
if (frame instanceof BinaryWebSocketFrame binary) {
return "BINARY:" + binary.content().readableBytes() + " bytes";
}
if (frame instanceof CloseWebSocketFrame) {
return "CLOSE";
}
return "OTHER:" + frame.getClass().getSimpleName();
})
);
})
引用计数只有一条规则:receive() 和 receiveFrames() 发出的对象都归 Reactor Netty 所有,它在 onNext 返回后自动释放。因此在本回调里同步读取内容(asString()、text()、content().readableBytes())既不需要 retain(),也不能手动 release()——手动释放会让随后的自动释放抛出 IllegalReferenceCountException: refCnt: 0, decrement: 1,当前连接直接断开。只有当对象需要跨出当前回调继续存活时才 retain(),例如交给另一条连接异步发送(client.send(req.receive().retain()))或放进缓存稍后读取;这时释放责任转移到接收方,接收方消费完必须自己释放。反过来,receive().retain().asString() 是常见误区:retain() 多留了一个引用,asString() 读完并不释放,结果是每帧泄漏一块直接内存。
服务端主动推送
WebSocket handler 的返回类型是 Publisher<Void>(实际几乎总是用 Mono<Void> 或 NettyOutbound),它的终止表示 handler 的生命周期结束。如果你想在连接期间持续推送(如定时心跳、行情广播),需要用 Flux.merge 组合多个流:
import java.time.Duration;
.ws("/ws/push", (in, out) -> {
// 流 1:每 5 秒推送一次心跳
Flux<String> heartbeat = Flux.interval(Duration.ofSeconds(5))
.map(i -> "{\"type\":\"heartbeat\",\"seq\":" + i + "}");
// 流 2:把客户端消息加上时间戳回显
Flux<String> echo = in.receive().asString()
.map(msg -> "{\"type\":\"echo\",\"msg\":\"" + msg + "\",\"time\":" + System.currentTimeMillis() + "}");
// 合并两个流输出
return out.sendString(Flux.merge(heartbeat, echo));
})
Flux.merge 把多个流交错合并。心跳流和客户端消息流同时输出到 wsOutbound。
连接生命周期
WebSocket handler 返回的 Mono<Void> 完成时,连接关闭。要保持连接直到客户端主动断开,需要让 Mono 永远不完成:
.ws("/ws/persistent", (in, out) -> {
// 1. 接收客户端消息(消费 inbound,否则 receive() 不请求)
Mono<Void> receive = in.receive()
.asString()
.doOnNext(msg -> System.out.println("Received: " + msg))
.then(); // 转成 Mono<Void>,但只在连接关闭时完成
// 2. 持续推送
Mono<Void> send = out.sendString(
Flux.interval(Duration.ofSeconds(1))
.map(i -> "tick-" + i)
).then();
// 3. 任一端终止(完成或出错)时结束 handler,连接随之关闭
return Mono.zip(receive, send).then();
})
关键点:
in.receive()必须被订阅,否则 Reactor Netty 不读取客户端发来的数据,TCP 窗口会阻塞out.sendString()必须被调用,否则没有数据可写Mono.zip(receive, send).then()在Mono<Void>上不是等两端都完成:两个流都不发射值,任何一方完成或出错,zip 就立即终止并取消另一方。这里正是要这个语义——客户端断开让receive完成,或发送流结束/出错,都会结束 handler 并关闭连接- 如果确实需要等两个流都自然结束,用
Mono.when(receive, send);但对 WebSocket 持久连接,任一端终止就关闭更符合预期,也能避免对端已断开后继续向已关闭的通道写数据
Ping/Pong 心跳
虽然 Reactor Netty 自动回复 Ping 帧,但服务端主动发送 Ping 可以更快地检测断开的客户端:
.ws("/ws/heartbeat", (in, out) -> {
// 每 10 秒发送一次 Ping
Flux<WebSocketFrame> pings = Flux.interval(Duration.ofSeconds(10))
.map(i -> new PingWebSocketFrame());
Mono<Void> sendPings = out.sendObject(pings).then();
// 30 秒收不到任何帧(正常客户端会自动回 Pong)就超时并关闭连接
Mono<Void> receive = in.receiveFrames()
.timeout(Duration.ofSeconds(30))
.doOnNext(frame -> {
// 帧由 Reactor Netty 自动释放,这里只判断类型,不要 release
if (frame instanceof PongWebSocketFrame) {
System.out.println("Pong received at " + System.currentTimeMillis());
}
})
.then();
return Mono.zip(sendPings, receive).then();
})
注意:PingWebSocketFrame 在 Netty 的 io.netty.handler.codec.http.websocketx 包中,不要在 handler 返回流中发送纯字符串 ping——浏览器/客户端不会识别。
receive 上的 timeout 触发后会发出 onError,Mono.zip 立即把错误传给 handler,连接随即关闭,这正是心跳超时想要的效果;sendPings 则被取消。
可运行完整示例
import reactor.netty.http.server.HttpServer;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
public class EchoWsServer {
public static void main(String[] args) {
HttpServer.create().port(8080)
.route(routes -> routes
.ws("/ws", (in, out) -> {
Flux<String> ticks = Flux.interval(Duration.ofSeconds(5))
.map(i -> "[server] tick " + i);
Flux<String> responses = in.receive().asString()
.map(msg -> "[echo] " + msg);
return out.sendString(Flux.merge(ticks, responses));
})
)
.bindNow();
System.out.println("ws://localhost:8080/ws");
System.out.println("Test: wscat -c ws://localhost:8080/ws");
try { System.in.read(); } catch (Exception ignored) {}
}
}
编译运行后用 wscat 测试:
# 安装 wscat
npm install -g wscat
# 连接测试
wscat -c ws://localhost:8080/ws
> hello
< [echo] hello
< [server] tick 0
< [server] tick 1
与 HTTP 路由共存
同一个 HttpServer 可以同时处理 HTTP 和 WebSocket:
HttpServer.create().port(8080)
.route(routes -> routes
.get("/health", (req, res) ->
res.sendString(Mono.just("ok")))
.get("/api/status", (req, res) ->
res.sendString(Mono.just("{\"status\":\"up\"}")))
.ws("/ws", (in, out) ->
out.sendString(in.receive().asString()))
)
.bindNow();
route() API 支持 .get()、.post()、.put()、.delete() 以及 .ws(),每个路由独立匹配。
小结
| 能力 | API | 注意 |
|---|---|---|
| 开启 WebSocket | .route(r -> r.ws("/path", handler)) | 自动处理 HTTP upgrade |
| 接收消息 | receive()(payload)/ receiveFrames()(带帧类型) | 必须订阅,否则 TCP 阻塞;对象由 Reactor Netty 自动释放,不要手动 release |
| 发送消息 | wsOutbound.sendString() / sendObject() | sendObject 需正确帧类型 |
| 持久连接 | Mono.zip(receive, send).then() | 任一端终止就结束 handler(关闭连接);Mono.when 才是等双方都完成 |
| 帧类型 | instanceof Text/Binary/Close/Ping/Pong | Close 帧只需识别,不用手动释放 |
| 心跳 | Flux.interval + PingWebSocketFrame | 客户端也应回复 Pong |
| 引用计数 | .retain() | 只在对象跨出 onNext 异步转发时使用 |
下一节 3.2 WebSocket 会话管理与广播 :多 session 管理、mapConnect、广播与订阅模式、背压在广播场景的特殊处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。