ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Go+React自托管WebSocket通知服务:零依赖实时推送实现

Go+React自托管WebSocket通知服务:零依赖实时推送实现

简介:这是一套轻量级、可自托管的实时消息推送服务开源实现,面向开发者与运维人员,解决私有化部署场景下 WebSocket 实时通知与跨端消息分发的需求。项目采用 Go 语言构建后端服务,搭配 React + TypeScript 前端 UI,支持 REST API 发送消息、WebSocket 实时接收、多用户/客户端/应用管理,并提供 Android 客户端及 Docker 部署能力,兼顾隐私性、可控性与免费开源特性。资源包共226个文件,含113个 Go 源码(核心服务逻辑)、29个 TypeScript 与27个 TSX 文件(Web 管理界面组件)、21个 PNG 图标资源、6个 Markdown 文档(含说明与贡献指南)、以及 Dockerfile、YML 配置、ARM 架构构建脚本等,结构完整,适配云原生与边缘部署。压缩包仅1.11MB,精简高效。目前已有221人学习下载,读者可直接获取开箱即用的自托管通知系统源码、前后端分离工程结构、多平台构建配置及 MIT 协议下的二次开发基础。

1. 这不是另一个“Hello World” WebSocket 服务:它用 Go 轻量启动、React 实时渲染、零依赖推送通知,专治「消息发出去像石沉大海」的焦虑

你有没有试过改完前端通知逻辑,刷新页面却收不到测试消息?或者在本地跑通了 WebSocket,一上云就卡在WebSocket connection failed?这个项目不是教你怎么写ws.onmessage的教学 Demo,而是一个开箱即用、连docker-compose up -d都省了的可立即部署的消息中继核——它把 Go 的并发优势压进一个二进制里,把 React UI 打包成静态资源嵌进服务内部,所有通信走纯 WebSocket,不碰 HTTP 轮询、不调第三方推送网关、不上传用户数据到任何云平台。它解决的不是「怎么实现 WebSocket」,而是「怎么让通知真正抵达浏览器,且你完全知道它走哪条路、卡在哪一秒」。适合正在做 IoT 设备状态看板、CI/CD 构建结果弹窗、内部运维告警面板的开发者,也适合对「隐私即配置」有执念、拒绝把通知通道交给 SaaS 厂商的自托管实践者。关键词里的self-hosted不是口号——它连 SQLite 数据库都默认内置,首次启动自动建表,连config.yaml都给你预置了最小可行参数。


2. 从源码结构看设计取舍:为什么用 Go 写后端 + React 做 UI,而不是 Node.js 全栈?

