简介:一个基于Go实现的可自托管消息推送服务端代码包,适合需要搭建WebSocket实时通知系统或研究前后端分离架构的开发者。项目通过REST API发送消息、WebSocket接收消息,内置用户、客户端与应用管理模块,并配有界面现代的Web UI与Android客户端支持,整体采用MIT许可证。压缩包共226个文件,以Go后端源码、TypeScript/TSX前端组件、PNG图标及JSON/YAML配置为主,同时附带Dockerfile和arm64、armv7平台部署文件,便于跨平台容器化运行。包体仅1.11MB,结构紧凑,已有221人学习浏览。借助源码可深入掌握实时通信API设计、多端消息推送机制、前后端协作方式,也能参考服务端测试用例与容器化构建流程;对于希望自建通知服务或研究开源项目架构的开发者,这是一份值得参考的完整代码样本。
1. 自建一个轻量级 WebSocket 消息服务器:让实时推送不再是黑匣子
很多人在自托管通知服务时踩过同一个坑:明明服务器日志里显示消息已经发出,浏览器这边却什么都没有。排查一圈发现是 WebSocket 握手没走对,或者消息在连接建立之前就已经发送了。这个项目解决的就是这类实时通信的基本问题——一个简单的 WebSocket 服务器,负责实时接收和推送消息,同时附带一个视觉风格偏现代的 Web UI,方便你直接在浏览器里看消息流。适合那些正在做内部系统告警、运维通知或者 IoT 数据看板,又不想引入 Kafka、EMQ X 这一套重型中间件的人。
2. 实时通信的选型与快速上手:为什么必须用 WebSocket
2.1 轮询、SSE 与 WebSocket:实时需求下怎么选
实时推送的经典方案有三种:HTTP 轮询(Polling)、Server-Sent Events(SSE)和 WebSocket。轮询最直观,前端按固定间隔向服务端发送请求,代码好写,但存在两个明显问题:一是消息的平均延迟取决于轮询间隔,间隔设短了,服务端压力直线上升;二是大量请求头和数据包的浪费,很多请求其实什么都没拿到。SSE 是服务端单向推送,通过 HTTP 长连接把数据推给浏览器,实现简单,但它只支持服务端到客户端的单向通信,而且部分网络环境下长连接的稳定性并不好。
WebSocket 和这两者的本质区别在于,它通过一次 HTTP 升级握手建立一条真正的双向长连接,服务端和客户端可以随时向对方发送数据帧,不再需要不断发起 HTTP 请求。这个服务器项目选择 WebSocket 作为传输层的核心,正是看中了这两点:延迟可以做到毫秒级,而且没有轮询那种请求头反复传递的开销。实际效果上,你打开 Web UI 看到消息基本是瞬时出现的,几乎没有肉眼可感知的延迟。
2.2 快速跑通服务端:Go 语言下的最小可运行方案
项目服务端用 Go 标准库net/http加上一个轻量级 WebSocket 库实现。如果你已经配好了 Go 环境,跑起来只需要以下几步。
# 拉取依赖并启动服务 go mod init ws-demo go get github.com/gorilla/websocket go run server.go参数说明:server.go是服务端入口文件,里面注册了 HTTP 路由和 WebSocket 升级逻辑。gorilla/websocket是目前 Go 生态里最常用的 WebSocket 库,它把底层帧解析、握手协议都封装好了,你只需要关注业务层的消息处理。如果是在本机调试,默认监听地址为:8080,浏览器直接访问http://localhost:8080就能打开 Web UI。
接着,打开浏览器控制台,粘贴一段最简单的客户端代码,验证连接是否打通。
// 浏览器控制台直连测试 const ws = new WebSocket('ws://localhost:8080/ws'); ws.onopen = () => console.log('连接已建立'); ws.onmessage = (e) => console.log('服务端消息:', e.data);说明一下:这里的ws://协议由浏览器自动处理,不需要额外引入任何库。连接建立后,onmessage里就能收到服务端推送的消息。用控制台做冒烟测试,是排查 WebSocket 问题的最快方式,可以绕开 UI 层的干扰,直接确认链路是否通。
2.3 接口约定与服务端消息格式
WebSocket 的消息体是二进制还是文本,直接决定了前后端怎么解析。这个项目的服务端默认按照文本消息处理,推荐的 JSON 格式如下:
{ "type": "message", "id": 123, "content": "这是一条测试消息", "timestamp": 1700000000 }字段含义:type用于区分消息类型,比如普通聊天还是系统通知;id是递增的唯一编号,前端可以用它来去重;timestamp是 Unix 时间戳,用于在 UI 里显示相对时间。这个约定不是协议标准,但建议从一开始就统一,否则后面加消息确认和补发功能时会非常痛苦。
3. 核心实现拆解:连接管理、广播与心跳机制的落地
3.1 路由注册与消息广播:一个连接池的自我修养
服务端要做的事情其实可以拆成三块:客户端连接接入后,分配一个唯一 ID 并保存到连接池;收到客户端消息后,决定是否广播给所有在线连接;客户端断开时,从连接池中移除并做清理。这里最需要注意的是并发访问的互斥问题。
type Hub struct { clients map[*websocket.Conn]bool register chan *websocket.Conn unregister chan *websocket.Conn mu sync.Mutex } func (h *Hub) run() { for { select { case conn := <-h.register: h.mu.Lock() h.clients[conn] = true h.mu.Unlock() case conn := <-h.unregister: h.mu.Lock() if _, ok := h.clients[conn]; ok { delete(h.clients, conn) conn.Close() } h.mu.Unlock() } } } func (h *Hub) broadcast(message []byte) { h.mu.Lock() defer h.mu.Unlock() for conn := range h.clients { conn.WriteMessage(websocket.TextMessage, message) } }这个 Hub 结构依赖register、unregister两个 channel 来做连接生命周期管理,广播时用mu互斥锁保护连接池的读写。为什么要用 channel 而不是直接加锁?因为 WebSocket 的握手、断开事件是从不同 goroutine 触发的,channel 可以天然地做并发串行化,避免写共享 map 时产生竞态。我一般会把广播的写超时时间设定为 5 秒,防止某个慢客户端拖累整个广播循环。
3.2 心跳与断线重连:你的连接还在吗
WebSocket 本身没有内置心跳探测,如果客户端直接断网(比如手机切了 WiFi),服务端要等 TCP 超时才能察觉,这个时间通常是 2 分钟到 4 分钟不等。在这期间,连接池里还保存着一个已经失效的连接,广播时写操作会卡在那里。解决方式是服务端每间隔 30 秒发送一个 Ping 控制帧,客户端收到后必须回 Pong,服务端在pongWait时间内收到回复才认为连接是健康的。
conn.SetPongHandler(func(appData string) error { conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) ticker := time.NewTicker(pingPeriod) for { if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { conn.Close() break } <-ticker.C }这里pingPeriod一般设置为pongWait除以 3,给自己留出余量。如果服务端发 Ping 失败,说明连接已经不可用,直接关闭并从 Hub 中移除。这部分的核心思路是:让失效连接尽早暴露,而不是延迟到广播失败时才报错。
3.3 并发消息的分发顺序:不丢、不乱才能说得过去
消息顺序问题在实时场景里通常被忽略,直到你发现前端显示的消息序号跳变或错乱。WebSocket 协议本身保证了单个连接上发送帧的有序性,但服务端多 goroutine 并发写同一个连接时,顺序就无法保证了。常见做法是每个连接维护一个写缓冲 channel,所有广播消息先进入这个 channel,由专门的写协程按先进先出的顺序发送。
type Client struct { conn *websocket.Conn send chan []byte } func (c *Client) writePump() { for { message := <-c.send if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { c.conn.Close() return } } }写缓冲长度可以设置为 256,当缓冲填满时,说明客户端消费速度跟不上服务端的产生速度,此时只能选择断开该客户端。这种做法在业界有个通俗的说法:慢消费者会被拖死,不如直接断掉重连。对于通知推送场景,256 的缓冲空间已经足够应付绝大多数文本消息。
4. 前端 Web UI 的实现与交互细节:漂亮界面背后是状态管理
4.1 整体结构与布局:一个即时通讯页面的构成
前端部分是一个单页应用,不依赖框架,原生 HTML/CSS/JavaScript 实现。整体布局分成三个区块:顶部是连接状态指示器和地址栏;中间是消息列表区域,采用自动滚动的消息流;底部是输入框 + 发送按钮。这样布局的目的是让使用者一眼就能看到连接状态的三种颜色切换——绿色代表正常,橙色代表重连中,红色代表断开。
<div id="app"> <header> <span id="status-dot" class="status-dot"></span> <input type="url" id="server-url" value="ws://localhost:8080/ws" /> <button id="connect-btn">连接</button> </header> <main id="message-area"></main> <footer> <input type="text" id="message-input" placeholder="输入消息,按回车发送" /> <button id="send-btn">发送</button> </footer> </div>布局上采用flex纵向排列,让消息区域占据剩余空间,底部输入框始终保持可见。断线重连的按钮在连接正常时是禁用状态,防止用户误操作。这套 UI 的核心是状态驱动:不同的连接状态对应不同的 UI 表现,而不是让用户反复刷新页面来试探。
4.2 消息渲染与 DOM 优化:不要做一个卡顿的消息列表
消息列表的渲染如果使用频繁的appendChild加批量插入,消息数量一多就会卡顿。这个项目里我采用了 DocumentFragment 批量插入加 DOM 池的方式:每次最多保鲜页面中最近的 200 条消息,超出部分直接从 DOM 树中移除。这样可以保证长时运行的消息流页面流畅不崩溃。
function appendMessage(msg) { const area = document.getElementById('message-area'); const frag = document.createDocumentFragment(); const row = document.createElement('div'); row.className = 'msg-row'; row.innerHTML = `<span class="msg-time">${formatTime(msg.timestamp)}</span><span class="msg-content">${msg.content}</span>`; frag.appendChild(row); area.appendChild(frag); // 超过200条,移除最早的消息节点 while (area.children.length > 200) { area.removeChild(area.firstChild); } area.scrollTop = area.scrollHeight; }参数说明:formatTime用时间戳计算相对时间,比如“刚刚”“3 分钟前”,避免使用绝对时间难以感知时效性。scrollTop赋值的时机一定要在消息节点插入结束后,否则会出现视图未更新导致滚不到底。用 DocumentFragment 的好处是只触发一次重绘,比逐条 append 性能好很多。
4.3 发送消息与广播回流:自己发的消息也会回来
项目的 Web UI 不只是被动接收消息,输入框写入内容发送后,服务端会把消息广播给所有在线连接,其中包含发送者自身。这样就能看到消息从输入到上屏的完整闭环。
sendBtn.onclick = () => { const msg = input.value.trim(); if (!msg || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: 'message', content: msg, timestamp: Date.now()/1000 })); input.value = ''; };这里有一个关键技巧:消息发送后不立即在本地渲染,而是等待服务端广播回来再显示。这样做的好处是,消息的上屏顺序与服务器分发完全一致,避免本地先行渲染导致的消息顺序错乱。
5. 常见问题排查:那些容易翻车的 WebSocket 连接细节
5.1 前端报错“connection failed”但服务端看起来在运行
现象:浏览器控制台提示 WebSocket 连接失败,服务端却没有任何异常日志。
原因:最常见的是地址写错了,ws://localhost:8080/ws和ws://localhost:8080是两个不同的路由,服务端只注册了/ws这一个升级入口。另外,浏览器限制 https 页面只能连接 wss:// 加密地址,如果你的 UI 是 https 部署,而 WebSocket 服务是裸 ws,就会握手失败。
解决:先确认前端地址中的 path 与后端路由一致,再确认页面协议与 WebSocket 协议匹配。开发环境最简单的方式是新增一个路由,把根路径/也指向 WebSocket 升级处理器,这样不会因为路径拼错而抓狂。
5.2 服务端日志显示广播成功,客户端收不到
现象:服务端每 5 秒广播一条消息,日志没有报错,但浏览器控制台是空的。
原因:广播逻辑存在一个隐蔽的 bug,把消息写到了连接对象的写缓冲,却没有启动writePump协程。缓冲区满了之后消息被静默丢弃。
解决:每条客户端连接建立后,必须同时启动readPump和writePump两个协程,缺一不可。缺少writePump,消息只是进了缓冲,永远不会走conn.WriteMessage真正发送。
5.3 反向代理下面 WebSocket 被 60 秒掐断
现象:前端每过 60 秒左右连接自动关闭,重连后又能撑 60 秒。
原因:常见的反向代理服务默认对无活动连接设置空闲超时,WebSocket 的 Ping/Pong 帧如果不被代理识别,就会被当成空连接掐断。
解决:在代理配置里为 WebSocket 路径设置更长的超时时间。如果你在用 Nginx,可以在 location 块里显式设置proxy_read_timeout 3600s;。如果代理本身不识别协议升级,还会出现 400 错误,需要在 location 里配置以下两行:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";这两行是保证代理能和客户端建立 WebSocket 隧道的关键,丢掉任意一行,握手都会被拒绝。
5.4 同一个事件被推送两次
现象:服务端明明只发了一次,前端却看到两条相同 id 的消息。
原因:前端在断线重连成功后,没有做消息同步补偿,导致服务端从持久化存储中补发的数据与已收到的数据重合。常见的做法是在重连成功后,前端发送一条包含最后消息 ID 的上行消息,服务端从该 ID 之后开始补发。
解决:前端维护一个lastReceivedId变量存储最后一次收到的 id,重连时发送{"type":"sync","lastId":123}给服务端,服务端据此过滤补发消息。
5.5 并发广播引发 panic:map 并发写冲突的典型症状
现象:服务端运行时偶尔抛出fatal error: concurrent map iteration and map write,随后进程崩溃。
原因:Hub 中的clientsmap 在多个 goroutine 中同时被读取和删除,没有加锁。
解决:确保所有对clients的读写都经过同一把互斥锁,包括 for range 遍历和 delete 操作。最简单的验证方式是在广播函数入口加一个defer打印日志,检查是否有两个 goroutine 同时进入。
6. 进阶用法:指数退避重连与消息补发的实用技巧
项目内置的 UV 重连逻辑是固定间隔 3 秒重试。放到真实环境中,这种策略容易造成服务端在重启期间被打爆。改进为指数退避重连:每次失败后的等待时间翻倍,直到一个上限值再重置。这样既保证了自动恢复的能力,又不会对服务端造成无谓的连接洪峰。
let retryCount = 0; const maxRetryDelay = 30 * 1000; function connect() { ws = new WebSocket(url); ws.onclose = () => { if (retryCount < 10) { retryCount++; const delay = Math.min(1000 * Math.pow(2, retryCount), maxRetryDelay); setTimeout(connect, delay); } }; ws.onopen = () => { retryCount = 0; }; }指数退避的边界条件需要处理:重试超过 10 次后停止自动重连,并在 UI 上给出手动重连的按钮,避免异常情况下无限循环消耗资源。为了让重连过程更平滑,连接状态指示器等onclose事件再切换颜色,不要根据定时器猜测是否在线。
另一个进阶点是服务端的消息补发。把发送过的消息 ID 和内容存进一个内存环形缓冲区,每个客户端连接时记录当时的lastId,断线重连时从lastId + 1开始补发,能覆盖大多数弱网场景下的消息丢失问题。这种方案不引入外部存储,适合消息量不大、对数据一致性要求没那么极端的场景。
把这两个能力合并到一起后,整个系统才像一个真正能在生产环境应付一点风浪的实时推送服务。从那以后,我每次接实时通信项目都强制先补齐心跳机制和补发策略这两块底子,再谈界面优化。希望这篇拆解记录对你搭建自己的 WebSocket 服务有点帮助。
本文还有配套的精品资源,点击获取