Wails v3 Streams 实战:在 Go 与前端之间建立无监听端口的双向字节通道
【免费下载链接】wailsCreate beautiful applications using Go项目地址: https://gitcode.com/gh_mirrors/wa/wails
Streams 是 Wails v3 提供的一种命名、有序、双向的字节通道,它让 Go 与前端之间保持与 WebSocket 完全一致的编程模型,却不需要绑定任何 TCP 端口。本指南以仓库中最精简的完整示例 v3/examples/streams 为核心,逐步拆解 Go 侧注册、前端连接、JSON 与原始字节两种模式,并结合源码深入讲解其底层传输与生命周期原理。读完本文,你将掌握app.HandleStream+ 前端Stream/JSONStream的完整用法,以及页面刷新、窗口关闭等场景下连接如何被正确回收。
Streams 是什么:WebSocket 的编程模型,零监听端口
官方文档 Streams 开篇给出的定义是:一个命名、有序、双向的字节通道,位于 Go 与你的前端之间,编程模型与 WebSocket 相同——但不绑定 TCP 端口。
为什么这一点重要?WebSocket 无法通过自定义 URL scheme 承载,因此在桌面 webview 中想要 WebSocket,只能真正启动一个 HTTP 服务器监听端口。这意味着:
- 桌面上会有一个任何本机进程都能访问的开放本地端口;
- 需要额外的 origin 校验与 token 保护才能保证安全;
- 该端口会暴露给用户机器上的防火墙和终端安全产品。
Streams 规避了全部这些问题:它直接复用应用已有的 asset server(该 server 本身已按 origin 绑定),Go→JS 方向走"长轮询(held poll)",JS→Go 方向走普通 POST,见 stream.go 的包注释。
在 v3/examples/streams 这个示例中,Go 每秒发送一次当前时间,并把前端发来的任何内容原样回显(打上echoed标记)——双端都使用 JSON 便利接口,是展示 Streams 全流程的最小可用范例。
运行示例
示例目录只有三个文件:README.md、main.go 与 assets/index.html。运行方式与其它 Wails 示例一致:
cd v3/examples/streams go run .启动后在输入框中键入文字并回车:Go 每秒推送的时间会出现在页面日志中,你发送的内容会以echo:前缀(实际为echoed: true字段)回显,Go 侧日志同时打印它收到的每条消息。页面底部的日志框由 index.html 中的log()辅助函数维护,新消息总是插入在列表顶部。
Go 侧:一个 handler 就是全部注册
main.go 中,整个流服务的注册只涉及一个调用:
app.HandleStream("hello", func(c *application.StreamConn) { defer c.Close() log.Println("[Go] frontend connected") // Go -> JS: send the time once a second until the connection goes away. go func() { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-ticker.C: err := c.SendJSON(map[string]any{"time": time.Now().Format("15:04:05")}) if err != nil { return // frontend is gone } case <-c.Context().Done(): return } } }() // JS -> Go: read frames until the connection closes, echoing each one. for { var msg map[string]any if err := c.ReceiveJSON(&msg); err != nil { log.Println("[Go] frontend disconnected") return } log.Printf("[Go] received: %v", msg) msg["echoed"] = true if err := c.SendJSON(msg); err != nil { return } } })这段代码体现了 Streams 三条核心模型约定:
- 每个连接一个 goroutine:
StreamHandler每次连接被调用一次、运行在自己的 goroutine 上(见 stream.go 的类型注释); - handler 的生命周期就是连接的生命周期:从 handler 返回即关闭连接。因此 handler 中通常有一个阻塞在
Receive(或c.Context())上的循环,只要它想保持连接打开; c.Context()随连接关闭而取消:单独启动的 ticker goroutine 通过select监听<-c.Context().Done()优雅退出,不会泄漏。Context()的实现见 stream.go。
StreamConn 的核心 API
StreamConn是"命名流上的一条连接",源码注释明确称它是*websocket.Conn的道德等价物:Send像 socket 写一样阻塞,Receive像 socket 读一样阻塞,对端消失后两者都返回错误(stream.go)。常用方法如下:
| 方法 | 语义 |
|---|---|
Send(data []byte) error | 入队一个帧交给前端;窗口出站缓冲满时阻塞,连接关闭后返回ErrStreamClosed(stream.go) |
TrySend(data []byte) error | Send的非阻塞版本,缓冲满时立即返回ErrStreamFull(stream.go) |
SendJSON(v any) error | 先json.Marshal再以单帧发送,是Send的便利封装(stream.go) |
Receive() ([]byte, error) | 返回来自前端的下一帧,阻塞直到帧到达或连接关闭(stream.go) |
ReceiveJSON(v any) error | 阻塞接收并json.Unmarshal到v;畸形帧只返回解析错误并消费该帧,不会关闭连接(stream.go) |
Close() error | 结束连接并通知前端(前端触发onclose),可安全多次调用、可跨 goroutine(stream.go) |
Name() string | 本连接所打开的流名称 |
Context() context.Context | 连接关闭时被取消,供 handler 派生的协程随连接一起收尾 |
Window() Window | 打开该连接的窗口对象;窗口已销毁时返回 nil(stream.go) |
发送端还有三个可供判错的哨兵错误值(stream.go):
ErrStreamClosed:连接已消失(页面跳转/刷新、窗口关闭、前端主动close()、应用退出);ErrStreamFull:TrySend时出站缓冲已满;ErrStreamTooLarge:单帧超过传输层的最大线缆尺寸,两个方向、桌面与 server 两种传输都适用同一上限。
注册语义
HandleStream把 handler 登记到流的注册表中(stream.go):重复注册同名流会替换handler,但已打开的连接仍使用它们开始时绑定的旧 handler。名称不能为空、不能超过streamMaxNameLen上限。
前端侧:一个实现了 WebSocket 接口的对象
assets/index.html 中的前端核心只有三行:
import { JSONStream } from "/wails/runtime.js"; const stream = JSONStream("hello"); // 同步返回,像 new WebSocket(url) stream.onmessage = (ev) => log(JSON.stringify(ev.data)); // ev.data 已是对象 stream.send({ text: "anything" }); // 自动 JSON.stringify关键点:
JSONStream(name)同步返回,readyState === CONNECTING,与new WebSocket(url)行为一致(stream.ts),因此可以在模块顶层直接创建连接,这也是生成绑定所推荐的形式;onopen/onmessage/onclose事件与 DOM 语义一致(on<type>赋值即注册真实事件监听器,见 stream.ts);JSONStream的send(value)用JSON.stringify序列化,onmessage里的ev.data是解析后的对象而非ArrayBuffer,与 Go 侧的SendJSON/ReceiveJSON配对(stream.ts);- 收到无法解析为 JSON 的帧时,会触发
error事件并丢弃该帧,而不会让轮询循环崩溃进而拖垮连接(stream.ts)。
页面还演示了典型的发送护栏:只有stream.readyState === stream.OPEN时才真正发送,并监听 Enter 键触发(index.html)。
想要原始字节?用 Stream 代替 JSONStream
JSONStream本质上是"在边界处替你完成编解码的Stream"——线缆上传输的始终是字节,Go handler 无法分辨前端用的是哪种。当你需要自己控制编码(protobuf、CBOR 或任意二进制格式)时,换用原始Stream与Send/Receive即可:
import { Stream } from "@wailsio/runtime"; const s = Stream("telemetry"); s.onopen = () => s.send(new TextEncoder().encode("hello")); s.onmessage = (ev) => console.log(new Uint8Array(ev.data)); s.onclose = (ev) => console.log("closed", ev.code);使用原始Stream时有一个必须知道的坑:此时ev.data是ArrayBuffer,绝不是字符串。如果直接JSON.parse(ev.data),你会把字符串"[object ArrayBuffer]"交给解析器并失败。需要先解码:
const decoder = new TextDecoder(); stream.onmessage = (ev) => JSON.parse(decoder.decode(ev.data));发送字符串则无需任何改动——Wails 会替你按 UTF-8 编码(README 明确说明"发送字符串不需要变化,它会被编码为 UTF-8")。这一点在 runtime 源码中也有印证:JSONStream内部正是用TextDecoder解码ArrayBuffer | string载荷(stream.ts)。
生命周期验证:刷新页面、关闭窗口
示例 README 给出了两个值得亲自动手的验证动作:
- 刷新页面(Reload):Go 日志先打印 disconnect(
frontend disconnected)再打印一次新的 connect。刷新页面会像关闭 socket 一样关闭旧连接,新页面会获得一条全新连接——这得益于"连接的打开与页面代际(generation)绑定"的设计,streamManager通过retiredThrough水位线跟踪每窗口已退役的页面代际,避免刷新导致 manager 内存随应用生命周期无限增长(stream.go); - 关闭窗口:handler 返回。示例在 main.go 中设置了
Mac.ApplicationShouldTerminateAfterLastWindowClosed: true,窗口关闭后应用随之退出,全部连接被回收。
深入底层:两条传输、一套 API
从源码结构看,Streams 最精巧的一点是:桌面构建与 server 构建共用完全相同的 handler 代码,只是"水槽(sink)"不同。
- 桌面构建(stream_prelude_desktop.go):没有真实监听端口,Go→JS 方向通过"held poll"长轮询交付(空轮询最多挂起
streamHoldTimeout = 20s后返回 204),JS→Go 方向通过普通 POST 上行(stream.go); - server 构建(stream_server.go):server 模式本来就有监听器,流直接升级为真实 WebSocket(
/wails/stream/ws,见 application_server.go)。但 handler、StreamConn与上层应用代码完全一致——这正是"镜像 WebSocket 模型而非另造 API"的回报。前端 stream.ts 也做了对称处理:server 构建会安装一个返回真实WebSocket的工厂,两个对象暴露同一接口,因此业务代码不需要区分传输。
有界缓冲与背压
Streams 的可靠性建立在一组经过实测校准的有界缓冲之上(stream.go),值得关注的关键常数:
| 常数 | 值 | 作用 |
|---|---|---|
streamOutQueueBytes | 8 MiB | 单窗口出站(等待下一次轮询)的字节上限,真正兜底宿主内存的闸门 |
streamOutQueueDepth | 256 | 单窗口出站帧数上限;轮询是批量排空的,256 足够覆盖一个生产往返的突发 |
streamMaxResponseBytes | 1 MiB | 单次轮询响应上限(Windows 响应体在内存中整体累积,必须封顶);截断的响应会设置 "more" 标志,客户端立即重新轮询 |
streamSessionTTL | 60s(3×streamHoldTimeout) | 会话无轮询即视为消亡并关闭其连接 |
streamMaxConnections | 256/页 | 连接准入上限,防止页面停止轮询时协议状态与 handler goroutine 无限增长 |
streamMaxFrameBytes | 由streamMaxResponseBytes等约束 | 单帧线缆上限,双向、双传输一致 |
对上行(前端→Go)背压的处理尤其值得注意:桌面端点在上行队列满时用429 响应告知客户端稍后重试同一帧,而不是阻塞等待——因为"挂起的请求"正是这种传输的稀缺资源,若阻塞等待,足够的停滞发送会饿死窗口唯一的轮询,直到会话 TTL 过期被整体回收(实测会损失 25–32% 的上传吞吐并直接破坏多连接上传)。下行方向,Send像 socket 写一样阻塞;TrySend则立即返回ErrStreamFull,扇出与 broker-回调模式正是指望用它避免阻塞生产者(stream_server.go)。
设计约束中的几条硬规则
stream.go 的包注释记录了从事件系统教训中沉淀出的三条硬规则:
- Go→JS 路径上没有任何代码触碰主线程:
Send在互斥锁下追加后立即返回,由单一 drainer 保持帧序,无论帧来自哪个 goroutine; - 该路径任何尺寸都不走
evaluateJavaScript:把载荷拼进 eval 源码会在特定平台阈值之上显著滞留宿主内存(注释记录 macOS 11.6 GB、WebKitGTK 6.2 GB @ 100 事件/秒 × 1 MB),所以这里完全不使用它; - 每个缓冲都有界,且窗口消失时全部丢弃。
从 WebSocket 迁移
如果现有应用是"起一个本地 HTTP 服务器向前端推数据",官方提供了 Migrating a WebSocket to Streams 指南,该文档以"可机械套用"为目标,并首先列出三个会静默出错的差异点。Streams 的 Go 侧 handler 形态与 gorilla/coder 的 WebSocket handler 几乎一一对应(README 也注明"连接生命周期 = handler goroutine 生命周期,这一选择让刷新、关闭与清理自然成立,而无需各自制定策略"),迁移成本主要集中在前端 API 与ArrayBuffer解码习惯上。
小结
本示例以不到 80 行的 Go 与一个单页 HTML,完整演示了 Wails v3 Streams 的全部核心能力:HandleStream注册、每个连接一个 handler goroutine 的生命周期模型、c.Context()驱动的优雅退出、SendJSON/ReceiveJSON与前端JSONStream的对象级通信,以及切换原始Stream时ArrayBuffer的解码陷阱。结合 stream.go 与 stream.ts 的源码,你可以看到这套"WebSocket 编程模型 + 无监听端口"的方案在传输、缓冲与生命周期三个维度上的完整设计——它不依赖外部端口,却提供了与 socket 一致的双向有序通道,是 Wails v3 在 Go 与前端之间推送数据时的推荐底座。
【免费下载链接】wailsCreate beautiful applications using Go项目地址: https://gitcode.com/gh_mirrors/wa/wails
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考