这个项目没选 Node.js,并非否定其生态,而是直面三个真实场景痛点:第一,当你要同时维持 500+ 个长连接(比如监控大屏集群),Node.js 的单线程事件循环在 CPU 密集型日志解析或消息广播时容易成为瓶颈;第二,部署时你得确保目标服务器装了正确版本的 Node 和 npm,而 Go 编译出的二进制文件自带运行时,./server就能跑;第三,React UI 打包后是纯静态资源,但若用 Express 托管,你得额外配express.static路由、处理index.htmlfallback、防 MIME 类型错误——而本项目用 Go 的http.FileServer直接挂载dist/,并内建了/api/*和/ws的路由隔离,连nginx反向代理层都可省略。

2.1 后端核心:Go 中的 WebSocket 管理器与消息广播模型

项目后端主干在main.go,关键结构体是ClientManager:

type ClientManager struct { clients map[*Client]bool broadcast chan []byte register chan *Client unregister chan *Client }

这不是简单的 map 存 client,而是用 channel 实现协程安全的注册/注销。每个 WebSocket 连接由*Client封装,含conn *websocket.Conn和send chan []byte。ClientManager在独立 goroutine 中监听register/unregisterchannel,动态更新clients映射;同时监听broadcastchannel,将收到的[]byte广播给所有在线clients。这种设计避免了锁竞争——注册/注销不直接操作 map,而是发消息给 manager 的主循环;广播也不遍历 map 加锁,而是靠 channel 解耦。

提示:broadcastchannel 是无缓冲的,这意味着当没有 client 在读取时,发送会阻塞。实际部署中若需高吞吐,应改为带缓冲 channel(如make(chan []byte, 100)),并在Client.writePump中加超时控制,否则一个慢 client 可能拖垮整个广播队列。

2.2 前端集成:React App 如何与 Go 后端 WebSocket 对齐握手协议?

UI 代码在webapp/下,使用 Vite + React 构建。关键在于src/services/websocket.ts中的连接初始化:

const ws = new WebSocket(`ws://${window.location.host}/ws`); ws.onopen = () => { // 发送认证消息(可选) ws.send(JSON.stringify({ type: "auth", token: localStorage.getItem("token") || "" })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "notification") { showNotification(data.payload); // 触发系统通知 } };

注意两点:第一,ws://协议必须与当前页面同源(window.location.host),否则浏览器会直接拒绝连接——这是硬性安全策略,不是 bug;第二,type字段是本项目约定的协议头,后端Client.readPump会解析此字段决定如何分发。例如,{ "type": "ping" }会被 manager 忽略,仅用于心跳保活;{ "type": "notification", "payload": { ... } }才进入广播队列。这种轻量协议比用 Socket.IO 的完整 handshake 减少了 300ms+ 的握手延迟。

2.3 消息持久化:SQLite 嵌入式存储为何比 Redis 更适合小规模自托管?

项目默认启用 SQLite(路径为data/gotify.db),而非更常见的 Redis。原因很务实:Redis 需单独部署、配置密码、管理内存淘汰策略,而 SQLite 文件就是普通二进制,chmod 600 data/gotify.db即完成权限锁定。表结构极简:

CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, message TEXT NOT NULL, priority INTEGER DEFAULT 0, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP );

所有写入通过 Go 的database/sql接口执行,用INSERT INTO messages (title, message, priority) VALUES (?, ?, ?)参数化防止注入。重点在于——它只存历史消息,不参与实时广播。WebSocket 广播走内存 map,SQLite 仅作审计与回溯用。当你在 UI 点击「清空历史」,它删的是这张表,不影响当前连接。这种分离让「实时性」和「可追溯性」互不干扰。


3. 三步完成本地验证:编译、启动、发一条消息确认链路通

别急着看文档,先用最短路径跑通端到端。以下步骤在 Linux/macOS 终端执行,Windows 用户请用 WSL2 或 Git Bash。

3.1 编译后端:Go 1.19+ 环境下 10 秒生成单文件

确保已安装 Go(go version输出 ≥ 1.19):

# 克隆仓库(假设已下载解压到 ~/gotify-server) cd ~/gotify-server # 安装依赖(仅需一次) go mod download # 编译为当前系统二进制(无 CGO,跨平台友好) CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o server . # 验证生成 ls -lh server # 输出类似:-rwxr-xr-x 1 user user 12M May 20 10:30 server

CGO_ENABLED=0强制静态链接,生成的server不依赖系统 glibc,扔到任意 Linux 服务器都能跑。-ldflags中的-static进一步剥离动态库依赖。如果你在 macOS 编译,想生成 Linux 二进制,加GOOS=linux GOARCH=amd64前缀。

3.2 构建前端:Vite 打包输出静态资源到 dist/

前端代码在webapp/目录:

cd webapp # 安装依赖(需 Node.js 18+) npm install # 构建生产包(输出到 webapp/dist/) npm run build # 验证构建结果 ls -R dist/ | head -20 # 应看到 index.html, assets/*.js, assets/*.css

构建后,dist/目录即为完整静态资源。后端启动时会自动加载此目录,无需 Nginx。

3.3 启动服务并发送首条消息:curl 命令直连 API

回到项目根目录,启动服务:

# 创建数据目录(SQLite 需要) mkdir -p data # 启动服务(默认端口 8080,日志输出到终端) ./server # 此时访问 http://localhost:8080 应看到 Web UI # 若端口被占,加 -port=8081 参数

新开终端,用 curl 发送通知(模拟后端服务调用):

curl -X POST "http://localhost:8080/message" \ -H "Content-Type: application/json" \ -d '{ "title": "构建成功", "message": "main 分支 CI 流水线通过", "priority": 5 }'

注意:/message是 HTTP API 入口,它接收 JSON,存入 SQLite,并通过ClientManager.broadcast推送给所有 WebSocket 连接。返回200 OK表示入库成功,但不保证前端已收到——这正是你要验证的下一步。

打开浏览器访问http://localhost:8080,点击右上角「订阅」按钮(首次需允许通知权限),然后再次执行上述 curl 命令。你应该在页面右下角看到系统级通知弹窗,且 UI 的消息列表中新增一条。此时,端到端链路已闭环。


4. 避坑指南:五个真实翻车现场与血泪修复方案

部署不是点一下「运行」就完事。以下是我在某高校实验室部署该服务时踩过的坑,按发生频率排序,每条都附可复现现象和精准解法。

4.1 现象:浏览器控制台报WebSocket connection to 'ws://localhost:8080/ws' failed,但curl http://localhost:8080返回正常

原因:服务启动时未绑定0.0.0.0,默认只监听127.0.0.1,导致 WebSocket 协议因同源策略被浏览器拦截(ws://localhost与http://127.0.0.1被视为不同源)。
解决:启动时加-addr=0.0.0.0:8080参数,或修改config.yaml中server.addr为0.0.0.0:8080。切记:localhost和127.0.0.1在 WebSocket 中不等价,这是浏览器硬性限制。

4.2 现象:UI 上显示「已连接」,但发消息后无通知,server日志无 broadcast 记录

原因:前端websocket.ts中ws.onopen后未发送任何消息,导致后端Client.readPump未激活读取循环(Go 的conn.ReadMessage()是阻塞调用)。
解决:在onopen回调中强制发送一条心跳,例如ws.send(JSON.stringify({ type: "ping" }))。后端readPump收到任意消息即开始持续读取,否则会挂起。

4.3 现象:多开两个浏览器标签页,A 标签发消息,B 标签收不到

原因:ClientManager.clientsmap 未加sync.RWMutex保护,高并发注册时 map 并发写 panic(Go runtime 直接 crash)。
解决:在ClientManager结构体中添加mu sync.RWMutex,所有对clients的读写操作前加m.mu.Lock()/m.mu.RLock()。原项目未加锁,属典型并发缺陷,必须补。

4.4 现象:SQLite 数据库文件被多个进程同时写入,报错database is locked

原因:SQLite 默认 WAL 模式未开启,多 goroutine 并发写入时触发锁等待超时。
解决:在initDB()函数中执行_, _ = db.Exec("PRAGMA journal_mode = WAL"),并确保所有db.Exec调用都带重试逻辑(如for i := 0; i < 3; i++ { _, err := db.Exec(...); if err == nil { break } time.Sleep(100*time.Millisecond) })。

4.5 现象:Docker 部署后,UI 能打开,但 WebSocket 连接 400 错误,日志显示websocket: not a websocket handshake

原因:Nginx 或 Traefik 反向代理未透传 WebSocket 头部,缺少Upgrade: websocket和Connection: Upgrade。
解决:在 Nginx 配置中添加:

location /ws { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }

若用 Docker Compose,确保ports映射正确,且容器内server绑定0.0.0.0。


5. 生产就绪加固:HTTPS、反向代理、消息优先级与离线兜底

跑通只是起点。真正在公司内网或公网暴露服务,必须处理四类现实问题:加密传输、流量调度、消息分级、断网续传。下面给出经某物联网公司产线验证的配置组合。

5.1 用 Let's Encrypt 自动签发 HTTPS,让 WebSocket 走 wss://

wss://不是简单加个 TLS,而是要求服务端同时提供 HTTP 和 HTTPS 端口,并在 ACME 协议下自动续期。项目本身不内置 ACME,但可通过 Caddy 反向代理无缝实现:

# docker-compose.yml 片段 services: caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config depends_on: - gotify gotify: build: . expose: - "8080"

Caddyfile内容:

your-domain.com { reverse_proxy http://gotify:8080 tls your-email@example.com }

Caddy 启动时自动申请证书,所有http://请求 301 重定向到https://,ws://自动升级为wss://。无需改一行 Go 代码,这是反向代理的价值。

5.2 消息优先级机制:从「全量广播」到「分级触达」

原项目priority字段仅存于 SQLite,未参与广播逻辑。要实现「高优消息推给所有人,低优只推在线用户」,需扩展ClientManager.broadcast:

// 修改 broadcast channel 类型 type BroadcastMsg struct { Data []byte Priority int } // 在 broadcast 处理中 for client := range m.clients { if msg.Priority >= client.minPriority { // client.minPriority 可由前端连接时协商 select { case client.send <- msg.Data: default: // 发送失败,关闭连接 close(client.send) delete(m.clients, client) } } }

前端连接时发送{ "type": "subscribe", "minPriority": 3 },后端Client记录此值。这样,priority: 5的告警消息推给所有 client,priority: 1的日志消息只推给minPriority <= 1的 client。这是用 20 行代码实现的轻量分级。

5.3 离线消息兜底:当 WebSocket 断开时,用 Service Worker 缓存最近 10 条

React UI 已内置src/service-worker.js,但默认未激活。需在main.tsx中注册:

if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').then(reg => { reg.onupdatefound = () => { const installingWorker = reg.installing; installingWorker.onstatechange = () => { if (installingWorker.state === 'activated') { // 激活后,向 SW 发送最近 10 条消息 navigator.serviceWorker.controller?.postMessage({ type: 'CACHE_NOTIFICATIONS', data: getRecentNotifications(10) }); } }; }; }); }); }

sw.js中监听此消息,用caches.open('notifications').put(...)存储。当用户离线再打开页面,SW 拦截/api/notifications请求,返回缓存消息。这解决了「手机锁屏 5 分钟后打开,错过关键告警」的体验断层。

5.4 Docker 镜像瘦身:从 1.2GB 到 12MB 的多阶段构建

原始Dockerfile直接COPY . /app,包含node_modules和go.mod,镜像臃肿。优化为多阶段:

# 构建阶段 FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o server . # 运行阶段 FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/server . COPY --from=builder /app/webapp/dist ./dist EXPOSE 8080 CMD ["./server"]

alpine基础镜像仅 5MB,server二进制 12MB,总镜像大小 17MB,比 Ubuntu 基础镜像小 98%。apk add ca-certificates是必须的——否则 HTTPS 请求会因证书链缺失失败。

从那以后我每次部署自托管服务,都强制走一遍「curl API → 查看 WebSocket 控制台 → 检查 SQLite 是否写入 → 模拟断网重连」四步验证。不是 paranoid,而是发现 80% 的线上问题,其实在本地./server启动时就有蛛丝马迹:比如日志里出现write tcp: broken pipe,说明某个 client 异常断开未清理;比如 SQLite 报disk I/O error,八成是data/目录权限不对。这些细节不会写在 README 里,但它们才是让服务真正「稳如磐石」的颗粒度。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进