go-websocket
按 receiver 精准投递的 WebSocket 聊天服务,不广播
技术栈
- Go
- Gin
- gorilla/websocket
- 内嵌静态资源
- Bootstrap 5
概览
go-websocket 是一个用 Go + Gin 写的 WebSocket 定向聊天服务:浏览器带上 uid 连上来,服务端按消息里的 receiver 精准投递给指定用户,而不是广播。页面模板、静态资源与配置模板全部通过 //go:embed 内嵌在二进制里,clone 下来 go run . 就能跑,运行时不依赖任何外部文件。
定位是源码实践 —— 代码分层、并发模型、配置加载与测试都控制在可以直接读、直接改的规模,不引入额外的构建或运行时依赖。
功能
- 定向投递:按
receiver只发给指定 uid,不广播;接收人不在线会给发送方回一条明确的 error 回执 - 身份不可伪造:
sender由服务端按 TCP 连接填写,客户端传什么都不作数 - uid 校验前置:只允许字母、数字、下划线、连字符,长度 1~64;非法 uid 在升级握手之前就返回
400+ JSON 原因 - 并发安全:连接中心在单个 goroutine 里维护 uid → 连接 映射(map 无锁),每个连接的写操作全部收敛到它唯一的写循环
- 心跳与超时:服务端按周期发 ping,等不到 pong(或读超时)就断开,死连接不会堆积
- 慢客户端保护:每个连接一个待发送队列,队列满即判定「对端不读了」并断开,慢连接拖不住 Hub
- 重复 uid 顶号 + 优雅退出:同一个 uid 再次连接时替换旧连接;
Ctrl+C/SIGTERM先停止监听,再统一断开全部 WebSocket 后退出 - 前端体验:亮色 / 跟随系统 / 暗色三态主题(首帧即应用)、断线指数退避自动重连、点击在线用户即可选中接收人、uid 本地持久化
快速开始
需要 Go 1.23 及以上。
# 直接跑(推荐,改完代码重跑即可,不产生文件)
go run .
# 或者编译后运行
go build -o go-websocket . && ./go-websocket
启动后会打印版本、配置来源与可访问地址:
go-websocket dev(release)
配置来源: 内嵌 env.ini.release
本机访问: http://127.0.0.1:8090
局域网访问: http://192.168.1.10:8090
WebSocket 与上述地址同端口,路径 /ws?uid=你的uid
开两个浏览器窗口(或一个正常窗口 + 一个隐私窗口),各拿到一个 uid,互相填写对方 uid 就能发消息;也可以直接在左侧「在线用户」里点对方。
配置
配置文件是 INI 格式,来源优先级从高到低:
-config参数 /GWS_CONFIG环境变量指定的文件- 工作目录下的
env.ini - 工作目录下的
config/env.ini - 二进制内嵌的
env.ini.<GWS_ENV>(默认release)
| 配置项 | 默认值 | 说明 |
|---|---|---|
env_mode |
release |
debug / release / test,非法取值按 release 处理 |
server.http_port |
8090 |
监听端口 |
server.shutdown_timeout |
10s |
优雅退出的最长等待时间 |
websocket.max_message_size |
4096 |
单条消息的最大字节数,超过会被拒绝 |
websocket.ping_period |
30s |
服务端发 ping 的间隔;0 表示关闭心跳 |
websocket.pong_wait |
60s |
等待 pong 的超时(同时作为读超时) |
websocket.send_queue |
64 |
每个连接的待发送队列长度,队列满即断开 |
websocket.allow_all_origins |
false |
是否允许任意来源连接;默认只允许同源 |
web/下的页面与静态资源是//go:embed打进二进制的,改完必须重新go run .或重启进程,只刷新浏览器不会生效。
接口
| 方法与路径 | 参数 | 说明 |
|---|---|---|
GET / |
- | 聊天页面 |
GET /ws |
uid(必填) |
升级为 WebSocket 连接;uid 非法返回 400 |
GET /api/online |
- | 在线用户列表,{"count":N,"users":["alice","bob"]} |
WebSocket 报文是 JSON。上行只有两个字段有意义,sender 由服务端覆盖:
{ "receiver": "bob", "content": "你好" }
下行(服务端 → 接收方):
{ "type": "chat", "sender": "alice", "receiver": "bob", "content": "你好", "time": "2026-10-01 13:43:43" }
服务端给触发者的提示(接收人离线、报文非法等):
{ "type": "error", "content": "接收人 nobody 不在线", "time": "2026-10-01 13:43:43" }
实现要点
- 连接中心单 goroutine 管 map:
Hub.clients只在run()这一个 goroutine 里改动,外部一律通过 channel(register/unregister/deliver/online)投递,因此 map 不需要加锁 - 连接只有一个写方:gorilla/websocket 不支持并发写同一个连接,每个连接只有
writePump会写,其他 goroutine 只投递 - 不用「关闭 channel」通知退出:
send关闭后任何一次投递都会 panic,而投递方可能来自 Hub 也可能来自读循环,时序无法保证;改用sync.Once+close(done),最多白投一次 - 慢客户端直接断开:
send队列满说明对端已经不读了,继续堆消息只会连累别人,判定后立即断开并回告发送方 - uid 校验前置:校验放在
Upgrade之前,用400+ JSON 说明原因;升级之后再报错就只能靠关闭帧,前端拿不到任何信息 sender服务端覆盖:只信连接身份,客户端传的sender一律丢弃,避免冒充- 读限制:
SetReadLimit限制单条消息大小,否则一个客户端就能用超大帧把内存吃光 - 心跳走 gorilla 的 ping/pong:
SetPongHandler里续读截止时间,死连接最多pong_wait就被清理 - HTTP 超时只限制读请求头:WebSocket 是长连接,设了
ReadTimeout/WriteTimeout会把正常会话和心跳一起掐断 - 优雅退出分成两步:先
srv.Shutdown停止接收新连接,再h.Close()断开还挂着的 WebSocket —— 升级过的连接不在http.Server的管理范围里 - 前端不拼 HTML:所有用户可控文本都通过 jQuery 的
.text()写进 DOM,uid 与消息内容完全由对端控制,拼进innerHTML就是现成的 XSS
已知局限
- 没有鉴权 —— 任何人只要知道 uid 就能连上来,uid 也不做占用校验(同 uid 直接顶号),默认只适合本机或内网
- 没有持久化 —— 消息只做转发,离线不补发、历史不落库
- 没有 HTTPS / WSS —— 真正的 TLS 需要由前置 Nginx / Caddy 之类的反向代理终结
- 单进程 —— 连接中心在内存里,多实例部署时不同进程的 uid 互相看不见,横向扩展得引入 Redis 之类的跨进程投递
- 没有分组 / 广播 —— 只支持点对点,没有房间、群聊与离线消息
- 在线列表是轮询的 —— 前端每 5 秒拉一次
/api/online,没有做成上行/下行通知 - 前端仍是 jQuery + 服务端模板,没有做前后端分离与构建链路
链接
源码:GitHub
最近更新