news 2026/10/27 5:12:10

Golang+WebSocket自托管实时消息推送服务器实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Golang+WebSocket自托管实时消息推送服务器实践

简介:面向需要自建实时消息推送通道的开发者与运维人员,这份压缩包提供一套可自托管的轻量服务器方案:通过 REST-API 发送消息、以 WebSocket 实时接收,并配套用户、客户端与应用管理界面及简洁 Web UI,适合替代已停止维护的开源项目或第三方免费推送服务。包内共 226 个文件,约 1.11MB,以 113 个 Go 后端源码、29 个 TypeScript 和 27 个 TSX 前端源码为主体,另有 21 张界面图片、6 份 Markdown 文档、6 个 JSON 配置,并包含 arm64、armv7 的 Dockerfile,便于梳理前后端分层与多架构部署流程。已有 222 人学习下载。读者可获得完整的前后端实现、REST 接口与 WebSocket 收发链路、测试用例及权限和客户端管理设计,也能据此搭建私有推送服务、接入 Android 客户端并进行二次开发,适合 Go、React 技术栈学习与自托管实践。

1. 一个进程搞定实时推送:这套自托管消息服务器适合谁

手机上装一个 App,服务器上跑一个二进制,任何脚本、CI 流水线、监控告警只要往一个 HTTP 接口 POST 一条 JSON,几毫秒后手机、浏览器、桌面同时弹出通知——这就是这套项目要做的事。服务端用 Golang 编写,基于 WebSocket 与每个客户端维持长连接,把消息实时扇出;前端是一个 React 写的时尚 Web UI,能翻历史消息、管理应用和 Token;对外只暴露一套 REST API。它适合不想把告警内容交给第三方推送平台的人:家庭实验室、内网 CI、个人博客评论提醒、树莓派上的定时任务,都可以拿它做 self-hosted notifications 的底座。部署成本很低,一个二进制加一个 SQLite 文件就够跑起来。

2. 技术栈与目录结构:Go 单二进制 + React 嵌入前端的工程布局

这个项目最值得先看的是它的工程形态:后端和前端不是两个独立部署的服务,而是最终合并成一个可执行文件。这个选择直接决定了后面的部署体验和二次开发方式,所以先把它拆开讲清楚,再谈目录和配置。

2.1 后端选型:net/http 加 gorilla/websocket 的组合

Golang 在这里几乎是必然选择。推送服务器的核心负载不是 CPU 也不是磁盘,而是同时挂着的大量空闲长连接,一个家庭用户可能挂 3 台设备,一个小团队挂几十台,goroutine 的栈是按需增长的,几千个连接的内存占用仍然很低,这是它比线程模型更合适的地方。另一个现实原因是交叉编译:GOOS=linux GOARCH=arm64 go build一条命令就能产出树莓派能直接跑的文件,不用在目标机器上装运行时。

具体到库的选择,HTTP 层用标准库net/http加一个薄路由(gin或chi都行,这个项目的接口数量很少,路由压力不大),WebSocket 用gorilla/websocket而不是标准库里的x/net/websocket。原因很实际:x/net/websocket的 API 设计偏老,连接升级、读写超时、Ping/Pong 控制帧这些都要自己处理得很别扭,而gorilla/websocket给了Upgrader、SetReadDeadline、WriteControl这几个直接可用的原语,心跳逻辑写起来只有十几行。

数据库选 SQLite,只存消息、应用、客户端三类数据,单文件、零运维、备份就是cp一下。代价是并发写入有锁,但这个场景的写入量是「告警级别」的,一天几千条已经算多,完全够用。如果你的场景是几十个服务每秒刷消息,那在选型阶段就该换 PostgreSQL,这一点在后面的配置章节会提到。

2.2 前端选型:React 构建产物用 go:embed 塞进二进制

前端是 React + Vite 的组合,构建出来的dist目录会被go:embed打进最终的二进制里。这样做的好处是部署时不需要 nginx 托管静态文件,也不存在前后端版本不一致的问题;坏处是每次改前端都要重新go build,开发阶段有点慢,所以一般会保留一个开发模式:Vite dev server 起在 5173,通过proxy把/api、/message、/stream转发到本地 8080 的 Go 服务。

