ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Go打造自托管WebSocket通知服务器:实时推送链路与部署实战

用Go打造自托管WebSocket通知服务器:实时推送链路与部署实战 简介这是一个面向自托管场景的轻量级消息推送与通知服务器项目基于 Go 语言实现负责通过 REST API 接收消息发送请求并通过 WebSocket 实时推送给前端随附的 Web UI 风格简洁还提供 Android 客户端支持。与多数已停止维护的开源推送方案不同它保留了用户、客户端和应用管理功能适合需要私有化部署通知服务、或想学习 Go 与 WebSocket 实时通信的开发者。压缩包共 226 个文件以 Go 后端源码和 TypeScript/TSX 前端源码为主辅以 PNG 图标、Markdown 文档、JSON/YAML 配置、多平台 Dockerfile 及单元测试等工程化文件整体体积仅 1.11MB。其中包含完整的项目源码、贡献指南和 MIT 许可证说明目录结构清晰便于直接阅读和二次开发。目前已有 221 人学习下载对搭建私有通知系统或研究前后端分离架构的人来说是一份小巧完整的参考实现。1. 一个 Go 写的 WebSocket 通知服务器缺的不是消息是链路我在服务器上加了整整两套监控一套盯负载一套盯证书过期。告警发到邮件被网关当垃圾邮件拦了发到 IM又嫌机器人太吵。折腾到最后我发现真正缺的是一个极轻的 WebSocket 服务器客户端上线保持一条常驻连接服务端有消息就实时推过来不需要任何轮询。这份资源干的正是这件事——一个 Go 写的轻量通知服务器自带一个设计现代的 Web UI。外部服务通过 REST 把消息送进来所有在线 WebSocket 客户端立刻收到人在外面打开 UI 也能翻历史消息、发测试推送。它收拢了通知链路里最核心的一段入站、存储、实时分发。适合两类人一类是手上有一台云服务器、想把告警和通知全收到自己域名下的开发者另一类是刚碰 Go 和 React、想抄一条完整实时链路的新手。2. WebSocket 链路设计从协议选型到心跳与重连机制先说结论这套服务真正值钱的部分不在界面而在那条从 REST 接口一路通到浏览器标签页的实时链路。下面把这条链路拆开讲。2.1 为什么推送要用 WebSocket而不是轮询或 SSE做实时通知第一选择不一定非是 WebSocket。但如果把「客户端在线状态」「服务端主动下发」「延迟小于一秒」这三件事同时摆上桌轮询和 SSE 都有明显短板。轮询是最容易想到的方案客户端每隔三秒问一次服务器有没有新消息。缺点是空转——服务器要处理大量没有新数据的请求客户端为了省电还得把请求频率调低频率一低延迟就上去了。长轮询好一点请求挂住直到有新消息才返回但每次推送完都要重建 HTTP 连接服务端还得维护一堆悬挂中的请求连接数一多Nginx 默认的 worker 连接数先撑不住。SSEServer-Sent Events是很多人的过渡方案浏览器原生支持连接只走单向能模拟实时推送。但它的短板在于单向客户端要给服务端回执、上报状态、发指令就得另开一条通道。通知系统往往不止是推还要收——客户端要确认收到、要上报「我已读」双向还是要 WebSocket。我的选择经验是纯看板类场景用 SSE 够用一旦消息需要双向确认直接上 WebSocket后续省一堆事。WebSocket 的选型理由也和数据格式有关。它直接在 TCP 上跑握手完成后就是一个全双工通道服务端可以随时把 JSON 帧推给客户端客户端也可以随时上行。浏览器原生支持不用引任何 SDK。配合 Nginx 的 upgrade 配置对外暴露和普通 HTTPS 站点一样没有额外端口。服务端是 Go 写的。Go 标准库不直接提供 WebSocket 实现这类资源通常走两个方向一是 gorilla/websocket老牌、资料多但仓库处于维护模式二是 nhooyr/websocket 后续演进的 coder/websocketAPI 全面拥抱 context读写都可以设置超时上下文写着更顺手。两份资源里如果代码风格偏现代多半是后者。我自己写新服务一般选 coder/websocket旧项目不动 gorilla。2.2 消息链路REST 入站、WebSocket 出站内部靠什么调度外部服务监控脚本、定时任务把消息推进来走的是 REST浏览器和手机客户端收消息走的是 WebSocket。两者在服务器内部怎么接上头几乎所有 Go 实现都指向同一个骨架hub。type Hub struct { // 在线客户端集合key 是 *Client 指针 clients map[*Client]bool // 待广播的消息帧带缓冲避免阻塞发送方 broadcast chan []byte // 新连接注册通道 register chan *Client // 连接断开注销通道 unregister chan *Client } func (h *Hub) run() { for { select { case c : -h.register: h.clients[c] true // 注册成功后这个客户端的 send channel 才被广播循环接管 case c : -h.unregister: if _, ok : h.clients[c]; ok { delete(h.clients, c) close(c.send) // 关闭写通道通知读循环退出 } case m : -h.broadcast: for c : range h.clients { select { case c.send - m: // 正常投递 default: // 客户端写缓冲已满说明它消费不过来直接踢掉 delete(h.clients, c) close(c.send) } } } } }这段代码是整个服务的心脏。逻辑说明所有对clients这个 map 的读写都发生在run()这一个 goroutine 里通过三个 channel 串行调度天然规避了 Go 里并发写 map 导致的 panic。REST handler 收到消息后只往broadcast塞不用关心当前有多少客户端在线、哪些客户端慢——这些都是 hub 的职责。参数上有两个细节值得抄。第一broadcast和每个Client的sendchannel 必须带缓冲我一般分别设 256 和 32如果客户端消费速度跟不上先让它积压 32 条再慢就直接断开避免一个慢客户端拖垮整条广播链路。第二写入连接时一定加SetWriteDeadlineTCP 层阻塞比想象中常见不加的话一个不读数据的客户端会让对应 goroutine 永久挂住。每个 WebSocket 连接进来后服务端通常开两个 goroutine一个读循环负责收客户端上行消息一个写循环负责把sendchannel 里的帧写进连接。如果这份资源要实现「每个 WebSocket 都支持实时发送和接收」读循环里解析到上行消息帧后把内容转成消息结构再投回broadcast即可——客户端也就有了通过同一连接发消息的能力。2.3 心跳机制与指数退避重连连接不掉线的三个参数WebSocket 连接挂得最多的不是网络故障而是没人管它。公网链路上运营商设备会掐掉长时间空闲的 TCP 连接服务器进程重启会清掉全部客户端手机切网一瞬间连接就废了。所以心跳机制不是可选项是自托管服务上线前必须配齐的。WebSocket 心跳机制实现的通行做法是 ping/pong服务器每隔固定间隔发送 ping 帧客户端按协议自动回 pong浏览器内置实现会主动回不用写代码服务器收到 pong 就重置读超时超过 pongWait 仍没收到任何数据就判定连接死亡主动清理。三个参数一起调别只改一个参数常见值作用pingPeriod30s服务器下发 ping 的间隔必须小于 pongWaitpongWait60s等到 pong 或任意数据的最长时限超时即断开writeWait10s写 ping 帧时允许的最大耗时防止写阻塞这三个值的约束关系是pongWait pingPeriod我一般留一倍余量。配合 Nginx 反代时proxy_read_timeout还要大于 pongWait否则 Nginx 先掐连接后面避坑章会细说。客户端侧还有一个同等重要的动作重连。最常见且稳妥的是指数退避加随机抖动let retry 1000; // 初始重连间隔 1 秒 const maxRetry 30000; // 最多退避到 30 秒 let ws null; function connect() { ws new WebSocket(wss://push.example.com/stream?tokenYOUR_TOKEN); ws.onopen () { retry 1000; }; // 连上就重置退避 ws.onclose () { setTimeout(connect, retry Math.random() * 500); retry Math.min(retry * 2, maxRetry); // 翻倍最高 30 秒 }; } connect();指数退避的意义在于错峰。设想 200 个客户端同时断线如果都用固定 5 秒重连第 5 秒那一下服务器要同时接受 200 次握手CPU 和文件描述符瞬间被打满。加随机抖动后重连请求分散在 5 秒到 5.5 秒之间压力小得多。这段代码里maxRetry是对服务器的一种保护——客户端最多 30 秒撞一次不至于在服务端维护窗口期里疯狂重试。这里有一个容易被忽略的细节如果客户端和服务器的系统时钟偏差很大不要用「当前时间减最后收到数据时间」判断连接存活直接用连接的读超时ReadDeadline更可靠读超时是内核级判断不依赖双方时钟一致。链路聊透了现在把它跑起来。3. 部署与 Web UI 实战三分钟跑起自托管推送并调通第一条消息3.1 配置文件与数据目录启动前先想清楚三项这种自托管通知服务器拿到手通常是一个编译好的二进制加一份示例配置启动入口固定把配置改对就能跑。核心配置项三块监听地址、数据库、初始管理员。以这类服务器最常见的配置格式为例server: port: 8080 listen: # 留空表示监听所有网卡等价于 0.0.0.0 ssl: enabled: false # 对外用 Nginx 终结 TLS服务端不开 ssl cors: alloworigins: [] # 允许的前端来源跨域场景才需要配 database: dialect: sqlite3 # 轻量场景默认 sqlite3数据量大了再换 mysql connection: data/push.db defaultuser: name: admin pass: admin # 首次启动初始化用登录后立刻改掉先说server.listen留空是最省事的选择监听所有网卡如果你填了127.0.0.1本机 curl 一切正常但手机在 4G 网络下永远连不上这是自托管服务最常见的翻车点。再看databasesqlite3 模式下connection是一个文件路径注意它指向的目录必须存在很多二进制不会自动帮你建目录启动报错先查这里。defaultuser只在首次启动时生效用来初始化管理员账号这组默认值上线前必须改掉不然任何人访问你的 UI 都能用 admin/admin 登录进去删库。容器方式部署的话数据目录一定要挂到宿主机或卷里。我见过不止一次有人把服务跑在容器里重启容器后历史消息全没了就是因为 sqlite 文件写在容器可写层容器一换就跟着蒸发。数据目录单独挂卷是所有自托管服务的第一条铁律。3.2 启动服务并用 curl 验证入站消息配置就绪后把二进制和配置文件放到同一目录前台启动做冒烟验证# 前台启动日志直接打到终端方便看报错 ./push-server --config config.yml # 用管理员账号登录拿管理 token curl -X POST http://127.0.0.1:8080/login \ -H Content-Type: application/json \ -d {name:admin,pass:admin} # 用应用 token 发一条测试消息应用 token 在 Web UI 里创建 curl -X POST http://127.0.0.1:8080/message?tokenAPP_TOKEN \ -F title磁盘告警 \ -F message/dev/sda1 使用率 91% \ -F priority5第一步启动命令里的--config参数指向刚才的 yaml前台跑的好处是启动报错一眼能看到比如数据库目录不存在、端口被占用日志里都会有明确提示。第二步拿管理 token这一步返回的 JSON 里带 token它用来调管理类接口比如创建应用、删除消息权限很大不能泄露。第三步发消息走的是应用级 token放在 URL 的token参数里-F表示 multipart 表单priority5是告警级别一般 5 表示高优先级提醒。收到 HTTP 200 只说明消息进了服务端存储并进入了广播队列不代表客户端收到了。验证 WebSocket 端最直接的办法是同时打开 Web UI 的消息时间线这条消息应该在两秒内滚出来。如果你更习惯命令行用 wscat 或 websocat 这类工具连一下流地址能看到 JSON 帧实时到达。「REST 返回成功但客户端没收到」是后面避坑章第一个要讲的经典问题。3.3 Web UI登录、建应用、拿推送凭证再挂到 Nginx 后面这套 Web UI 登录后的布局通常分三块应用管理、消息时间线、客户端下载。操作路径一条线捋下来登录 → 创建应用 → 拿到该应用专属 token → 把 token 写进监控脚本或客户端配置。token 是按应用隔离的不同监控项用不同应用消息在 UI 里按应用分组推送时也能区分来源别所有脚本共用一个 token。Web UI 本身就是 React 单页应用构建产物由后端静态托管所以部署上你只需要把流量指到后端端口前端路由的事情后端已经处理好了。对外暴露时放在 Nginx 后面这是自托管最标准的姿势。反代配置里最核心的是那三行 WebSocket 升级头map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 443 ssl; server_name push.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 75s; proxy_buffering off; } }map块的作用是如果客户端请求带了Upgrade: websocket头就把它映射成Connection: upgrade如果没带就映射成close保证普通 HTTP 请求不受影响。location里的三行是 WebSocket 反代的标准写法没有它们浏览器发起的握手请求到后端时会被当成普通 HTTP直接握手失败。proxy_read_timeout 75s必须大于服务端心跳里的 pongWait上一章说的 60s否则空闲连接先被 Nginx 掐断客户端陷入无限重连。proxy_buffering off是为了让消息帧不被 Nginx 攒着推送即时性更好。生产环境建议再用 systemd 托管服务进程写一个简单的 service 文件ExecStart指向二进制和配置加Restartalways进程挂了自动拉起来。到这里一个能从公网访问、能发消息、能收推送的自托管通知服务已经完整跑通了。4. 避坑与排查自托管 WebSocket 服务最容易翻车的五个位置链路通了不代表稳了。下面五条来自我自己和身边人真实踩过的坑从服务器运维的角度看每一条都值得上线前对着检查一遍。4.1 REST 返回 200客户端却永远收不到现象curl发消息返回成功Web UI 时间线里也能看到这条消息但某个客户端进程就是没反应。原因大多数情况下是 token 不对应。消息发给了应用 A客户端订阅的是应用 B 的流两边各自安好就是碰不上面。次要原因是客户端连接时用的 WebSocket 地址路径写错连上了一个不存在或不推送的端点。解决先打开 Web UI 的时间线能看到消息说明服务端链路没问题再核对发送 token 和客户端 token 是否属于同一个应用最后用 wscat 直接连流地址看有没有帧推下来。这三步能把「服务端问题」和「客户端问题」快速切开。血泪经验别在生产环境用 admin token 发消息测试管理 token 一旦写进脚本泄露就意味着别人能删光你全部历史消息。4.2 连接每隔 60 秒准时断一次陷入重连循环现象服务器日志和客户端日志里全是断开、重连而且间隔极其规律几乎精确到秒。原因十有八九是 Nginx 的proxy_read_timeout默认值 60 秒把空闲连接掐了。WebSocket 长时间没有消息传输时在 Nginx 眼里就是一段没有数据流动的闲置连接超时一到就断开。这条和心跳机制是配套的只配了服务端心跳没调 Nginx 超时等于白配。解决把反代配置里的proxy_read_timeout调到 75 秒以上必须大于服务端pongWait的 60 秒。约束关系记住一句话即可反代超时 pongWait pingPeriod。改完配置记得nginx -t检查语法再 reload。4.3 直连正常一上域名就握手失败或直接 403现象服务器本机 curl 一切正常浏览器通过域名访问 Web UI 也正常但 WebSocket 就是连不上控制台报 400/403 或者干脆连接被阻止。原因分两种情况。一种是 Nginx 没带Upgrade和Connection头反代把 WebSocket 握手当成普通 HTTP 请求后端自然不接受另一种是跨域问题Web UI 跑在https://push.example.com而代码里 WebSocket 地址写成了http://开头或指向别的域名浏览器按混合内容策略直接拦截。解决先核对上一章那三行反代头Upgrade和Connection缺一不可再检查前端代码里的连接地址页面是什么协议WebSocket 就该用什么协议https对应wss://http对应ws://不要混。跨域场景下要看服务端有没有开放对应来源的 CORS 配置但自托管单域名部署一般用不到前端代码里写同源 wss 地址是最省心的做法。4.4 本机能连外网永远连不上现象服务器上curl http://127.0.0.1:8080通手机切到 4G 访问域名就是超时。原因两个位置必查其一。要么服务端listen配的是127.0.0.1只监听回环地址外部流量根本到不了端口要么云服务器的安全组或本地防火墙没放行对应端口——很多云厂商的安全组默认只开了 22、80、4438080 这类自定义端口是被挡在门外的。解决listen留空或显式写0.0.0.0:8080同时检查安全组入站规则确认端口对公网开放。如果用了 Nginx 反代其实只需要放行 80/4438080 保持内网访问即可这样还少暴露一个攻击面。放行后从外部机器执行一次nc -vz 服务器IP 443验证连通性马上能定位是网络层问题还是服务层问题。4.5 重启服务历史消息全部蒸发现象升级版本或重启进程后Web UI 里消息时间线一片空白之前存的推送记录全没了。原因数据库文件路径指向了临时目录或错误的相对路径。最常见的是 sqlite 文件写在了容器可写层里容器一重建就丢其次是启动时的工作目录变了相对路径data/push.db解析到了另一个位置。这类项目默认 sqlite 持久化但如果文件路径没落稳重启即丢是必然结果。解决确认配置里database.connection是绝对路径或确保 systemd 的WorkingDirectory写对了容器部署务必把数据目录挂到宿主机卷。养成一个习惯升级前先对 sqlite 文件做一次sqlite3 push.db .backup backup.db几十兆的小库备份只要一瞬间出问题有后悔药。5. 进阶把推送从「发出去」做到「发得稳」5.1 按应用隔离消息别让告警和闲聊混在一起基础用法是所有人共用一个 token 收发消息推到 UI 里所有消息混成一条时间线。做得稍微讲究一点每个监控项创建一个独立应用消息带上不同的应用 ID 和优先级。客户端连接时带上自己的应用 token服务端只往对应应用的分支广播。这样磁盘告警、证书到期提醒、构建结果各归各的UI 里能按应用筛选客户端也可以只订阅自己关心的一路省流量也省眼睛。5.2 模拟 200 个并发连接心里才有底上线前我最常用一个压测脚本验证广播能力用 Python 的 websockets 库模拟 200 个客户端同时在线然后通过 REST 发一条广播统计到达率和延迟import asyncio, time, websockets, httpx URI ws://127.0.0.1:8080/stream?tokenYOUR_APP_TOKEN API http://127.0.0.1:8080/message?tokenYOUR_APP_TOKEN async def one_client(i, results): async with websockets.connect(URI) as ws: msg await asyncio.wait_for(ws.recv(), timeout10) results[i] time.time() async def main(): n 200 results {} tasks [asyncio.create_task(one_client(i, results)) for i in range(n)] await asyncio.sleep(2) # 等所有客户端完成握手 start time.time() async with httpx.AsyncClient() as client: await client.post(API, data{title: 压测, message: broadcast-test}) await asyncio.sleep(5) arrived sum(1 for v in results.values() if v) latency max((results[i] - start for i in results), default0) print(f到达 {arrived}/{n}最大延迟 {latency:.3f}s) asyncio.run(main())脚本逻辑每个协程建立一个 WebSocket 连接等待服务器广播的第一条消息并记录到达时间主协程等所有客户端就绪后通过 REST 发一条广播最后统计到达数和最慢的一条。wait_for(timeout10)是给消息到达设的硬上限防止某条连接挂死导致整个压测不退出。这个脚本对单机 200 连接以内完全够用实测下来这类 Go 服务在 200 连接并发推送时 CPU 占用几乎可以忽略真正的瓶颈通常在 Nginx 的并发连接上限而不是应用本身。那次上线前我改了 Nginx 端点却忘了带 Upgrade 头结果所有客户端连环断连重试服务端日志被打爆才逼我养成了现在的习惯每次改完推送链路都强制走一遍三道关——先 curl 发一条确认 REST 入站返回 200再打开 Web UI 时间线确认 WebSocket 端实时收到最后断开网络 30 秒观察客户端是否按指数退避自动重连。三道关全过才敢说链路是通的。这份资源本身不难难的是把每个环节的参数都对齐。希望这篇笔记能帮你在自托管 WebSocket 推送这条路上少踩几个坑把这台服务器真正用起来。本文还有配套的精品资源点击获取
返回列表