简介这是一份面向Go语言初学者与网络编程入门者的控制台局域网聊天程序源码包围绕局域网即时通信场景提供可运行的客户端与服务端基础实现帮助读者理解Socket连接、消息收发、并发处理与用户连接管理等核心环节。压缩包共6个文件以4个Go源码文件为主并配有服务端、客户端相关文件整体约3.72MB体量轻巧便于直接阅读、编译运行和按需改造。程序已具备基本聊天功能代码中可看到服务端入口、客户端交互、用户管理与启动流程等模块适合梳理多客户端接入、消息广播和goroutine协作的实现思路读者也可在此基础上扩展私聊、群聊、昵称管理、历史消息或文件传输等能力用作课程设计、实验演示和自学练手项目均较合适。已有222人学习下载适合希望从可运行的小项目切入逐步掌握Go网络编程与并发模型的读者参考。1. 从零搭一个 Go 局域网聊天程序为什么控制台版反而是最好的入门靶子先说个反直觉的结论想真正吃透 Go 的网络编程别一上来就啃 IM 后端先写一个跑不满 300 行的 go 局域网聊天程序。上周在一间没有外网的机房里某实验室的旧机器连包管理器都装不上A 同学只是想让六台机器之间能互发几行文本——不装中间件、不带前端、单个二进制拷过去就能跑。这种场景里一个纯控制台的聊天程序半小时能落地而一套带 Web 界面的方案光调环境就要一天。这份资源的定位就在这儿它不是产品是一套能读懂、能改、能拆的最小可运行骨架服务端监听 TCP、客户端接入、房间内广播全部基于标准库的 net 与 goroutine零第三方依赖。适合刚学完 Go 语法想找个真实项目练手的人也适合在隔离环境里做临时通信和调试的运维、嵌入式同学。2. 协议与选型4 字节长度前缀 1 字节类型为什么不用 HTTP 轮询动手之前必须先把传输层想清楚否则写到一半发现方向错了返工的成本远大于设计花的这半小时。这一章讲三件事为什么是 TCP、消息帧长什么样、三份代码的职责怎么切。这三件事定下来后面的服务端和客户端只是把它们翻译成 Go 而已。2.1 TCP 长连接、UDP 广播、HTTP 轮询三者的取舍局域网聊天的本质是「多对多的低延迟文本分发」把这个需求拆开看消息小、频率不固定、要求到达顺序不能乱、参与方数量在十级别。按这四条去筛传输方式就很清楚了。UDP 广播看起来最省事一个包发出去全网段都能收到连服务端都不用写。但它不保证送达、不保证顺序跨网段还会被路由器拦掉更要命的是没有「谁在线」的概念——你永远不知道房间里还有几个人。真实项目里用 UDP 做局域发现比如启动时广播一下找服务端地址是合理的用它传聊天内容就是给自己找麻烦。HTTP 轮询的问题在延迟和连接开销。1 秒轮询一次意味着平均 500ms 的延迟十个人同时轮询就是每秒十次请求而且每次都要重新建连或者维护 keep-alive。控制台程序不需要这套东西它要的是「我说一句对面立刻出现一行字」。TCP 长连接是这类场景的标准答案一次握手之后双方随时可以主动推数据字节流保证顺序连接本身天然表达「在线」状态。代价是要自己处理分包和粘包——这正是下一节要解决的事。传输方式延迟在线状态实现复杂度适用判断UDP 广播最低无低只适合做服务发现不适合传消息HTTP 轮询500ms 起靠心跳表推断中需要对接浏览器时再考虑TCP 长连接毫秒级连接即在中控制台聊天、内网工具的首选2.2 消息帧格式为什么必须是长度前缀而不是换行符TCP 是字节流不是消息流。你 Write 了两次对面可能一次 Read 全拿到粘包也可能只拿到半条半包。新手最容易犯的错是「我发一行对面按行读」在本地回环测试永远正常一到真实局域网就开始错位这种玄学问题排查起来能耗掉一整天。解决办法是给每条消息加一个自描述的头。这里选的是最经典也最好调的格式---------------------------------- | 4 字节 | 1 字节 | N 字节 | | 长度 N | 类型 | 载荷UTF-8 | ----------------------------------长度字段只统计载荷长度不含头本身用大端序写入。大端序BigEndian是网络协议里的惯例选它不为性能只为跟大多数抓包工具和文档对得上将来你用 Wireshark 看的时候不用在脑子里做字节翻转。类型字段用一个字节枚举够用且省空间类型值名称方向载荷内容0x01MsgJoin客户端 → 服务端昵称字符串0x02MsgChat双向聊天正文0x03MsgQuit客户端 → 服务端空0x04MsgSystem服务端 → 客户端系统提示如「XX 加入了」0x05MsgPing双向空仅用于保活对应到代码这一层单独放一个包服务端和客户端都 import 它协议改动只改一处// protocol/protocol.go package protocol import ( encoding/binary errors io ) const ( MsgJoin byte 0x01 MsgChat byte 0x02 MsgQuit byte 0x03 MsgSystem byte 0x04 MsgPing byte 0x05 // MaxPayload 限制单条消息 4KB防止对端发个超长长度字段把内存打爆 MaxPayload 4 10 headerLen 5 ) type Message struct { Type byte Payload []byte } // Encode 把头和数据拼成一整块调用方一次 Write 发出去减少系统调用 func (m *Message) Encode() []byte { buf : make([]byte, headerLenlen(m.Payload)) binary.BigEndian.PutUint32(buf[0:4], uint32(len(m.Payload))) buf[4] m.Type copy(buf[headerLen:], m.Payload) return buf } // ReadMessage 阻塞读一整条消息半包由 io.ReadFull 负责补齐 func ReadMessage(r io.Reader) (*Message, error) { var head [headerLen]byte if _, err : io.ReadFull(r, head[:]); err ! nil { return nil, err } n : binary.BigEndian.Uint32(head[0:4]) if n MaxPayload { return nil, errors.New(protocol: payload exceeds limit) } payload : make([]byte, n) if _, err : io.ReadFull(r, payload); err ! nil { return nil, err } return Message{Type: head[4], Payload: payload}, nil } // Frame 是给广播用的便捷函数把一条消息编码成可复用的字节切片 func Frame(t byte, text string) []byte { return (Message{Type: t, Payload: []byte(text)}).Encode() }几个参数值得单独说。MaxPayload设成 4KB 是个经验值聊天文本远用不到这么多但留了余量给将来的文件路径、JSON 结构化消息同时它是一道安全阀没有这个上限恶意或出错的对端只要发一个0xFFFFFFFF的长度字段接收方就会尝试分配 4GB 内存然后 OOM。io.ReadFull是整个协议的核心它保证要么读满指定字节数要么返回错误半包问题在这一个函数里就被消化掉了。2.3 三份代码的职责边界protocol / server / client目录结构尽量扁平让第一次打开项目的人能在三十秒内看懂哪个文件干什么lanchat/ ├── go.mod ├── protocol/protocol.go # 帧编解码、类型常量两端共用 ├── server/main.go # 监听、accept、连接生命周期 ├── server/hub.go # 房间成员表、广播、加锁 └── client/main.go # 连接、读屏、读 stdin、心跳职责划分的原则是「协议不依赖业务业务不重复协议」。protocol 包里不出现任何日志、不打印、不引用 server 的符号server 只关心「谁在线、往谁那儿发字节」不解析聊天内容client 只关心「把用户输入变成帧、把收到的帧变成屏幕输出」。这样切的好处很实际将来想加私聊只动 protocol 的枚举和 server 的分发逻辑客户端一行不用改。go.mod 里 module 名随便起Go 版本写你本机实际装的就行mkdir lanchat cd lanchat go mod init lanchat3. 把代码跑起来监听、接入、广播的完整实现协议定完了接下来是真正能跑的部分。这一章的三段代码按「服务端 → 客户端 → 联调」的顺序给出每段后面都说明它为什么这么写、哪个参数可以改、改错了会怎样。3.1 服务端Hub 管成员每个连接两个 goroutine服务端的心跳只有一个一个map[*Client]struct{}加一把读写锁。所有对这张表的操作都走 Hub 的方法绝不直接在 handler 里碰 map这是后期不出现 concurrent map writes panic 的唯一保障。// server/hub.go package main import ( log sync lanchat/protocol ) type Client struct { name string send chan []byte // 有缓冲缓冲满说明这个客户端已经跟不上 } type Hub struct { mu sync.RWMutex clients map[*Client]struct{} } func NewHub() *Hub { return Hub{clients: make(map[*Client]struct{})} } func (h *Hub) Join(c *Client) { h.mu.Lock() h.clients[c] struct{}{} h.mu.Unlock() h.Broadcast(protocol.Frame(protocol.MsgSystem, c.name 加入了房间)) } func (h *Hub) Leave(c *Client) { h.mu.Lock() if _, ok : h.clients[c]; !ok { h.mu.Unlock() return // 已经被别的路径摘过了直接返回避免重复广播 } delete(h.clients, c) h.mu.Unlock() close(c.send) // 关闭后 writePump 的 range 会退出 if c.name ! { h.Broadcast(protocol.Frame(protocol.MsgSystem, c.name 离开了房间)) } } // Broadcast 先在读锁里投递把跟不上的客户端记下来 // 出了读锁再摘除——在读锁里调 Leave 会二次加锁直接死锁 func (h *Hub) Broadcast(frame []byte) { var slow []*Client h.mu.RLock() for c : range h.clients { select { case c.send - frame: default: slow append(slow, c) } } h.mu.RUnlock() for _, c : range slow { log.Printf(客户端 %s 发送缓冲已满踢出, c.name) h.Leave(c) } }send通道的缓冲大小是关键参数这里用 16。设成 0无缓冲会让广播变成同步阻塞只要有一个客户端网络卡住整个房间的所有人都跟着卡。设成 1024 又会让慢客户端堆积大量过期消息恢复连接后一次性刷屏。16 到 64 之间是比较舒服的区间超过这个量级基本可以判定这个连接已经废了直接踢掉比留着更健康。Leave里那个if _, ok : h.clients[c]; !ok的幂等判断是踩过坑之后加的。同一个连接可能从两条路径触发清理读循环返回错误、广播发现缓冲满。没有这个判断用户会看到两条「离开了房间」人数统计也会算错。主循环负责监听和派发// server/main.go package main import ( flag log net time lanchat/protocol ) const ( readTimeout 90 * time.Second // 超过这个时间没收到任何帧就断开 writeTimeout 10 * time.Second pingPeriod 30 * time.Second sendBufSize 16 ) func main() { addr : flag.String(addr, 0.0.0.0:9000, 监听地址) flag.Parse() ln, err : net.Listen(tcp, *addr) if err ! nil { log.Fatalf(监听 %s 失败: %v, *addr, err) } log.Printf(聊天服务已启动: %s, ln.Addr()) h : NewHub() for { conn, err : ln.Accept() if err ! nil { log.Printf(accept 出错: %v, err) continue } go serve(h, conn) // 一个连接一个 goroutine十级别并发完全够用 } } func serve(h *Hub, conn net.Conn) { c : Client{send: make(chan []byte, sendBufSize)} go c.writePump(conn) defer func() { h.Leave(c) conn.Close() }() conn.SetReadDeadline(time.Now().Add(readTimeout)) for { msg, err : protocol.ReadMessage(conn) if err ! nil { return } // 每收到一帧就续期空闲连接靠客户端的心跳续命 conn.SetReadDeadline(time.Now().Add(readTimeout)) switch msg.Type { case protocol.MsgJoin: c.name string(msg.Payload) if c.name { c.name conn.RemoteAddr().String() } h.Join(c) case protocol.MsgChat: if c.name { continue // 没报昵称就不给发言防刷屏 } h.Broadcast(protocol.Frame(protocol.MsgChat, c.name: string(msg.Payload))) case protocol.MsgPing: // 心跳帧不广播收到即续期什么都不用做 case protocol.MsgQuit: return } } }serve是每个连接的执行体它的结构决定了整个服务端的健壮性先起写 goroutine再用defer保证无论从哪条路径退出都会清理。读循环是一个死循环唯一的出口是ReadMessage返回错误——对端关闭、超时、协议错乱都归到这一条上逻辑简单到不会出错。writePump单独拎出来负责所有写操作// server/main.go 续 func (c *Client) writePump(conn net.Conn) { ticker : time.NewTicker(pingPeriod) defer func() { ticker.Stop() conn.Close() }() for { select { case data, ok : -c.send: if !ok { return // hub 关闭了通道连接可以退场 } conn.SetWriteDeadline(time.Now().Add(writeTimeout)) if _, err : conn.Write(data); err ! nil { return } case -ticker.C: conn.SetWriteDeadline(time.Now().Add(writeTimeout)) if _, err : conn.Write(protocol.Frame(protocol.MsgPing, )); err ! nil { return } } } }读和写分离成两个 goroutine 是 Go 网络程序的标准写法原因很实在Write在对端网络拥塞时会阻塞如果读和写在一个 goroutine 里一个慢客户端就能把接收消息的路径也堵死。分开之后写阻塞只影响这一个客户端读循环照常跑广播照常发。3.2 客户端两个读源两条输出路径客户端比服务端多一个麻烦它有两个「输入源」——网络和标准输入。两者都是阻塞的只能各给一个 goroutine。// client/main.go package main import ( bufio flag log net os time lanchat/protocol ) const pingPeriod 30 * time.Second func main() { server : flag.String(server, 127.0.0.1:9000, 服务端地址 host:port) name : flag.String(name, , 昵称) flag.Parse() if *name { host, _ : os.Hostname() *name host } conn, err : net.DialTimeout(tcp, *server, 5*time.Second) if err ! nil { log.Fatalf(连接 %s 失败: %v, *server, err) } defer conn.Close() // 上线第一帧必须是 join服务端靠它拿昵称 if _, err : conn.Write(protocol.Frame(protocol.MsgJoin, *name)); err ! nil { log.Fatalf(发送加入消息失败: %v, err) } go readLoop(conn) // 收网络 → 屏幕 go pingLoop(conn) // 保活每 30 秒一个 ping // 发标准输入 → 网络主 goroutine 干这个 scanner : bufio.NewScanner(os.Stdin) for scanner.Scan() { line : scanner.Text() if line { continue } if line /quit { conn.Write(protocol.Frame(protocol.MsgQuit, )) return } if _, err : conn.Write(protocol.Frame(protocol.MsgChat, line)); err ! nil { log.Printf(发送失败: %v, err) return } } } func readLoop(conn net.Conn) { for { msg, err : protocol.ReadMessage(conn) if err ! nil { log.Printf(连接已断开: %v, err) os.Exit(0) } switch msg.Type { case protocol.MsgChat, protocol.MsgSystem: os.Stdout.WriteString(string(msg.Payload) \n) case protocol.MsgPing: // 心跳帧不打印 } } } func pingLoop(conn net.Conn) { t : time.NewTicker(pingPeriod) defer t.Stop() for range t.C { if _, err : conn.Write(protocol.Frame(protocol.MsgPing, )); err ! nil { return } } }pingLoop是必须的很多人第一版会漏掉。服务端给读设置了 90 秒超时如果客户端只是挂着不说话用户在看别人聊30 秒后它的连接就会被服务端判死。加了心跳之后客户端即使全程不发言连接也是活的。pingPeriod和服务端的readTimeout要成比例经验是「心跳间隔 ≤ 超时的 1/2」30 秒对 90 秒留了三次容错。/quit这个约定值只是个取巧的做法正式一点应该在协议里加一个命令解析层这里保持简单。3.3 编译、分发与局域网内的连通性验证代码写完联调顺序不能乱。先在同一台机器上用回环地址验证逻辑再去局域网验证网络。# 1. 编译产物是单个静态二进制没有运行时依赖 go build -o chat-server ./server go build -o chat-client ./client # 2. 本机自测一个服务端 两个客户端 ./chat-server -addr 127.0.0.1:9000 ./chat-client -server 127.0.0.1:9000 -name A同学 ./chat-client -server 127.0.0.1:9000 -name B同学 # 3. 真要给别的机器用服务端必须监听 0.0.0.0 ./chat-server -addr 0.0.0.0:9000 # 4. 交叉编译一个 Windows 版本直接拷过去双击 GOOSwindows GOARCHamd64 go build -o chat-client.exe ./client # 5. 端口通不通先看监听状态再谈连接 ss -lntp | grep 9000-addr 0.0.0.0:9000和-addr 127.0.0.1:9000的区别是本项目最常见的「连不上」原因。回环地址只有本机能连局域网里其他机器发过来的 SYN 包根本不会被这个 socket 接住表现就是连接超时。交叉编译那两条环境变量也值得记住GOOS和GOARCH一改产物立刻变成目标平台的原生二进制不用装交叉编译工具链这是 Go 在运维场景里最实用的一点。第 5 步的ss命令先确认监听地址是0.0.0.0:9000还是127.0.0.1:9000这一步能省掉后面八成的排查时间。4. 并发与连接管理别让一个死连接拖垮整个房间能跑通和能长时间跑是两回事。这一章处理三个必然会在真实使用中遇到的问题空闲连接被误杀、并发写 map 崩溃、进程退出时连接没有收尾。4.1 读写超时怎么设才不会误杀正常用户SetReadDeadline是 Go 里最容易被误解的 API 之一。它不是「读一次最多等多久」而是「到这个绝对时间点还没读到数据就返回超时错误」。所以正确用法是每成功读到一帧就重新设一次而不是在循环外面设一次就完事。conn.SetReadDeadline(time.Now().Add(readTimeout)) for { msg, err : protocol.ReadMessage(conn) if err ! nil { return } conn.SetReadDeadline(time.Now().Add(readTimeout)) // 关键每帧续期 // ... 处理 }写成循环外面一次的话用户在连接上待满 90 秒就会被踢无论他有多活跃——这个 bug 的表现是「聊了大约一分半钟突然掉线」看着像玄学其实是超时没续。写侧同理SetWriteDeadline必须每次写之前设。不设的后果是一个客户端把网线拔了内核的发送缓冲区满了之后Write会永久阻塞那个 goroutine 就泄漏了一天下来服务端堆几千个僵尸 goroutine内存慢慢爬。这个症状很隐蔽runtime.NumGoroutine()打个日志就能看出来。超时值的组合参考参数建议值说明readTimeout90s客户端心跳 30s留三次容错writeTimeout10s局域网内正常写入是微秒级10s 已经是宽容值pingPeriod30s不超过 readTimeout 的一半DialTimeout5s客户端连不上要尽快失败别让用户干等4.2 并发安全map 加锁的粒度与死锁的典型形状clients这张表会被至少三个 goroutine 同时碰accept 派生的读循环、广播、写 goroutine 出错后的清理。Go 的 map 不是并发安全的两个 goroutine 同时写会直接fatal error: concurrent map writes注意这个错误不能被 recover进程当场退出。用sync.RWMutex是最朴素也最够用的做法。关键在粒度广播时只需要遍历用读锁增删成员用写锁。上一章的Broadcast里有个容易被忽略的细节——遍历过程中发现某个客户端缓冲满了不能当场调Leave因为Leave内部要拿写锁而当前 goroutine 持有读锁RWMutex 的读锁和写锁互斥直接死锁。正确做法是先把「需要踢掉的人」收集到切片里出了读锁再处理。这个形状在其他地方也会反复出现任何在持锁期间回调外部函数的设计都要先问一句「这个回调会不会再拿同一把锁」。血泪经验是死锁的表现往往是「程序没崩但所有人都收不到消息了」比 panic 难查得多加一个pprof的 goroutine dump 是常规手段。还有一个非阻塞投递的选择select { case c.send - frame: default: // 宁可丢这一条也不能让广播阻塞 }这里的default分支不是偷懒是刻意的。广播是「一对多」的操作它的耗时不应该由最慢的那个接收者决定。丢掉一条消息对这个场景是可接受的卡住整个房间不可接受。4.3 优雅关闭把监听、连接、goroutine 一起收掉控制台程序常常被 CtrlC 打断。默认行为是进程立刻死掉内核回收所有 socket对端收到 RST。少量连接无所谓但如果你在退出前想广播一句「服务下线」或者想把成员列表落盘就必须接信号。ctx, stop : signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { -ctx.Done() log.Println(收到退出信号正在关闭监听) ln.Close() // Close 之后 Accept 会立刻返回错误 }() for { conn, err : ln.Accept() if err ! nil { select { case -ctx.Done(): return // 是我们主动关的正常退出 default: log.Printf(accept 出错: %v, err) continue } } go serve(h, conn) }这段的关键是区分「主动关闭导致的 Accept 错误」和「真的出错了」。ln.Close()会让阻塞中的Accept立即返回use of closed network connection如果不加判断日志里会刷一条看着像故障的记录实际是正常退出。进一步的收尾是给 Hub 加一个CloseAll()遍历成员逐个close(c.send)让每个writePump从case data, ok : -c.send的ok false分支自然退出。不要直接conn.Close()了事那样写 goroutine 会在下一次Write时才报错中间的状态是不确定的。5. 排查与避坑五个把聊天室搞崩的真实细节前面三章的代码照抄能跑但跑不出问题不代表没问题。这一章的五条都是从实际调试里攒下来的按「现象 → 原因 → 解决」写遇到对得上的直接照着改。5.1 连不上、连上了没反应网络层的三个坑现象一本机两个客户端聊得好好的隔壁机器连过来一直超时。原因基本锁定在监听地址。默认参数写成127.0.0.1:9000时socket 只绑定回环网卡局域网里其他机器的连接请求根本到不了这个进程。另一个可能是系统防火墙默认拦掉了非常用端口。解决分两步走服务端启动参数改成-addr 0.0.0.0:9000然后用ss -lntp | grep 9000确认输出里的本地地址是0.0.0.0:9000或*:9000而不是127.0.0.1:9000。确认监听没问题再去看防火墙规则放行这个 TCP 端口。这个顺序不能反先查监听再查防火墙能省掉一半时间。现象二连接建立成功但屏幕上一条消息都不出现客户端像卡住了。原因是客户端漏了发MsgJoin。服务端的读循环收到MsgChat时会检查c.name 为空就continue跳过于是消息被静默丢弃。因为 TCP 连接是通的客户端也不报错看起来就像「连上了但不工作」。解决是确保Dial成功后的第一个 Write 就是 join 帧不要在中间插入其他逻辑。另外服务端那个continue建议加一行log.Printf(未注册昵称的连接尝试发言: %s, conn.RemoteAddr())把静默失败变成长嘴失败下次同类问题五秒定位。现象三客户端一切正常服务端日志里却不断冒出「发送缓冲已满踢出」。原因是这个客户端的消费速度跟不上广播速度。常见于客户端跑在性能很差的机器上或者 stdout 被重定向到了处理很慢的管道。也可能是send缓冲设得太小比如设成了 1正常的突发消息就能把它填满。解决是先看sendBufSize调到 32 或 64 试试如果调整后仍然频繁出现那问题在客户端侧去检查readLoop里有没有阻塞操作比如在打印时同步写文件。还有一种可能是网络本身有问题往writePump里加一行耗时日志就能看出来。5.2 消息错位、进程崩溃数据层的两个坑现象四中文昵称显示成乱码或者一句话被切成两半。两种情况要分开看。乱码通常是编码不统一某些终端默认不是 UTF-8os.Stdout.WriteString写出去的字节被按别的编码解释。注意Go 的 string 本身就是 UTF-8 字节序列代码层面不需要转码问题在终端。被切两半则是另一个原因自己实现了按「字符数」切分的逻辑。len(s)返回的是字节数len([]rune(s))才是字符数如果用了s[:n]按 rune 数量去切字节中文必然断在半个字符上。解决是长度字段永远用len([]byte(payload))读写全部走io.ReadFull中间不要做任何按字符的裁剪。现象五跑了几分钟后进程直接退出日志最后一行是fatal error: concurrent map writes。原因是某个 goroutine 绕过了 Hub 的方法直接读写h.clients。这个错误不能被 recover抓到就死。排查方式是把clients字段改成小写并放在 hub.go 里其他文件访问不到编译期就能拦住。解决是所有对成员表的操作统一走Join/Leave/Broadcast三个方法并且像 4.2 节说的那样收集完待处理对象再出锁操作。另外给Leave加上幂等判断避免同一个连接被两条路径重复清理导致close of closed channel—— 这个 panic 和上面那个长得像但原因不同通道关闭两次同样致命。提示调试并发问题时在启动时加一行go func(){ log.Println(http.ListenAndServe(localhost:6060, nil)) }()然后用curl localhost:6060/debug/pprof/goroutine?debug1看 goroutine 栈比盲猜快得多。6. 进阶加昵称列表、私聊与消息落盘的最小改动基础版本能聊之后大多数人会想加两个功能看谁在线、单独跟某个人说话。这两件事都不需要重构只要在协议层留的位置上填内容。昵称列表用一条新消息类型0x06 MsgList客户端发一个空载荷的 list 请求服务端遍历成员表拼一个逗号分隔的字符串返回。改动只涉及 protocol 的常量和 serve 的 switch 分支二十行以内。私聊稍微绕一点但也不复杂。约定以昵称 内容开头的消息走定向投递// serve 的 MsgChat 分支里 text : string(msg.Payload) if strings.HasPrefix(text, ) { if i : strings.IndexByte(text, ); i 0 { target : text[1:i] body : text[i1:] if h.SendTo(target, protocol.Frame(protocol.MsgChat, [私聊] c.name: body)) { return } conn.Write(protocol.Frame(protocol.MsgSystem, 用户 target 不在线)) continue } } h.Broadcast(protocol.Frame(protocol.MsgChat, c.name: text))SendTo在持有读锁的情况下按昵称线性查找十级别的成员数量下这个开销可以忽略不要为它上索引。真正需要注意的是返回 bool 而不是静默失败让发送方能收到「对方不在线」的反馈否则用户会以为消息发丢了。消息落盘更适合做成旁路而不是主路径在Broadcast之前把原始帧追加到一个用os.OpenFile(..., O_APPEND|O_CREATE|O_WRONLY, 0644)打开的文件里格式就用时间戳\t类型\t载荷的纯文本。之所以强调旁路是因为同步写盘会让广播的延迟从微秒级跳到毫秒级房间里人一多就能感觉到卡顿。真要做可靠存储标准做法是丢给一个带缓冲的 channel单独一个 goroutine 顺序写进程退出时靠 4.3 节的信号处理把 channel 排空再关文件。验证这套改动有没有生效我会用一个笨办法开三个客户端A 发群聊、B 发私聊给 C、C 直接 CtrlC 关掉窗口然后看服务端日志里的成员数是不是从 3 变 2再cat一下落盘文件确认三条记录都在、时间戳递增。这个过程能同时覆盖广播、定向投递、断线清理和持久化四条路径比单测写起来还快。从那以后我每次给这类程序加功能都会强制走一遍「三客户端 一个 CtrlC」的验证流程因为它几乎能一次暴露所有连接生命周期相关的问题。这套骨架的价值不在功能多少而在于它把 TCP 分包、并发保护、超时保活这几个绕不开的点都摆在了明面上改一处就能看到一处的影响。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?