嵌入的写法大致是这样:

// main.go import "embed" //go:embed all:ui/dist var uiFS embed.FS func mountUI(r *gin.Engine) { // 去掉前缀,把 dist 当成根目录 sub, _ := fs.Sub(uiFS, "ui/dist") r.NoRoute(func(c *gin.Context) { // 只对非 API 路径回落到 index.html,否则接口 404 会被前端页面吞掉 if strings.HasPrefix(c.Request.URL.Path, "/api") { c.JSON(404, gin.H{"error": "not found"}) return } c.FileFromFS(c.Request.URL.Path, http.FS(sub)) }) }

//go:embed后面的路径是相对于当前.go文件所在目录的,所以ui/dist必须真实存在,否则编译直接报错——这一点经常在全新 clone 仓库时踩到,因为dist通常在.gitignore里。fs.Sub的作用是把ui/dist这层前缀剥掉,让embed.FS里的/index.html对应到 URL 的/index.html。NoRoute里那个前缀判断是必须的,否则前端路由的 fallback 会把本该返回 404 的接口请求也返回成 HTML,调试时会让人怀疑人生。

2.3 目录结构与配置项逐项对照

按模块拆开,这个项目的目录大致长这样:

pushsvr/ ├── main.go // 入口:读配置、初始化 DB、装配路由、启动 ├── config.example.yml // 配置模板 ├── api/ // HTTP handler:message、application、client、user │ ├── message.go │ ├── application.go │ └── stream.go // WebSocket 升级入口 ├── model/ // 结构体与请求体定义 ├── database/ // SQLite 连接、建表、查询封装 ├── router/ // Hub:连接注册、广播、心跳 ├── plugin/ // 可选的消息转发扩展 ├── ui/ // React 源码 + dist 构建产物 └── data/ // 运行期生成:pushsvr.db

配置走 YAML,常见项如下:

配置项默认值作用
server.listen-address0.0.0.0监听地址,容器里必须保持 0.0.0.0
server.port80监听端口,非 root 运行时改成 8080
server.public-url空生成客户端配置时用的对外地址,反代后必须填
database.dialectsqlite3数据库类型,预留了 postgres 分支
database.connectiondata/pushsvr.dbSQLite 文件路径,容器里要指向挂载卷
defaultuser.useradmin首次启动创建的账号
defaultuser.pass随机生成不填则打印到启动日志

server.public-url是最容易漏的一项。Web UI 里的「客户端接入信息」页面会用它拼出完整的推送地址和 WebSocket 地址,如果不填,你从外网打开页面看到的就是http://0.0.0.0:80这种没法复制的地址。我一般会在反代域名确定之后第一件事就是把它补上,而不是等到配手机客户端时才发现不对。

3. 消息主链路拆解:从一次 POST /message 到 WebSocket 扇出

这一章走完整条链路:外部脚本 POST 一条消息,服务端鉴权、落库、广播,WebSocket 连接上的客户端收到并渲染。整条链路涉及四段代码,我把它们按数据流顺序摆开,每一段都可以直接对着抄。

3.1 数据模型与建表:三张表撑起消息、应用、客户端

核心只有三张表。应用(Application)是消息的生产者身份,一个应用一个 Token;客户端(Client)是消费方,可以同时订阅多个应用;消息(Message)落在应用下面。

-- 应用:每个应用一个 token,用来校验推送请求 CREATE TABLE IF NOT EXISTS applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 客户端:一台设备一个 token,display_name 用于在 UI 里区分 CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL, last_seen DATETIME ); -- 消息:priority 决定客户端是否响铃,范围 -2 ~ 10 CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id INTEGER NOT NULL, title TEXT NOT NULL DEFAULT '', message TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 客户端按应用 + 自增 id 倒序拉历史,这个索引必须有 CREATE INDEX IF NOT EXISTS idx_messages_app_id ON messages(application_id, id DESC);

