ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Go+React+WebSocket自托管实时消息推送服务

Go+React+WebSocket自托管实时消息推送服务

简介:面向需要自托管消息推送与实时通知的开发者,这份服务器项目提供基于 REST-API 发送、WebSocket 实时接收的轻量方案,并配套简洁 Web 管理界面,可用于管理用户、客户端与应用,适合作为私有部署通知服务的参考实现。压缩包共 226 个文件,约 1.11MB,以 Go 源码(113 个)构成服务端核心,TypeScript 与 TSX 文件共 56 个支撑前端界面,另含 PNG 界面素材、Markdown 文档、JSON 配置、YAML 流程及多架构 Dockerfile,便于理解前后端结构与容器化部署。已有 222 人学习下载。读者可从中获得较完整的实时消息服务工程样本,包括消息发送与 WebSocket 接收链路、用户与客户端管理逻辑、Web UI 组织方式,以及面向 arm64、armv7 的部署配置,适合学习 Go 后端与 React 前端的协作,也可作为二次开发自托管通知工具的基础。

1. 服务器能干什么:一条消息从 API 打进浏览器只要几十毫秒

某开发者手里有台常年开着的小机器,上面跑着定时脚本、构建任务和几个监控探针,出问题时他不想靠翻邮箱才发现。于是他需要一个服务器:对外只暴露一个简单的 HTTP 接口,收到消息后用 WebSocket 实时推给所有在线连接,前端再配一个时尚的 Web UI,打开网页就能看到消息流。

这个项目干的就是这件事。Go 写后端,React 写界面,消息落库、连接广播、令牌鉴权一条龙,编译完是单个可执行文件,扔到机器上就能自托管。它适合在意数据不外流、又不想为一条通知去接第三方云服务的人:CI 挂了、磁盘快满、脚本跑完,一条 curl 过去,网页和手机客户端同时响。

2. 架构与目录:Go 后端、React 前端和 WebSocket 通道怎么拼

先把整体骨架捋清楚,后面不管是跑起来还是改代码,心里都有张地图。这套服务的心智模型其实很小:一个 HTTP 入口负责收消息和管配置,一个内存里的广播中心负责把消息分发给所有活着的连接,一个 SQLite 文件负责兜住历史,前端只做两件事——订阅和渲染。

2.1 一条消息从 API 到浏览器的完整链路

拿一条「构建完成」的通知走一遍,链路是这样的:

  1. 客户端POST /message?token=<应用令牌>,带上 title、message、priority 三个字段;
  2. 路由层先过认证中间件,把令牌换成对应的应用实体,拿到这个应用属于哪个用户;
  3. handler 组装消息结构体,写进数据库,拿到自增 ID 和时间戳;
  4. 写库成功后,把这条消息投递到广播中心的 channel 里,注意这里是非阻塞投递;
  5. 广播中心把消息复制给每个在线连接各自的发送缓冲;
  6. 每个连接有独立的写协程,从自己的缓冲里取消息,序列化成 JSON 写进 WebSocket 帧;
  7. React 端onmessage收到后追加到列表头部,同时刷新未读数。

这条链路上有三个地方最容易出问题,我在实际部署时都撞过:第 4 步如果是阻塞投递,一个卡住的客户端能把整个 API 拖死;第 5 步如果缓冲太小,突发消息会丢;第 6 步如果没有写超时,半死的 TCP 连接会一直占着 goroutine。

阶段关键动作常见失败点
接收认证中间件解析令牌令牌过期、应用被删
落库插入消息表,回填 IDSQLite 写锁冲突
广播非阻塞投递到 hub慢客户端导致丢弃
推送每连接写协程发帧反代不转发 Upgrade 头
渲染前端插入列表、去重重连后重复消费

提示:理解这条链路的价值在于,任何「消息没收到」的问题,都能按这五步二分定位,而不是一上来就怀疑代码。

2.2 目录结构与模块职责

项目大致分两半,后端是典型的 Go 分层结构,前端是标准的 React 单页应用,构建产物用embed打进二进制。这是我按这套服务的设计思路还原出来的目录形态,实际拿到手基本大同小异:

. ├── app/ # Go 后端 │ ├── api/ # 路由注册与各个 handler │ │ ├── message.go # 收发消息 │ │ ├── application.go # 应用管理 │ │ ├── client.go # 客户端管理 │ │ └── user.go # 登录与用户 │ ├── auth/ # 令牌解析、JWT 签发与校验 │ ├── database/ # 存储接口与 SQL 实现 │ │ ├── sqlite/ │ │ └── mysql/ │ ├── model/ # Message / Application / Client / User │ ├── router/ # 中间件链、静态资源挂载 │ ├── websocket/ # hub、连接、读写协程 │ └── config/ # 配置加载与默认值 ├── ui/ # React 前端 │ ├── src/ │ │ ├── api/ # 与后端对齐的请求封装 │ │ ├── components/ # 消息卡片、列表、表单 │ │ └── store/ # 状态管理 │ └── build/ # 构建产物,编译时嵌入 ├── docs/ ├── Makefile └── config.example.yml

websocket目录是整套服务里最值得读的部分,它只做一件事:管理一个 hub,hub 里维护连接集合,对外暴露注册、注销、广播三个方法。database目录用接口抽象了存储,SQLite 和 MySQL 各一套实现,好处是测试时可以塞内存实现进去。ui/build被打进二进制这点很关键——部署时不用再单独跑一个 nginx 托管静态文件,一个文件搞定。

2.3 技术选型:为什么是单二进制加 WebSocket

Go 的编译产物是静态链接的单文件,交叉编译到 ARM 小机器毫无压力,对自托管场景几乎是必选项。默认数据库选 SQLite 也很务实:绝大多数人就是给自己用,消息量一天几百条,SQLite 完全扛得住,还省掉了额外跑一个数据库容器的麻烦;等真有压力了,改配置切到 MySQL 或 Postgres 就行,存储层接口已经把差异吃掉了。

WebSocket 而不是 SSE 或者轮询,主要是双向考虑。虽然消息推送方向主要是服务端到客户端,但客户端连上之后要能即时收到「应用被删除」「令牌失效」这类控制事件,长连接天然合适。而且浏览器的 WebSocket 实现足够简单,断线重连逻辑自己写五十行就够了。

前端选 React 加 TypeScript,配合一个轻量状态管理,好处是组件复用和类型约束。这个项目的 UI 算不上复杂,消息列表、应用管理、客户端管理三个页面,但「时尚的 Web UI」这个定位说明它在交互上花了心思:暗色主题、消息卡片按优先级着色、未读数在标题栏跳动,这些细节对日常使用的体验提升比功能本身还大。

3. 跑起来:从零到收到第一条推送的完整步骤

这一章的目标很明确:让你在十分钟内看到一个能收消息的页面。我会按「先跑起来、再改配置、最后调接口」的顺序走,中间穿插几个必须提前知道的参数含义。

3.1 两种起法:裸二进制与容器

最省事的是容器,一条命令搞定,数据卷挂出来就不怕重启丢消息:

# 起容器,把数据和配置都挂到宿主机 docker run -d \ --name push-server \ -p 8080:80 \ -v /opt/push/data:/app/data \ -v /opt/push/config.yml:/etc/push/config.yml \ --restart unless-stopped \ push-server:latest

这里有几个参数值得说清楚。-p 8080:80是端口映射,容器内部默认监听 80,宿主机换到 8080 避免和已有服务打架。-v /opt/push/data:/app/data是数据卷,SQLite 的库文件、上传的附件都在这,这个目录丢了就等于所有历史消息没了。--restart unless-stopped让机器重启后服务自动回来,自托管场景基本必加。

如果不想用容器,直接跑二进制也行,适合资源紧张的小机器:

# 下载对应架构的发行包,解压后直接执行 tar -xzf push-server-linux-amd64.tar.gz cd push-server chmod +x ./push-server ./push-server \ -config ./config.yml \ -port 8080 \ -data ./data

-config指配置文件路径,-port覆盖监听端口,-data指数据目录。命令行参数的优先级高于配置文件,调试时用命令行临时改端口很方便,不用去动配置文件。

3.2 配置文件逐项拆开看

配置文件决定了部署形态,值得逐项过:

server: listenaddr: "0.0.0.0" # 监听地址,容器里必须是 0.0.0.0 port: 80 # 容器内端口 ssl: enabled: false # TLS 交给反代终结,这里关掉 cors: alloworigins: # 允许跨域的前端地址 - "https://push.example.internal" database: dialect: sqlite3 # sqlite3 / mysql / postgres connection: data/push.db # SQLite 是文件路径,MySQL 是 DSN maxopenconnections: 10 # 连接池上限 defaultuser: name: admin # 首次启动自动创建的管理员 pass: change-me-now # 务必第一次登录后改掉 registration: enabled: false # 关闭公开注册,只允许管理员建号

