仓颉语言 WebSocket 编程实战:从 HTTP 升级握手到帧读写

原创2026-10-13 19:32:211,386 阅读
文章标签:文档教程

仓颉语言 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 实践要点汇总

  1. 依赖先行:net、encoding、log 库位于 stdx 模块,使用前必须在 cjpm.toml 的 [dependencies] 中配置对应软件包;
  2. 握手即升级:upgradeFromClient / upgradeFromServer 一次调用即完成协议升级,成功后通过返回的 WebSocket 实例通信;
  3. 帧类型分派:读写循环必须处理 Text/Binary/Continuation(应用数据)、Close/Ping/Pong(控制信息)全部分支,其中 fin + frameType 决定分段边界;
  4. 主动回应:收到 PingWebFrame 应调用 writePongFrame 回应以维持 Keep-Alive;收到 CloseWebFrame 应按协议回写关闭帧;
  5. 关闭三步曲:writeCloseFrame(status) → read() 确认对端关闭帧 → closeConn() 释放底层连接。
登录后查看全文
cangjie_docs