priority用整数而不是枚举,是为了让客户端自己做映射:-2到1静默入库,2到5普通通知,7以上响铃加震动。服务端只做范围校验,不关心客户端怎么响,这样两端可以独立演进。索引建在(application_id, id DESC)上是有讲究的:UI 的历史列表永远是「某个应用的最新 N 条」,这个复合索引能直接命中,不用回表排序。

消息表还需要一个保留策略,否则跑半年之后 SQLite 文件会有几百兆。常见做法是在写入之后顺手做一次清理,按应用保留最近 1000 条:

// 写入成功后调用,删除该应用第 1000 条之后的旧消息 func (db *DB) Trim(appID uint) error { _, err := db.Exec(` DELETE FROM messages WHERE application_id = ? AND id NOT IN ( SELECT id FROM messages WHERE application_id = ? ORDER BY id DESC LIMIT 1000 )`, appID, appID) return err }

这个语句在消息量大时会有性能开销,所以我一般不在每条消息后都执行,而是每写入 50 条触发一次,或者干脆挂一个每小时的定时任务。子查询里两次传appID是必须的,漏掉一个会变成跨应用删除,这个错误一旦发生就是数据事故。

3.2 推送接口 handler:鉴权、落库、扇出三段式

POST /message是整套系统对外的唯一写入口。它做的事情按顺序是:从请求里取 Token 找到应用、校验参数、写库、广播给 Hub。