配置里最容易翻车的是listenaddr。在容器里写127.0.0.1,端口映射就是死的,外面怎么连都连不上,我第一次部署在这上面耗了半小时。ssl.enabled一般设 false,TLS 交给前面那层反向代理做,证书续期也省事。registration.enabled建议关掉,公网暴露的服务开着公开注册等于给自己挖坑。

参数作用建议值
server.listenaddr监听网卡容器内0.0.0.0
server.port服务端口与容器映射对齐
database.dialect存储引擎个人用sqlite3
database.maxopenconnections连接池上限SQLite 保持 1~10
defaultuser.pass初始管理员密码部署后立刻改
registration.enabled是否开放注册false

3.3 用 curl 打通第一条消息

服务起来之后,先登录拿用户令牌,这一步会返回一个 JWT,后续管理操作都靠它:

# 用默认管理员登录,拿用户令牌 USER_TOKEN=$(curl -s -X POST http://127.0.0.1:8080/user/login \ -H 'Content-Type: application/json' \ -d '{"name":"admin","pass":"change-me-now"}' | jq -r .token) echo "用户令牌: ${USER_TOKEN:0:20}..."

jq -r .token是从 JSON 响应里抠字段,没装 jq 的话手动复制也行。这个令牌有有效期,过期后重新登录即可。

接着建一个「应用」。应用是消息的归口,一个应用对应一个令牌,你可以给 CI 建一个、给监控建一个,出问题好排查是谁发的:

# 创建一个应用,返回的 token 就是发消息用的凭证 APP_TOKEN=$(curl -s -X POST http://127.0.0.1:8080/application \ -H "X-User-Token: ${USER_TOKEN}" \ -H 'Content-Type: application/json' \ -d '{"name":"ci-bot","description":"构建与部署通知"}' | jq -r .token) echo "应用令牌: ${APP_TOKEN}"

拿到应用令牌就能发消息了,用表单方式最直观,中文不用自己处理编码:

# 发一条消息,priority 决定前端展示的颜色和是否触发系统通知 curl -s -X POST "http://127.0.0.1:8080/message" \ -H "X-App-Token: ${APP_TOKEN}" \ -F "title=构建完成" \ -F "message=分支 main 耗时 3m12s,产物已上传" \ -F "priority=5"

title是消息标题,前端列表里加粗显示;message是正文,支持 Markdown;priority取值 0 到 10,低于 3 的一般静默处理,7 以上前端会标红并触发浏览器通知。这个分级设计用久了会觉得很顺手——不是所有通知都值得打断你。

3.4 浏览器端的接入与 WebSocket 握手

打开 Web UI,登录后前端立刻发起一条长连接,形如wss://你的域名/stream?token=<客户端令牌>。注意这里用的是客户端令牌而不是应用令牌,这是两条完全不同的认证路径:应用令牌只能发消息,客户端令牌只能收消息,职责分开。

握手成功后,服务端会先补发最近的历史消息(默认按时间倒序取一页),然后进入实时推送状态。前端在onopen里把状态标记为已连接,在onclose里启动重连计时器,这部分逻辑后面第 4 章会展开。

4. 核心代码拆解:数据模型、令牌鉴权与广播中心

这一章挑三个最要害的部分:存储模型决定了你能查什么,令牌鉴权决定了谁能干什么,广播中心决定了消息能不能稳定送到。三段代码看完,这套服务的黑匣子基本就打开了。

4.1 数据模型与存储层

消息表的设计很朴素,但几个字段的取舍有讲究:

-- 消息主表,id 自增保证顺序,application_id 用于按应用过滤 CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id INTEGER NOT NULL, message TEXT NOT NULL, title TEXT, priority INTEGER NOT NULL DEFAULT 0, extras TEXT, -- JSON 字符串,放 client::display 之类的元信息 date DATETIME NOT NULL, -- 服务端生成,不信任客户端时间 FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE ); -- 时间倒序查询是最高频的操作,这个索引必须有 CREATE INDEX idx_messages_application_date ON messages (application_id, date DESC);

date由服务端生成这点很重要,客户端传的时间不可信,多设备时钟漂移会让你排序全乱。extras用 JSON 字符串存,是为了将来加字段不用改表结构,比如前端渲染需要的图标地址、点击跳转的 URL 都塞这里。ON DELETE CASCADE保证删应用时消息一起清掉,不然数据库会越滚越大。

