仓颉语言 WebSocket 编程实战:从 HTTP 升级握手到帧读写
仓颉语言 WebSocket 编程实战:从 HTTP 升级握手到帧读写
WebSocket 是一种基于 TCP 的应用层实时通信协议,只需一次握手即可建立持久连接并支持服务端主动推送数据。本文基于仓颉编程语言开发者文档 WebSocket 编程,系统讲解仓颉对 WebSocket 协议的抽象方式(WebSocket 类、帧模型),并给出完整可运行的客户端与服务端握手、消息收发与关闭流程代码。读完本文,你将掌握在仓颉中使用 stdx.net.http 包完成 WebSocket 升级、处理各类帧、实现分段消息重组以及优雅关闭连接的完整实战能力。
1. WebSocket 协议与仓颉的抽象
1.1 协议定位:一次握手,双向实时通信
在网络编程中,WebSocket 是一种常用的应用层协议。与 HTTP 一样,它也基于 TCP 协议之上,常用于 Web 服务端应用开发。不同于 HTTP 的是,WebSocket 只需要客户端和服务端进行一次握手,即可创建长久的连接,并进行双向的数据传输——基于 WebSocket 实现的服务端可以主动传输数据给客户端,从而实现实时通讯。
WebSocket 是一个独立的协议,它与 HTTP 的关联在于:它的握手被 HTTP 服务端解释为一个升级(Upgrade)请求。因此,仓颉将 WebSocket 协议通信机制放在 http 包中实现。这一点在网络编程概述中也有呼应——仓颉支持 HTTP/1.0、HTTP/1.1、HTTP/2.0 等协议版本,并支持从 HTTP 协议升级至 WebSocket 协议。
要点:WebSocket 不是 HTTP 的替代品,而是通过 HTTP 的升级机制“借道”建立连接,建立完成后即切换到独立的 WebSocket 帧协议进行双向通信。
1.2 仓颉的抽象:WebSocket 类
仓颉将 WebSocket 协议通信机制抽象为 WebSocket 类,它提供方法将一个 HTTP/1.1 或 HTTP/2 服务端句柄升级到 WebSocket 协议实例。通信通过返回的 WebSocket 实例进行,例如数据报文的读写。对应地,客户端侧也通过 WebSocket.upgradeFromClient 完成握手,服务端侧通过 WebSocket.upgradeFromServer 完成握手(详见第 4 节实战代码)。
1.3 帧(Frame)模型:数据的基本传输单元
在仓颉中,WebSocket 所传输的数据基本单元称为帧。帧分为两类:
| 帧类别 | 帧类型 | 用途 |
|---|---|---|
| 控制帧 | CloseWebFrame |
用于关闭连接 |
| 控制帧 | PingWebFrame |
用于实现 Keep-Alive(心跳) |
| 控制帧 | PongWebFrame |
Ping Frame 的响应类型 |
| 应用数据帧 | TextWebFrame / BinaryWebFrame / ContinuationWebFrame |
传输应用数据,支持分段传输 |
仓颉的帧由三个属性构成,开发者无需关心其他属性即可进行报文传输:
fin:与frameType共同说明帧是否分段。fin为true表示该帧是消息的最后一个分片(或整条消息),为false表示后续还有分片;frameType:帧的类型(如TextWebFrame、BinaryWebFrame、ContinuationWebFrame、CloseWebFrame、PingWebFrame等);payload:帧的载荷(负载数据)。
其中,ContinuationWebFrame 是分段传输时的续帧:一条大消息被拆分为「首帧(Text/Binary,fin=false)+ 若干 Continuation 帧 + 末帧(Continuation,fin=true)」时,接收方通过累积各帧的 payload 并在 fin=true 时完成重组。第 4 节的完整示例正是利用了这一机制,服务端发送 4097 个 a 字符(超过单帧默认承载能力),客户端按序接收分片并重组。
2. 环境准备:引入 stdx 模块依赖
说明:
net、encoding、log等库已从仓颉 SDK 移到stdx模块,使用前需要下载软件包,并在cjpm.toml中配置。
也就是说,编写 WebSocket 程序不再使用 std.net / std.encoding,而是使用 stdx.net.http、stdx.encoding.url、stdx.log、stdx.crypto.kit 等扩展库。在cjpm 使用中说明:cjpm.toml 用来配置基础信息、依赖项、编译选项等内容,cjpm 主要通过这个文件进行解析执行,其中 [dependencies] 为源码依赖配置项,支持 git 依赖与 path 本地源码依赖两种方式:
[package] # 单模块配置字段
cjc-version = "1.0.0" # 所需 cjc 的最低版本要求,必需
name = "demo" # 模块名及模块 root 包名,必需
version = "1.0.0" # 模块版本信息,必需
output-type = "executable" # 编译输出产物类型,必需
[dependencies] # 源码依赖配置项,非必需
stdx = { git = "xxx", branch = "dev" } # 导入 git 依赖
# 或:doo = { path = "./pro1" } # 导入源码依赖
完成配置后,在项目根目录(cjpm.toml 所在目录)执行 cjpm build 构建项目、cjpm run 编译并运行可执行文件(cjpm run 会自动处理依赖检查、编译构建以及运行的全过程,参见运行第一个仓颉程序)。
3. 完整实战示例:WebSocket 握手与消息收发
如下示例展示了 WebSocket 的握手以及消息收发过程:创建 HTTP 客户端和服务端,分别发起 WebSocket 升级(或握手),握手成功后开始帧的读写。
import stdx.net.http.*
import stdx.encoding.url.*
import std.time.*
import std.sync.*
import std.collection.*
import stdx.log.*
import stdx.crypto.kit
let server = ServerBuilder().addr("127.0.0.1").port(0).build()
// client:
main() {
// 1 启动服务器
spawn {startServer()}
sleep(Duration.millisecond * 200)
let client = ClientBuilder().build()
let u = URL.parse("ws://127.0.0.1:${server.port}/webSocket")
let subProtocol = ArrayList<String>(["foo1", "bar1"])
let headers = HttpHeaders()
headers.add("test", "echo")
// 2 完成 WebSocket 握手,获取 WebSocket 实例
let websocket: WebSocket
let respHeaders: HttpHeaders
(websocket, respHeaders) = WebSocket.upgradeFromClient(client, u, subProtocols: subProtocol, headers: headers)
client.close()
println("subProtocol: ${websocket.subProtocol}") // fool1
println(respHeaders.getFirst("rsp") ?? "") // echo
// 3 消息收发
// 发送 hello
websocket.write(TextWebFrame, "hello".toArray())
// 收
let data = ArrayList<UInt8>()
var frame = websocket.read()
while (true) {
match (frame.frameType) {
case ContinuationWebFrame =>
data.add(all: frame.payload)
if (frame.fin) {
break
}
case TextWebFrame | BinaryWebFrame =>
if (!data.isEmpty()) {
throw Exception("invalid frame")
}
data.add(all: frame.payload)
if (frame.fin) {
break
}
case CloseWebFrame =>
websocket.write(CloseWebFrame, frame.payload)
break
case PingWebFrame => websocket.writePongFrame(frame.payload)
case _ => ()
}
frame = websocket.read()
}
println("data size: ${data.size}") // 4097
println("last item: ${String.fromUtf8(data.toArray()[4096])}") // a
// 4 关闭 websocket,
// 收发 CloseFrame
websocket.writeCloseFrame(status: 1000)
let websocketFrame = websocket.read()
println("close frame type: ${websocketFrame.frameType}") // CloseWebFrame
println("close frame payload: ${websocketFrame.payload}") // 3, 232
// 关闭底层连接
websocket.closeConn()
server.close()
}
func startServer() {
// 1 注册 handler
server.distributor.register("/webSocket", handler1)
server.logger.level = LogLevel.OFF
server.serve()
}
// server:
func handler1(ctx: HttpContext): Unit {
// 2 完成 websocket 握手,获取 websocket 实例
let websocketServer = WebSocket.upgradeFromServer(ctx, subProtocols: ArrayList<String>(["foo", "bar", "foo1"]),
userFunc: {
request: HttpRequest =>
let value = request.headers.getFirst("test") ?? ""
let headers = HttpHeaders()
headers.add("rsp", value)
headers
})
// 3 消息收发
// 收 hello
let data = ArrayList<UInt8>()
var frame = websocketServer.read()
while (true) {
match (frame.frameType) {
case ContinuationWebFrame =>
data.add(all: frame.payload)
if (frame.fin) {
break
}
case TextWebFrame | BinaryWebFrame =>
if (!data.isEmpty()) {
throw Exception("invalid frame")
}
data.add(all: frame.payload)
if (frame.fin) {
break
}
case CloseWebFrame =>
websocketServer.write(CloseWebFrame, frame.payload)
break
case PingWebFrame => websocketServer.writePongFrame(frame.payload)
case _ => ()
}
frame = websocketServer.read()
}
println("data: ${String.fromUtf8(data.toArray())}") // hello
// 发 4097 个 a
websocketServer.write(TextWebFrame, Array<UInt8>(4097, repeat: 97))
// 4 关闭 websocket,
// 收发 CloseFrame
let websocketFrame = websocketServer.read()
println("close frame type: ${websocketFrame.frameType}") // CloseWebFrame
println("close frame payload: ${websocketFrame.payload}") // 3, 232
websocketServer.write(CloseWebFrame, websocketFrame.payload)
// 关闭底层连接
websocketServer.closeConn()
}
该示例运行结果如下:
subProtocol: foo1
echo
data: hello
data size: 4097
last item: a
close frame type: CloseWebFrame
close frame payload: [3, 232]
close frame type: CloseWebFrame
close frame payload: [3, 232]
4. 实战拆解:四步走通 WebSocket 通信
4.1 第一步:搭建 HTTP 服务端并注册 WebSocket 路由
与服务端 HTTP 编程一致(参见 HTTP 编程 中 ServerBuilder 的用法),WebSocket 服务端同样基于 HTTP 服务端构建:
let server = ServerBuilder().addr("127.0.0.1").port(0).build()
func startServer() {
server.distributor.register("/webSocket", handler1)
server.logger.level = LogLevel.OFF
server.serve()
}
ServerBuilder().addr("127.0.0.1").port(0).build():构建监听127.0.0.1的服务端,port(0)表示由系统自动分配空闲端口,之后可通过server.port读取实际端口供客户端连接;server.distributor.register("/webSocket", handler1):注册路由,/webSocket路径上的连接请求由handler1处理,该路径即握手 URL 中ws://127.0.0.1:${server.port}/webSocket的路径部分;server.logger.level = LogLevel.OFF:关闭服务端日志,避免干扰输出;server.serve():阻塞式启动服务,接受并分发请求。
在主函数 main 中,通过 spawn {startServer()} 将服务端启动放入新线程(仓颉的并发模型可参考创建线程),随后 sleep(Duration.millisecond * 200) 等待服务端就绪,再启动客户端。
4.2 第二步:客户端发起握手(upgradeFromClient)
客户端侧通过 WebSocket.upgradeFromClient 完成握手:
let client = ClientBuilder().build()
let u = URL.parse("ws://127.0.0.1:${server.port}/webSocket")
let subProtocol = ArrayList<String>(["foo1", "bar1"])
let headers = HttpHeaders()
headers.add("test", "echo")
let websocket: WebSocket
let respHeaders: HttpHeaders
(websocket, respHeaders) = WebSocket.upgradeFromClient(client, u, subProtocols: subProtocol, headers: headers)
client.close()
参数说明:
client:由ClientBuilder().build()构建的 HTTP 客户端实例;u:目标 WebSocket 地址,使用stdx.encoding.url包中的URL.parse解析ws://协议的 URL;subProtocols:客户端期望协商的子协议列表。本例客户端声明["foo1", "bar1"],服务端声明["foo", "bar", "foo1"],最终协商结果取双方交集,websocket.subProtocol打印为foo1;headers:握手请求携带的自定义头,本例添加test: echo,服务端在userFunc中读取该头并回写为rsp响应头;- 返回值:元组
(websocket, respHeaders),前者是升级成功后用于通信的WebSocket实例,后者是握手响应的响应头(respHeaders.getFirst("rsp")打印为echo)。
握手成功后底层 HTTP 连接已交接给 WebSocket 实例,因此可立即 client.close() 关闭 HTTP 客户端对象。
4.3 第二步(服务端):upgradeFromServer 与 userFunc
服务端在路由 handler 内通过 WebSocket.upgradeFromServer 完成握手:
func handler1(ctx: HttpContext): Unit {
let websocketServer = WebSocket.upgradeFromServer(ctx, subProtocols: ArrayList<String>(["foo", "bar", "foo1"]),
userFunc: {
request: HttpRequest =>
let value = request.headers.getFirst("test") ?? ""
let headers = HttpHeaders()
headers.add("rsp", value)
headers
})
...
}
ctx:握手对应的HttpContext,由 HTTP 服务端分发传入;subProtocols:服务端支持的子协议列表;userFunc:类型为HttpRequest => HttpHeaders的闭包,用于在握手过程中自定义响应头。本例从握手请求头中读取test字段的值,放入响应头rsp返回给客户端,实现了「客户端传什么、服务端回显什么」的自定义握手交互。
握手完成后,websocketServer 即与客户端的 websocket 建立了一对一的通信通道,可以开始帧的读写。
4.4 第三步:帧读写与分段消息重组
客户端发送文本消息 hello:
websocket.write(TextWebFrame, "hello".toArray())
write(frameType, payload) 的第一个参数是帧类型(TextWebFrame / BinaryWebFrame 等),第二个参数是字节载荷。这里 "hello".toArray() 将字符串转换为字节数组。
服务端收到后用 read() 循环读取帧,并依据 frame.frameType 分派处理:
var frame = websocketServer.read()
while (true) {
match (frame.frameType) {
case ContinuationWebFrame =>
data.add(all: frame.payload)
if (frame.fin) { break }
case TextWebFrame | BinaryWebFrame =>
if (!data.isEmpty()) { throw Exception("invalid frame") }
data.add(all: frame.payload)
if (frame.fin) { break }
case CloseWebFrame =>
websocketServer.write(CloseWebFrame, frame.payload)
break
case PingWebFrame => websocketServer.writePongFrame(frame.payload)
case _ => ()
}
frame = websocketServer.read()
}
该读写循环覆盖了帧模型的所有处理分支:
TextWebFrame/BinaryWebFrame(首帧):若data已非空说明出现了乱序或非法帧(throw Exception("invalid frame")),否则将载荷累积进data;fin=true表示整条消息已收完,退出循环;ContinuationWebFrame(续帧):将载荷追加到data,fin=true时说明这是末帧,消息重组完成,退出循环;CloseWebFrame:收到关闭帧,按协议回写一个CloseWebFrame(载荷原样返回)并结束读取;PingWebFrame:收到心跳帧,调用writePongFrame(frame.payload)自动回 Pong 帧维持连接;_ => ():其他帧类型(如 Pong 帧)直接忽略。
随后服务端发送 4097 个 a 字符作为大消息,验证分段传输与重组:
websocketServer.write(TextWebFrame, Array<UInt8>(4097, repeat: 97))
Array<UInt8>(4097, repeat: 97) 生成 4097 个值为 97 的字节(97 是字符 a 的 ASCII 码)。由于单帧无法承载 4097 字节,库会将消息自动分片发送,客户端以「TextWebFrame(fin=false)+ 若干 ContinuationWebFrame + ContinuationWebFrame(fin=true)」的形式接收。客户端输出 data size: 4097 与 last item: a,证明分段消息被完整重组。这里使用的是字节缓冲 ArrayList<UInt8> 配合 data.add(all: frame.payload) 批量追加,最终用 String.fromUtf8(data.toArray()) 将字节序列还原为字符串(该字节到字符串的转换模式与仓颉 I/O 流处理一致,参见 I/O 流概述)。
4.5 第四步:关闭流程与底层连接释放
WebSocket 关闭采用「先发 Close 帧、再收对端 Close 帧回应、最后关闭底层连接」的规范流程。
客户端发起关闭:
websocket.writeCloseFrame(status: 1000)
let websocketFrame = websocket.read()
println("close frame type: ${websocketFrame.frameType}") // CloseWebFrame
println("close frame payload: ${websocketFrame.payload}") // 3, 232
websocket.closeConn()
writeCloseFrame(status: 1000):主动发送关闭帧,status为关闭状态码,1000表示正常关闭;- 随后
read()读取服务端回应的关闭帧,其payload为[3, 232],这正是1000的大端编码(0x03E8),验证了状态码随帧载荷回传; closeConn():关闭底层 TCP 连接,完成资源释放。
服务端侧同样先 read() 收到客户端关闭帧,然后回写关闭帧并 closeConn():
let websocketFrame = websocketServer.read()
websocketServer.write(CloseWebFrame, websocketFrame.payload)
websocketServer.closeConn()
主流程结束后调用 server.close() 关闭 HTTP 服务端。最终两端各打印一行 close frame type: CloseWebFrame 与 close frame payload: [3, 232],与运行结果完全对应。
5. 运行结果解读
将示例代码编译执行(cjpm build 后 cjpm run),输出与上文逐段注释一一对应:
subProtocol: foo1
echo
data: hello
data size: 4097
last item: a
close frame type: CloseWebFrame
close frame payload: [3, 232]
close frame type: CloseWebFrame
close frame payload: [3, 232]
subProtocol: foo1:子协议协商结果为客户端列表与服务端列表的交集foo1;echo:服务端userFunc将请求头test的值回写到响应头rsp;data: hello:服务端完整收到客户端发送的hello;data size: 4097/last item: a:客户端完整重组 4097 字节的分段消息;- 两行
close frame type: CloseWebFrame/close frame payload: [3, 232]:分别对应服务端与客户端的关闭帧收发记录。
6. 延伸阅读与注意事项
6.1 阻塞模型说明
仓颉的网络编程是阻塞式的,但被阻塞的是仓颉线程,阻塞中的仓颉线程会将系统线程让渡出去,因此并不会真正阻塞一个系统线程(见网络编程概述)。示例中 server.serve()、websocket.read() 均为阻塞调用,在 spawn 启动的仓颉线程中执行是安全且高效的,开发者可以放心使用阻塞式读写模型。
6.2 与 HTTP、Socket 编程的关系
WebSocket 建立在 HTTP 升级机制之上,因此其服务端构建、路由注册与 HTTP 编程共用 ServerBuilder / ClientBuilder / HttpContext 等类型;而底层传输仍基于 TCP,其连接生命周期管理与 Socket 编程中的可靠传输语义一致。三篇文档共同构成仓颉网络编程的完整脉络:
- 网络编程概述:网络编程的整体模型与协议支持情况;
- Socket 编程:TCP / UDP 传输层编程;
- HTTP 编程:HTTP 应用层请求-响应编程;
- 本文(WebSocket 编程):HTTP 升级到 WebSocket 后的帧读写实时通信。
6.3 实践要点汇总
- 依赖先行:
net、encoding、log库位于stdx模块,使用前必须在cjpm.toml的[dependencies]中配置对应软件包; - 握手即升级:
upgradeFromClient/upgradeFromServer一次调用即完成协议升级,成功后通过返回的WebSocket实例通信; - 帧类型分派:读写循环必须处理
Text/Binary/Continuation(应用数据)、Close/Ping/Pong(控制信息)全部分支,其中fin+frameType决定分段边界; - 主动回应:收到
PingWebFrame应调用writePongFrame回应以维持 Keep-Alive;收到CloseWebFrame应按协议回写关闭帧; - 关闭三步曲:
writeCloseFrame(status)→read()确认对端关闭帧 →closeConn()释放底层连接。