type pushRequest struct { Title string `form:"title" json:"title"` Message string `form:"message" json:"message" binding:"required"` Priority int `form:"priority" json:"priority"` } func (a *API) PushMessage(c *gin.Context) { // 1. 鉴权:Token 可以放在 X-Gotify-Key 头,也可以放在 ?token= 查询参数 app, err := a.authApp(c) if err != nil { c.JSON(401, gin.H{"error": "invalid token"}) return } var req pushRequest if err := c.ShouldBind(&req); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } // 2. 参数兜底:priority 越界直接截断,避免客户端收到非法值 if req.Priority < -2 { req.Priority = -2 } if req.Priority > 10 { req.Priority = 10 } msg := &model.Message{ ApplicationID: app.ID, Title: req.Title, Message: req.Message, Priority: req.Priority, CreatedAt: time.Now(), } // 3. 先落库再广播:即使广播失败,消息也能在历史里查到 if err := a.db.CreateMessage(msg); err != nil { c.JSON(500, gin.H{"error": "persist failed"}) return } // 4. 扇出到所有订阅了该应用的 WebSocket 连接 a.hub.Broadcast(app.ID, msg) c.JSON(200, msg) }

s.ShouldBind同时支持application/json和application/x-www-form-urlencoded,后者是为了让curl -F和 shell 脚本好写。binding:"required"只加在message上,因为标题可以为空,很多脚本只发一行文本。priority的截断是防御性的,客户端代码里通常会有一个 switch 映射优先级,收到 99 这种值可能直接 panic。

顺序上「先落库再广播」不能反。如果先广播后落库,落库失败时客户端已经弹了通知,历史里却查不到,用户会以为是重复推送。这个顺序在消息量大的时候会多几毫秒延迟,但换来的是一致性,值得。

3.3 WebSocket 连接管理:Hub、读写 pump 与心跳

广播不能直接遍历连接写 socket。gorilla/websocket的连接不支持并发写,多个 goroutine 同时写会直接 panic,所以标准做法是每个连接配一个带缓冲的sendchannel,写操作统一交给一个专属 goroutine。

type Client struct { AppID uint conn *websocket.Conn send chan *model.Message } type Hub struct { mu sync.RWMutex clients map[*Client]struct{} } // Broadcast 遍历所有连接,非阻塞投递,投不进去就踢掉这个客户端 func (h *Hub) Broadcast(appID uint, msg *model.Message) { h.mu.RLock() defer h.mu.RUnlock() for c := range h.clients { if c.AppID != appID { continue } select { case c.send <- msg: // 缓冲未满,投递成功 default: // 缓冲满了说明这个客户端消费不过来,断开它,别拖累其他人 go c.Close() } } } // writePump 是唯一允许写这条连接的地方 func (c *Client) writePump() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case msg, ok := <-c.send: if !ok { c.conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err := c.conn.WriteJSON(msg); err != nil { return } case <-ticker.C: // 每 30 秒发一个 Ping,客户端回 Pong,用来发现半开连接 if err := c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { return } } } }

send的缓冲设成 16 到 64 之间比较合适。太小容易误杀正常客户端(网络抖动时会短暂积压),太大则一条慢连接会占用更多内存。default分支里用go c.Close()而不是直接调用,是因为Broadcast持有读锁,Close里要拿写锁注销自己,同步调用会死锁——这是这类 Hub 模式最经典的翻车点。

心跳那部分是另一个关键。TCP 连接在没有数据往来时可能被中间设备静默丢弃,服务端以为还连着,消息发出去石沉大海。30 秒 Ping 一次,配合读端SetReadDeadline(60 * time.Second),就能在 60 秒内发现死连接并清理掉。

3.4 前端订阅层:React 里的自动重连 hook

Web UI 侧的核心就是一个useEffect加一个 WebSocket 实例,重点在于断线重连和去重。

function useMessages(appToken) { const [messages, setMessages] = useState([]); useEffect(() => { let ws; let retry = 0; let timer; const connect = () => { // 相对路径拼协议,https 页面下自动用 wss,避免混合内容被浏览器拦截 const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/stream?token=${appToken}`); ws.onopen = () => { retry = 0; }; // 连上就重置退避计数 ws.onmessage = (e) => { const msg = JSON.parse(e.data); // 用 id 去重,防止重连瞬间服务端重放导致列表出现重复项 setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev].slice(0, 200) ); }; ws.onclose = () => { retry += 1; const delay = Math.min(1000 * 2 ** retry, 30000); // 指数退避,封顶 30 秒 timer = setTimeout(connect, delay); }; }; connect(); return () => { clearTimeout(timer); ws && ws.close(); }; }, [appToken]); return messages; }

location.host而不是硬编码后端地址,是为了让反代和本地开发共用一个组件。2 ** retry的指数退避封顶 30 秒,防止服务端重启期间几百个标签页同时疯狂重连把它再打挂一次。slice(0, 200)是纯前端的列表长度限制,避免长时间挂着页面后 DOM 节点爆炸,历史数据仍然在服务端,翻页时再拉。

4. 启动与验证:从 docker run 到第一条推送落地的完整路径

把项目跑起来有两条路:容器和源码编译。前者适合只想用的场景,后者适合要改代码的场景。两条路我都会走一遍,因为排查问题时经常需要在本地复现容器里的行为。

4.1 容器化启动:挂载数据卷、映射端口、设置初始账号

容器启动最短的一条命令是这样,但我建议直接上 compose,因为参数不止一两个:

# docker-compose.yml services: pushsvr: image: pushsvr/pushsvr:latest container_name: pushsvr ports: - "8080:80" # 宿主 8080 映射容器内 80 volumes: - ./data:/app/data # 必须挂载,否则删容器等于删库 environment: - PUSHSVR_SERVER_LISTENADDRESS=0.0.0.0 - PUSHSVR_SERVER_PORT=80 - PUSHSVR_SERVER_PUBLICURL=https://push.example.internal - PUSHSVR_DEFAULTUSER_USER=admin - PUSHSVR_DEFAULTUSER_PASS=change-me-now restart: unless-stopped

三个点必须说明。./data:/app/data这个挂载是硬性要求,SQLite 文件在容器内的/app/data/pushsvr.db,不挂载的话docker rm之后所有应用、消息、Token 全部蒸发,我见过有人跑了两周才发现这件事。环境变量是 YAML 配置项的扁平化写法,_分隔层级,全大写,这个约定在很多 Go 项目里通用,改之前先看一眼config.example.yml的结构对应关系。SERVER_PUBLICURL填的是用户实际访问的地址,不是容器地址,填错了 Web UI 里生成的客户端配置全是废的。

启动后第一件事是看日志里那行初始密码:

docker compose up -d docker compose logs -f pushsvr | head -n 30

日志里会打印Default admin user created: admin / xxxxxxxx(如果你没在环境变量里指定密码)。这行只出现一次,错过就只能删库重来或者进容器里改配置,所以启动完立刻复制到密码管理器。登录http://localhost:8080之后,先进「应用」页新建一个应用,名字随便填,比如ci-build,系统会给你一个应用 Token,长得像A1b2C3d4E5f6G7h8,这是后面推送要用的。

4.2 本机源码编译:go build 与前端构建产物的对齐

源码路径适合要改代码的人。顺序不能颠倒,先构建前端再构建后端,因为go:embed在编译期就要读到ui/dist:

# 1. 前端依赖与构建,产物落到 backend/ui/dist cd ui && npm ci && npm run build && cd .. # 2. 后端编译,注意 GOOS/GOARCH 按目标机器调整 CGO_ENABLED=1 go build -trimpath -ldflags "-s -w" -o pushsvr . # 3. 准备配置并启动 cp config.example.yml config.yml ./pushsvr

CGO_ENABLED=1是因为 SQLite 驱动通常走 cgo 版本,交叉编译到 ARM 时需要配套的交叉工具链;如果嫌麻烦,可以换成纯 Go 的 SQLite 实现,代价是性能略低,但交叉编译一条命令就能出结果,个人项目里我更倾向后者。-trimpath和-ldflags "-s -w"是纯粹为了减小体积,把路径信息和调试符号剥掉,二进制能小三分之一左右。

如果编译报pattern all:ui/dist: no matching files found,说明前端没构建或者输出目录不对。去ui/vite.config.js里确认build.outDir是不是指向了../ui/dist或者被改成了别的路径。这个报错信息很直白,但第一次遇到时容易懵,因为它出现在go build阶段而不是npm阶段。

本机跑的默认监听是 80 端口,非 root 用户会bind: permission denied,改config.yml里的server.port: 8080就行,不用 sudo。

4.3 首条推送与客户端接入验证

服务起来之后,用 curl 打一条消息,这是整个验证流程里最快能确认端到端通不通的方式:

# -F 走表单编码,后端 ShouldBind 同时兼容表单和 JSON curl -X POST "http://127.0.0.1:8080/message?token=A1b2C3d4E5f6G7h8" \ -F "title=部署完成" \ -F "message=构建 #1024 已上线,耗时 3 分 12 秒" \ -F "priority=5" # 期望返回:{"id":1,"application_id":1,"title":"部署完成",...}

同时打开 Web UI,如果消息列表里立刻出现了这条记录,说明「HTTP 写入 → 落库 → WebSocket 广播 → React 渲染」整条链路是通的。如果没有立刻出现但刷新后有,问题在 WebSocket 段(多半是反代没转发 Upgrade 头);如果刷新后也没有,问题在写入段(Token 错或者数据库没权限)。这个二分法能省掉大量猜测。

接下来验证订阅分离:在 UI 里新建一个客户端(Client),拿到客户端 Token,用它在手机 App 或第二个浏览器窗口订阅多个应用。这一步的意义是确认客户端的app_id过滤逻辑正确——向ci-build推送时,只订阅了home-alert的客户端不应该弹通知。我一般会故意交叉发三条不同应用的消息来验证,因为广播里漏掉appID判断是一个很难在单应用测试中发现的 bug。

5. 避坑与排查:五条把服务跑挂的现场记录

这一章是我自己踩过和帮人排查过的几类问题,按「现象 → 原因 → 解决」写。共同点是它们都不会在本地localhost测试时暴露,全都出现在上了反代、换了网络环境、或者跑了几天之后。

5.1 反向代理与协议层的三个坑

现象:页面能打开,登录正常,消息也能发出去,但 UI 上的实时列表永远不自动更新,必须手动刷新;浏览器控制台里 WebSocket 连接几秒就断一次,然后重连。

原因:Nginx 默认不转发Upgrade和Connection头,WebSocket 握手被当成普通 HTTP 请求处理,握手失败后前端按退避策略不断重连,看起来就像「连上又断」。这个现象非常容易被误判成后端心跳写错了。

解决:在 location 块里显式加上协议升级头,并且把超时拉长:

location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; # 长连接不能被 60 秒默认超时掐断 }

proxy_read_timeout这一行经常被漏掉,默认 60 秒,一个空闲的 WebSocket 连接就会被 Nginx 主动关掉,表现是「每分钟断一次又自动重连」,日志里看不出明显错误。

现象:换到 HTTPS 域名之后,页面加载正常,但控制台报Mixed Content: The page at 'https://...' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://...'。

原因:前端代码里硬编码了ws://地址,浏览器在 HTTPS 页面下禁止发起非加密的 WebSocket 连接。

解决:代码里用location.protocol判断,HTTPS 下拼wss://,或者干脆只写相对路径让浏览器自己补协议。前面 3.4 那段 hook 就是这么处理的。如果反代终止了 TLS,后端本身仍然是 HTTP,不需要额外配置证书,只要保证 Nginx 那边proxy_set_header X-Forwarded-Proto $scheme传下去,public-url配置成https://开头的地址即可。

现象:消息推送接口返回 200,返回体里的id也正常递增,但客户端就是收不到,重启客户端偶尔又能收一条。

原因:应用 Token 和客户端 Token 混用了。推送接口认的是应用 Token,WebSocket 订阅认的是客户端 Token(或者用户会话),拿应用 Token 去连/stream有时恰好能通过是因为鉴权逻辑做了兼容,但广播时按appID过滤就对不上。

解决:在 UI 里把两种 Token 的用途分清楚,应用 Token 只出现在「推送示例」里,客户端 Token 只出现在「客户端接入」里。排查时最快的办法是看服务端日志里那条连接注册时打印的appID,和你推送时用的应用id对不对得上。

5.2 数据与运行时的两个坑

现象:容器重启一次,所有应用、消息历史、客户端全部消失,账号密码也回到了初始状态。

原因:没有挂载数据卷。SQLite 文件写在容器的可写层里,容器重建就是全新的文件系统。这个坑的迷惑性在于「重启」(docker restart)不会丢数据,只有「重建」(docker compose up拉新镜像)才会丢,所以有人跑了一周都没发现,直到升级镜像才炸。

解决:挂载./data:/app/data,并且确认database.connection指向的就是这个路径。迁移时把宿主机的data目录打包带走就行,不需要mysqldump那套。顺手加一个每天cp到另一块盘的定时任务,SQLite 的好处就在这里,备份成本几乎为零。

现象:服务跑着跑着内存涨到几百兆,CPU 也上去了,日志里没有报错,重启后恢复正常,过几天再来一次。

原因:慢客户端导致的连接堆积。如果广播时用的是同步写或者无缓冲 channel,一个网络很差的客户端会把整个 Hub 的读锁长时间占住,其他客户端的消息全部延迟,goroutine 越积越多。

解决:用带缓冲的sendchannel 加default分支超时踢人,就是 3.3 里那段代码。另外给每条连接设一个写超时conn.SetWriteDeadline(time.Now().Add(10 * time.Second)),写不出去就断开。判断是否还有这个问题,可以加一个/metrics或者简单的/api/health接口,返回当前 Hub 里的连接数,观察它是不是只增不减。

现象:消息列表里偶尔出现两条一模一样的内容,id不同,时间差一两秒。

原因:调用方重试。很多 CI 脚本里的curl没有--fail,遇到超时或者 502 会自动重试,服务端没有任何幂等判断,重试就变成两条消息。

解决:服务端加一个可选的X-Idempotency-Key头,请求进来先查最近一分钟内有没有相同 key 的记录,有就直接返回旧消息。调用方也可以自己带一个内容哈希。这个需求不是所有人都需要,所以做成可选头,不带就保持原有行为。

6. 二次开发:加一个消息转发插件与自定义 Web UI

改这个项目最常见的两个需求:把消息同时转发到别的系统,以及把 Web UI 换个样子。前者改后端,后者改前端,两条路都不需要动核心逻辑,这也是当初把转发做成扩展点的原因。

转发扩展一般实现成一个接口,在消息落库之后、广播之前调用:

// plugin/plugin.go type Forwarder interface { Name() string // 返回 error 不影响主流程,只记日志 Forward(msg *model.Message, app *model.Application) error } // plugin/webhook.go 一个最简单的实现 type Webhook struct{ URL string } func (w *Webhook) Name() string { return "webhook" } func (w *Webhook) Forward(msg *model.Message, app *model.Application) error { body, _ := json.Marshal(map[string]any{ "app": app.Name, "title": msg.Title, "text": msg.Message, "priority": msg.Priority, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() return nil }

关键在于context.WithTimeout和错误不外抛。转发目标挂掉是常态,不能因为对方超时就把自己的推送链路堵死。注册时在main.go里按配置决定加载哪些 Forwarder,Forward调用放在一个独立 goroutine 里跑,主链路只负责把消息塞进去。

前端改样式更简单,ui/src下的主题变量集中在一个 CSS 变量文件里,主色、圆角、字体改三行就能换一套观感,npm run build之后重新go build一次即可。改完的验证方式我固定为三步:先跑curl确认消息能落库,再开两个浏览器窗口确认 WebSocket 实时到达,最后断网十秒再恢复,看重连后有没有重复消息。这三步走完,基本可以确定改动没有破坏主链路。

从那以后我每次动这类推送服务的连接层,都会先把「断网十秒再恢复」这一条加进自测清单,因为大部分问题都藏在那十秒里,而不是在一切正常的演示里。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 5:11:39

营销技能体系如何搭建:从用户洞察到策略执行的完整方法论

做了几年营销&#xff0c;我越来越确认一件事&#xff1a;所谓“营销技能”&#xff0c;最大的门槛从来不是工具和渠道&#xff0c;而是把零散动作串成一条逻辑链的能力。很多入行两三年的朋友问我&#xff1a;“为什么我每天写文案、投广告、发内容&#xff0c;年终总结却讲不…

作者头像 李华
网站建设 2026/10/27 5:10:55

OpenMontage:基于智能体与流水线的可编程视频生产系统

1. 项目概述&#xff1a;这不是一个视频剪辑软件&#xff0c;而是一套可编程的视频生产流水线OpenMontage 这个名字刚看到时&#xff0c;我第一反应是“哦&#xff0c;又一个开源剪辑工具”&#xff0c;但实际深入跑通第一个 pipeline 后&#xff0c;我立刻把本地所有 Premiere…

作者头像 李华
网站建设 2026/10/27 5:09:59

营销技能的真实构成:数据敏感度、用户洞察与渠道矩阵操盘

1. 营销技能的真实构成&#xff1a;它从来不是单点能力这几年面试过不少营销岗位候选人&#xff0c;也带过一批又一批刚入行的新人&#xff0c;我越来越明确一件事&#xff1a;营销技能这个词&#xff0c;已经被用滥了。简历上写"熟悉全域营销""具备内容策划能力…

作者头像 李华
网站建设 2026/10/27 5:08:50

端到端测试(E2E)实战指南:框架选型、Profile配置与CI集成

做软件开发的人&#xff0c;对e2e这个词应该都不陌生——它就是end-to-end的缩写&#xff0c;中文常叫端到端测试。但你真去问身边同事&#xff0c;能把这个概念讲透、能把测试真正落地跑起来的人&#xff0c;其实不多。有人觉得E2E就是“用自动化脚本点一点页面”&#xff0c;…

作者头像 李华
网站建设 2026/10/27 5:07:41

基于YOLO的火焰烟雾检测系统开发与优化实践

1. 项目概述&#xff1a;基于YOLO的火焰烟雾检测系统去年参与某工业园区安防系统升级时&#xff0c;客户特别强调需要实时火焰烟雾检测功能。传统监控系统依赖人工值守&#xff0c;夜间误报率高达30%。我们最终采用YOLOv5PyQt5的方案&#xff0c;使误报率降至3%以下。这个开源项…

作者头像 李华
网站建设 2026/10/27 5:07:40

专科生论文格式规范:8大AI工具高效解决方案

1. 论文格式规范&#xff1a;专科生的痛点与AI工具的破局写毕业论文最让人头疼的往往不是内容创作本身&#xff0c;而是那些繁琐的格式规范。页眉页脚、目录生成、参考文献引用、字体字号、段落间距……这些细节问题常常让专科生们抓狂。我带的几届学生中&#xff0c;至少有70%…

作者头像 李华