对应的 Go 结构体大致长这样:

// model/message.go type Message struct { ID uint `json:"id"` ApplicationID uint `json:"applicationid"` Message string `json:"message"` Title string `json:"title"` Priority int `json:"priority"` Extras Extras `json:"extras"` // 自定义类型,实现 Scanner/Valuer Date time.Time `json:"date"` } // Extras 用 map 存任意元信息,读写时序列化成 JSON 字符串 type Extras map[string]interface{} func (e Extras) Value() (driver.Value, error) { b, err := json.Marshal(e) return string(b), err }

结构体字段名和 JSON 标签的对应关系要留意,前端拿到的字段名是标签里那个,改标签等于改接口契约,别随手改。Extras实现了driver.Valuer和sql.Scanner两个接口,读写数据库时自动在 map 和字符串之间转换,业务代码里就不用关心序列化细节了。

4.2 两级令牌:应用令牌与客户端令牌

这套服务的权限模型是两层:用户令牌管配置,应用令牌管发送,客户端令牌管接收。三个令牌的校验逻辑走同一个中间件,靠请求路径和请求头区分:

// auth/middleware.go func TokenAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 优先从请求头取,兼容部分客户端只能放 query 的情况 token := r.Header.Get("X-App-Token") if token == "" { token = r.URL.Query().Get("token") } if token == "" { http.Error(w, "missing token", http.StatusUnauthorized) return } // 令牌以 A 开头表示应用令牌,C 开头表示客户端令牌 scope := "app" if strings.HasPrefix(token, "C") { scope = "client" } // 查库校验并解析出对应实体,失败直接短路 entity, err := store.ResolveToken(r.Context(), scope, token) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) return } // 把实体塞进 context,下游 handler 直接取,避免重复查库 ctx := context.WithValue(r.Context(), tokenKey{}, entity) next.ServeHTTP(w, r.WithContext(ctx)) }) }

这段代码的关键在最后三行:认证结果放进context,下游 handler 就不用再查一次库,一次请求只查一次令牌,量大了差别很明显。scope的区分方式看着简陋,但很实用——令牌有固定前缀,看一眼就知道该查哪张表,省掉了「先查应用表再查客户端表」的两次 IO。

注意:令牌一定要用随机数生成而不是时间戳或自增 ID 拼接,否则等于把权限送人。常见做法是 32 字节 crypto/rand 转成 URL 安全编码。

4.3 Hub:连接注册、广播与慢客户端剔除

广播中心是并发正确性最容易出问题的地方,核心就三件事:注册、注销、广播。我见过不少实现死在「阻塞发送」上:

// websocket/hub.go type Hub struct { mu sync.RWMutex clients map[*Client]struct{} } type Client struct { send chan []byte // 每个连接独立的发送缓冲 conn *websocket.Conn } // 广播:复制一份订阅者列表,逐个非阻塞投递 func (h *Hub) Broadcast(msg []byte) { h.mu.RLock() targets := make([]*Client, 0, len(h.clients)) for c := range h.clients { targets = append(targets, c) } h.mu.RUnlock() for _, c := range targets { select { case c.send <- msg: default: // 缓冲区满,说明这个客户端收不动了,直接踢掉 h.Remove(c) c.conn.Close() } } } // 每个连接一个写协程,从自己的缓冲里取消息发帧 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, nil) return } c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-ticker.C: // 定期发 ping,探活半死连接 c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }

Broadcast里先加读锁复制一份列表再释放,是为了避免在遍历过程中调用Remove触发写锁导致的死锁,这是并发编程里很经典的血泪经验。select加default保证投递永不阻塞,缓冲区满就直接断开连接——宁可丢一个收不动的客户端,也不能让整个广播卡住。

writePump里的SetWriteDeadline是另一个容易漏的点。没有写超时,一个网络已经断但 TCP 还没感知的连接会永远阻塞在WriteMessage上,goroutine 越积越多,最后 OOM。30 秒一次的 ping 就是探活用的,写失败就退出,由 defer 清理资源。

4.4 React 端的订阅与重连

前端这块最容易写错的是重连,很多人图省事用setInterval每三秒重连一次,网络抖动时会疯狂刷请求:

// ui/src/hooks/useMessageStream.js export function useMessageStream(token, onMessage) { const retryRef = useRef(0); useEffect(() => { let ws; let timer; let closed = false; const connect = () => { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/stream?token=${token}`); ws.onopen = () => { retryRef.current = 0; // 连上就重置退避计数 }; ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); onMessage(msg); // 交给上层追加到列表 }; ws.onclose = () => { if (closed) return; // 指数退避:1s, 2s, 4s... 最多 30s,避免抖动时打爆服务端 const delay = Math.min(1000 * 2 ** retryRef.current, 30000); retryRef.current += 1; timer = setTimeout(connect, delay); }; }; connect(); return () => { closed = true; clearTimeout(timer); ws && ws.close(); }; }, [token, onMessage]); }

退避计数在onopen里清零是关键:连接成功后重新计算,下次断线还是从 1 秒开始,不会因为历史失败次数一直等 30 秒。closed标志防止组件卸载后重连定时器还在跑,这个坑在路由切换频繁的页面里特别常见,表现出来就是后台不断有请求但界面上什么都没有。

上层拿到消息后要做去重,因为重连补发历史消息时可能和内存里已有的重叠,用消息 ID 做 key 塞进 Set 判断一下就行,成本极低。

5. 避坑与常见问题:那些让我半夜爬起来看日志的问题

部署这类服务,出问题的往往不是业务逻辑,而是网络层和配置层。下面五条都是我实际踩过或者帮别人排查过的,按「现象 → 原因 → 解决」写清楚,遇到了直接对号入座。

5.1 反向代理下 WebSocket 一秒断一次

现象:页面打开,连接状态反复在「已连接」和「重连中」之间跳,浏览器控制台每隔几秒就报一次连接关闭,服务端日志看不到任何报错。

原因:反向代理默认按普通 HTTP 请求处理,没有转发Upgrade和Connection这两个头,WebSocket 握手被降级成普通请求,服务端返回 101 失败后连接立刻关闭。有些代理还会在 60 秒空闲后主动断掉长连接,表现为不定时掉线。

解决:代理配置里显式开启协议升级,并把读超时调大。Nginx 大致这样写:

