- 后端
【免费下载链接】websocket
Minimal and idiomatic WebSocket library for Go
导读
internal/examples目录承载着 github.com/coder/websocket 中不适合用 godoc 展示的完整可运行示例——它们不是几十行的 API 片段,而是包含前端、后端、测试与优雅退出逻辑的端到端程序。本文以该目录的 README 为骨架,完整展开其中两个示例的架构、源码实现与运行方式:echo(协议级的回显服务器,含子协议协商与限流)与chat(前后端全栈的广播聊天室,含订阅/发布分离设计与并发测试)。读完本文,你将掌握websocket.Accept、websocket.Dial、Conn.CloseRead、Conn.CloseNow等核心 API 在生产级场景中的真实用法,以及如何以 curl 直接向 WebSocket 服务发消息、如何用 Go 编写高并发客户端测试来验证服务端正确性。
一、目录定位:为什么这些示例要单独存放
根目录 README.md 明确把这两个示例列为官方推荐入口:
- echo 示例 被描述为"生产质量、演示完整 API"的示例;
- chat 示例 被描述为"全栈"示例。
而 internal/examples/README.md 给出了目录存在的根本原因:
This directory contains more involved examples unsuitable for display with godoc.
即:godoc 适合展示短小的、可独立成段的代码片段,而这两个示例涉及多个文件、前后端资产与并发测试,无法在 godoc 的单一页面中优雅呈现,因此独立成目录。目录采用独立的 Go module(module github.com/coder/websocket/examples,Go 1.23),并通过replace github.com/coder/websocket => ../..指向仓库根目录的库本体,依赖仅有golang.org/x/time v0.7.0(用于限流器)——见 internal/examples/go.mod。这意味着你可以直接cd internal/examples/chat(或echo)后go run .运行,无需额外配置。
两个示例共享同一种启动模式(见 chat/main.go 与 echo/main.go):从命令行第一参数读取监听地址、用net.Listen建立 TCP 监听、以http.Server承载、通过signal.Notify捕获os.Interrupt并在 10 秒超时内执行优雅关闭。差别只在于注册的 Handler 不同。
二、echo 示例:协议级回显服务
2.1 运行方式
$ cd internal/examples/echo $ go run . localhost:0 listening on ws://127.0.0.1:51055启动后程序打印ws://前缀的实际监听地址(使用localhost:0时端口由系统分配)。随后可用任意 WebSocket 客户端连接(如 hashrocket/ws),所有写入的消息都会被原样回显。同样也可以用标准库客户端验证:根目录 README.md 展示的websocket.Dial客户端写法与之配合即可。
2.2 服务端实现:Accept + 子协议协商 + 限流
服务端核心在 server.go,整体是一个实现了http.HandlerFunc语义的echoServer结构体:
func (s echoServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ Subprotocols: []string{"echo"}, }) if err != nil { s.logf("%v", err) return } defer c.CloseNow() if c.Subprotocol() != "echo" { c.Close(websocket.StatusPolicyViolation, "client must speak the echo subprotocol") return } l := rate.NewLimiter(rate.Every(time.Millisecond*100), 10) for { err = echo(c, l) if websocket.CloseStatus(err) == websocket.StatusNormalClosure { return } if err != nil { s.logf("failed to echo with %v: %v", r.RemoteAddr, err) return } } }关键点逐一拆解:
- 子协议强制:
AcceptOptions.Subprotocols: []string{"echo"}让服务端只协商echo子协议。握手成功后检查c.Subprotocol(),若客户端未携带该子协议则用StatusPolicyViolation(1008)关闭连接。这一模式对应库源码中 accept.go 的子协议选择逻辑——服务端从客户端Sec-WebSocket-Protocol头中挑选第一个匹配项写回响应头。 - 即时关闭:
defer c.CloseNow()不做关闭握手直接切断底层连接,适合服务端"用完即弃"的场景,见 close.go 对CloseNow的定义(与执行完整关闭握手的Close相区分)。 - 限流保护:
rate.NewLimiter(rate.Every(time.Millisecond*100), 10)表示每 100ms 放行一个、突发容量 10,来自golang.org/x/time/rate,这也是该 module 唯一的外部依赖。 - 正常关闭识别:循环中用
websocket.CloseStatus(err) == websocket.StatusNormalClosure判断客户端是否发起了正常的 1000 关闭,正常关闭则不记错误日志直接返回——这正是 close.go 中CloseStatus辅助函数(errors.As+CloseError的便捷包装)的典型用法。
2.3 echo 循环:Reader / Writer 流式回显
单条消息的处理封装在echo函数中:
func echo(c *websocket.Conn, l *rate.Limiter) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() err := l.Wait(ctx) if err != nil { return err } typ, r, err := c.Reader(ctx) if err != nil { return err } w, err := c.Writer(ctx, typ) if err != nil { return err } _, err = io.Copy(w, r) if err != nil { return fmt.Errorf("failed to io.Copy: %w", err) } err = w.Close() return err }- 每次消息处理分配 10 秒超时(
context.WithTimeout),超时或客户端断开都会中止当前循环。 c.Reader(ctx)返回消息类型与io.Reader(见 read.go),c.Writer(ctx, typ)返回io.WriteCloser(见 write.go)。- 回显的核心是一行
io.Copy(w, r):把读到的消息原样写入写出流,随后w.Close()提交消息。由于回显时沿用读到的消息类型typ,二进制/文本帧类型都能正确保持。
2.4 测试:httptest + wsjson 往返验证
server_test.go 用httptest.NewServer起服务,然后:
c, _, err := websocket.Dial(ctx, s.URL, &websocket.DialOptions{ Subprotocols: []string{"echo"}, })注意测试端同样声明Subprotocols: []string{"echo"},与服务端对齐。随后循环 5 次,用wsjson.Write写入map[string]int{"i": i}、用wsjson.Read读回并断言v["i"] == i——这同时验证了wsjson子包的 JSON 序列化在回显链路上的正确性。测试在t.Parallel()下运行,且以t.Logf作为logf注入,使服务端日志汇入测试输出。
三、chat 示例:全栈广播聊天室
chat 是一个"提交消息 → 广播给所有订阅者"的完整 Web 应用:前端由 index.html、index.js、index.css 组成,后端为 main.go 与 chat.go,测试在 chat_test.go。
3.1 运行方式
$ cd internal/examples/chat $ go run . localhost:0 listening on ws://127.0.0.1:51055浏览器访问打印出的 URL 即可提交与查看广播消息。因为前端资产通过http.FileServer(http.Dir("."))从当前工作目录提供,务必在chat目录内运行go run .。
3.2 订阅 / 发布分离的设计动机
chat 示例最值得借鉴的架构决策是:消息不在 WebSocket 上发布,而是通过 HTTP POST/publish发布。README 明确解释了原因:方便直接用 curl 发消息:
$ curl -X POST -d 'hello world' http://127.0.0.1:51055/publish这样任何语言、任何工具都能向聊天室注入消息,WebSocket 连接只承担单向的广播接收职责(/subscribe端点)。这一"单向连接 + 独立发布通道"的模式在生产中也很常见:客户端连接只收不写,写操作走更易鉴权、限流和审计的 HTTP 接口。
3.3 路由与整体结构
chat.go 中newChatServer用http.ServeMux注册三个路由:
cs.serveMux.Handle("/", http.FileServer(http.Dir("."))) cs.serveMux.HandleFunc("/subscribe", cs.subscribeHandler) cs.serveMux.HandleFunc("/publish", cs.publishHandler)GET /:静态文件服务(index.html / index.js / index.css)。GET /subscribe:WebSocket 升级端点,把连接注册为订阅者并持续推送广播消息。POST /publish:读取请求体(上限 8192 字节,通过http.MaxBytesReader限制),广播给所有订阅者,返回202 Accepted。非 POST 请求直接返回405 Method Not Allowed。
chatServer内部状态包括:subscribers(map[*subscriber]struct{},配sync.Mutex保护)、subscriberMessageBuffer(默认 16,控制每个订阅者待发送缓冲上限,见 chat.go)、publishLimiter(默认每 100ms 一次、突发 8 的rate.Limiter)、可注入的logf。
3.4 subscribeHandler:优雅的错误分类
func (cs *chatServer) subscribeHandler(w http.ResponseWriter, r *http.Request) { err := cs.subscribe(w, r) if errors.Is(err, context.Canceled) { return } if websocket.CloseStatus(err) == websocket.StatusNormalClosure || websocket.CloseStatus(err) == websocket.StatusGoingAway { return } if err != nil { cs.logf("%v", err) return } }它把"预期内的退出"与"真正的错误"区分开:context.Canceled(客户端断开导致CloseRead上下文取消)、StatusNormalClosure(1000)与StatusGoingAway(1001)都属于正常结束,静默返回;其余错误才记日志。
3.5 subscribe:CloseRead 的妙用
subscribe是 chat 示例的技术精华,展示了"只写连接"的标准处理范式(见 chat.go):
s := &subscriber{ msgs: make(chan []byte, cs.subscriberMessageBuffer), closeSlow: func() { ... c.Close(websocket.StatusPolicyViolation, "...") }, } cs.addSubscriber(s) defer cs.deleteSubscriber(s) c2, err := websocket.Accept(w, r, nil) ... defer c.CloseNow() ctx := c.CloseRead(context.Background()) for { select { case msg := <-s.msgs: err := writeTimeout(ctx, time.Second*5, c, msg) ... case <-ctx.Done(): return ctx.Err() } }- CloseRead 双通道读取:
c.CloseRead(context.Background())启动一个后台 goroutine 持续读取连接(见 read.go),目的是处理 ping/pong/close 等控制帧并响应它们;一旦对端关闭,后台读返回错误并取消返回的 context。这样服务端主循环可以完全不读业务消息,只在一个select里等待"有新消息要写"或"连接已断开"。 - 慢消费者踢出:每个订阅者有一个缓冲通道
msgs(容量 16)。publish向通道投递时用select+default非阻塞发送,若通道已满(订阅者消费速度跟不上广播速度),则触发closeSlow用StatusPolicyViolation(1008)关闭该连接,防止内存无限堆积。这正是注释所强调的"never blocks and so messages to slow subscribers are dropped"。 - 写超时:
writeTimeout为每次写操作套上 5 秒超时上下文,避免对端 TCP 接收窗口堵死时写操作无限阻塞。
3.6 publish:非阻塞广播 + 全局限流
func (cs *chatServer) publish(msg []byte) { cs.subscribersMu.Lock() defer cs.subscribersMu.Unlock() cs.publishLimiter.Wait(context.Background()) for s := range cs.subscribers { select { case s.msgs <- msg: default: go s.closeSlow() } } }发布端持有subscribersMu遍历订阅者集合,向每个订阅者的缓冲通道投递同一份消息字节切片(各订阅者并发写自己的通道,切片只读共享,安全)。publishLimiter.Wait则把发布频率全局限制为默认每 100ms 一条、突发 8 条,起到防刷作用。
3.7 前端:原生 WebSocket + 自动重连
前端不依赖任何框架。index.js 的核心逻辑:
- 用
new WebSocket(ws://${location.host}/subscribe)建立连接; close事件中,若关闭码不是1001(GoingAway),1 秒后自动setTimeout(dial, 1000)重连——这是对服务端"慢消费者踢出"策略的客户端侧补偿;message事件把收到的文本追加到滚动消息区,若用户刚提交过消息(expectingMessage标志)则scrollIntoView()滚动到新消息;- 表单提交走
fetch('/publish', { method: 'POST', body: msg }),校验返回状态必须为202; - 每条消息带时间戳(
toLocaleTimeString)便于阅读,错误以红色样式呈现。
index.css 用 flex 布局把页面固定为"上为可滚动消息区、下为固定表单",并对 Safari 的100vh高度问题做了@supports (-webkit-overflow-scrolling: touch)降级。
3.8 测试:单客户端冒烟 + 高并发全序验证
chat_test.go 包含两组测试,均通过httptest.NewServer起真实服务:
- simple(简单回环):一个客户端连上
/subscribe,publish一条 512 字节随机消息,随后在 WebSocket 上读到该消息并断言完全一致。客户端封装client结构提供publish(HTTP POST)与nextMessage(c.Read并断言消息类型为websocket.MessageText)两个方法。 - concurrency(并发全序验证):README 描述为"10 个客户端并发发送 128 条、每条不超过 128 字节的唯一消息",源码中的实际参数为
nclients = 16、nmessages = 128、maxMessageSize = 128(见 chat_test.go)。每个客户端 goroutine 并发publishMsgs自己的 128 条消息,同时另一组 goroutine 用testAllMessagesReceived读取nclients*nmessages条消息,逐条从"期望全集"中删除,最后断言集合为空,从而验证"每条消息都被每个客户端收到、且无错乱无丢失"。测试辅助函数setupTest把subscriberMessageBuffer提高到 4096、publishLimiter设为rate.Inf,以保证在-race检测下也能快速稳定通过;随机消息由randString/randInt生成(转为合法 UTF-8、去除 NUL 并以=补齐长度),避免非法 UTF-8 文本帧破坏断言。
3.9 优雅退出
两个示例的main.go都实现了生产级的退出流程:http.Server在独立 goroutine 中Serve,主 goroutine 在errc(服务出错)与sigs(os.Interrupt信号)间select,随后调用s.Shutdown并给 10 秒超时完成存量请求收尾(见 chat/main.go)。
四、两个示例的对比与学习路径
| 维度 | echo | chat |
|---|---|---|
| 定位 | 协议级、API 全覆盖的最小完整服务 | 全栈(前后端 + 并发测试)的广播应用 |
| 核心 API | Accept、Subprotocols、Reader/Writer、CloseStatus | Accept、CloseRead、CloseNow、缓冲通道广播、限流 |
| 消息方向 | 双向(客户端写入即回显) | 单向(服务端只推送,客户端只接收) |
| 客户端交互 | 任意 ws 客户端 | 浏览器 + curl POST/publish |
| 测试重点 | JSON 往返 5 轮 | 单客户端冒烟 + 16 客户端 × 128 消息全序并发验证 |
| 外部依赖 | golang.org/x/time(限流) | golang.org/x/time(限流) |
官方 README 推荐的阅读顺序为:先读 main.go(启动与退出骨架),再读 chat.go(订阅/发布核心),然后看 index.html 与 index.js(前端如何与后端协作)。echo 更短小,适合作为理解Accept/Dial握手与Reader/Writer流式读写的第一站。
若想在自己项目中复刻这些模式,可直接对照:把 echo 的"子协议强制 + 限流 +CloseStatus判错"用于服务端协议约束,把 chat 的"单向连接 + HTTP 发布通道 +CloseRead处理控制帧 + 慢消费者踢出 + 优雅退出"用于实时广播类应用。二者的测试写法(httptest+t.Logf注入日志 + 并发全序断言)也可直接迁移到业务项目中作为 WebSocket 服务的验收基线。
- 后端
【免费下载链接】websocket
Minimal and idiomatic WebSocket library for Go
相关推荐
RxDB 在 Node.js 中的实战入门:运行并剖析官方 heroes-list 示例
RxDB 在 Node.js 中的实战入门:运行并剖析官方 heroes list 示例 导读 本指南围绕仓库中的 examples/node https://
数据库NoSQL嵌入式数据库实时数据库LiteFlow示例项目:完整可运行的示例代码
LiteFlow示例项目:完整可运行的示例代码 概述 LiteFlow是一款轻量级且功能强大的国产规则引擎框架,专为复杂的组件化业务编排而设计。通过独特的DSL
后端流程编排工作流自动化人工智能AI AgentHarbor 运行时技能注入实战:以 skills-merge 示例剖析 SKILL.md 从定义到验证的完整链路
Harbor 运行时技能注入实战:以 skills merge 示例剖析 SKILL.md 从定义到验证的完整链路 导读 本篇文章围绕 Harbor 仓库中的
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考