location /stream { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 关键:透传协议升级头 proxy_set_header Connection "upgrade"; # 关键:连接类型改为 upgrade proxy_set_header Host $host; proxy_read_timeout 3600s; # 长连接别被空闲超时切断 proxy_send_timeout 3600s; }

改完记得nginx -t && nginx -s reload。如果前置还有一层云厂商的负载均衡,那边也有连接空闲超时,通常默认 60 秒,需要一起调大,否则改了 Nginx 照样断。

5.2 消息偶发丢失,客户端毫无察觉

现象:批量发十条消息,前端只收到七八条,没有报错,也没有断线记录。

原因:广播时用的是非阻塞投递,某个客户端的发送缓冲满了,select走到default分支,这条消息对这个连接就直接丢了。缓冲区满通常不是网络慢,而是前端主线程卡住导致onmessage处理不过来,或者浏览器标签页被切到后台被限流。

解决:先分清是哪种。如果是单条大消息(比如带长 Markdown)超过了 WebSocket 帧大小限制,把服务端的读写缓冲调大;如果是高频消息打满缓冲,把单连接缓冲从 16 提到 64,同时在前端做批量渲染而不是每条都触发一次 React 重渲染。真正要保证不丢的场景,前端重连后应该主动拉一次最近的历史消息做补偿,而不是只依赖推送。

5.3 应用令牌被拿来查管理员接口

现象:某次排查发现,用一个只该发消息的应用令牌,居然能调通「列出所有用户」的接口。

原因:认证中间件只校验了令牌有效性,没有校验令牌的 scope 和当前路由是否匹配。发消息的接口和用户管理接口走了同一套中间件,等于把权限边界抹平了。

解决:把 scope 校验前置到路由注册那一层,管理类路由统一挂要求用户令牌的中间件,消息类路由只接受应用令牌。别指望在业务 handler 里逐个判断,漏一个就是一个洞。改完之后写一条集成测试固定住这个行为,防回归。

5.4 SQLite 的写锁把请求卡死

现象:日常很快,一到定时任务集中发消息的时段(比如整点),接口响应从几十毫秒涨到好几秒,日志里偶尔冒出database is locked。

原因:SQLite 同一时刻只允许一个写事务,连接池开了多个写连接时会互相等锁,默认的 busy timeout 又很短,等不到就报错。整点批量发消息正好撞上这个场景。

解决:把连接池的写连接限制到 1,读操作走单独的只读连接,再给 busy timeout 设成 5 秒。写法上把连接串改成file:data/push.db?_journal_mode=WAL&_busy_timeout=5000,WAL 模式下读写不互斥,日常卡顿会明显好转。数据量真的上来了,直接切 MySQL,长痛不如短痛。

5.5 容器重建后历史消息全没了

现象:docker compose down再up,所有消息清空,账号也得重新建。

原因:数据目录没有挂出来,或者挂的是data/相对路径但容器的WORKDIR不在预期位置,SQLite 文件写进了容器内部的可写层,容器一删就没了。

解决:容器启动一律用绝对路径挂卷,并且启动后进容器确认一下库文件确实在挂载点里:

# 确认库文件在挂载点下,而不是容器内部 docker exec push-server ls -lh /app/data/ # 顺便看一下配置里的路径是不是和挂载点一致 docker exec push-server cat /etc/push/config.yml | grep connection

这两条命令花不了十秒钟,能省掉一次数据丢失的后悔药。挂载点权限也要留意,容器里跑的不是 root 时,宿主目录的属主得对得上,否则表现为能启动但一写库就报 permission denied。

6. 二次开发:给广播中心挂一个外部转发通道

最实用的改造是加一条外部通道:本地收到的消息,除了推给浏览器,再转发一份给某个外部 Webhook,这样能和企业内部的群机器人打通。改动量很小,因为 hub 的广播点只有一个。

思路是给 hub 加一个订阅者接口,而不是在 handler 里硬编码转发逻辑,这样将来加第二条通道不用再改 handler:

// websocket/hub.go 追加订阅者机制 type Subscriber interface { Deliver(msg model.Message) } func (h *Hub) Subscribe(s Subscriber) { h.mu.Lock() defer h.mu.Unlock() h.subs = append(h.subs, s) } // 在收消息的 handler 里,落库成功后先广播给连接,再通知订阅者 func onMessageCreated(msg model.Message) { payload, _ := json.Marshal(msg) hub.Broadcast(payload) // 订阅者用独立 goroutine 投递,慢也不能拖累主链路 for _, s := range hub.subs { go s.Deliver(msg) } }

这里的取舍是:订阅者投递放在 goroutine 里异步做,失败了就记日志,不影响消息入库和实时推送。如果你要求「转发失败必须重试」,那就要引入队列,改造量会上去一个量级,取决于业务对可靠性的要求。

具体的转发实现大约三十行,核心是构造请求和限流:

// forward/webhook.go type Webhook struct { url string client *http.Client limiter *rate.Limiter // 防止下游抖动时把请求打爆 } func (w *Webhook) Deliver(msg model.Message) { if !w.limiter.Allow() { log.Printf("webhook 限流丢弃, msgID=%d", msg.ID) return } body, _ := json.Marshal(map[string]string{ "title": msg.Title, "text": msg.Message, }) req, _ := http.NewRequest("POST", w.url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := w.client.Do(req.WithContext(ctx)) if err != nil { log.Printf("webhook 投递失败, msgID=%d, err=%v", msg.ID, err) return } defer resp.Body.Close() if resp.StatusCode >= 300 { log.Printf("webhook 非预期状态, msgID=%d, code=%d", msg.ID, resp.StatusCode) } }

验证方法要分层做,别只测单一场景:

验证项做法期望结果
正常投递发一条 priority=5 的消息外部通道秒级收到
下游超时把 webhook 地址指向一个不响应的端口5 秒后记错误日志,主链路不受影响
下游 5xx故意返回 500日志里有 code=500,消息仍入库
高频冲击循环发 200 条触发限流,日志有丢弃记录,无 goroutine 堆积
重启恢复重启服务后再发订阅者正常注册,历史消息不重复转发

最后一项容易被忽略:如果转发逻辑写在启动流程里但注册晚于消息接收,重启后的头几条消息会漏。我现在的习惯是,任何新增的订阅者都必须在服务就绪的同一阶段注册完毕,并且在启动日志里打一行「subscriber registered: webhook」,这样出了问题一眼能看出是没注册还是没投递。

从那以后我每次给这套服务加副通道,都强制走一遍上表的五项验证,尤其是超时和重启这两条,它们不写在文档里但一定会发生。希望帮到你。

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

返回列表