简介:这是一份面向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 看的时候不用在脑子里做字节翻转。
类型字段用一个字节枚举,够用且省空间:
| 类型值 | 名称 | 方向 | 载荷内容 |
|---|---|---|---|
| 0x01 | MsgJoin | 客户端 → 服务端 | 昵称字符串 |
| 0x02 | MsgChat | 双向 | 聊天正文 |
| 0x03 | MsgQuit | 客户端 → 服务端 | 空 |
| 0x04 | MsgSystem | 服务端 → 客户端 | 系统提示,如「XX 加入了」 |
| 0x05 | MsgPing | 双向 | 空,仅用于保活 |
对应到代码,这一层单独放一个包,服务端和客户端都 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, headerLen+len(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 版本,直接拷过去双击 GOOS=windows GOARCH=amd64 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()打个日志就能看出来。
超时值的组合参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| readTimeout | 90s | 客户端心跳 30s,留三次容错 |
| writeTimeout | 10s | 局域网内正常写入是微秒级,10s 已经是宽容值 |
| pingPeriod | 30s | 不超过 readTimeout 的一半 |
| DialTimeout | 5s | 客户端连不上要尽快失败,别让用户干等 |
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 一起收掉
控制台程序常常被 Ctrl+C 打断。默认行为是进程立刻死掉,内核回收所有 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-8,os.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?debug=1看 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[i+1:] 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 直接 Ctrl+C 关掉窗口,然后看服务端日志里的成员数是不是从 3 变 2,再cat一下落盘文件确认三条记录都在、时间戳递增。这个过程能同时覆盖广播、定向投递、断线清理和持久化四条路径,比单测写起来还快。
从那以后我每次给这类程序加功能,都会强制走一遍「三客户端 + 一个 Ctrl+C」的验证流程,因为它几乎能一次暴露所有连接生命周期相关的问题。这套骨架的价值不在功能多少,而在于它把 TCP 分包、并发保护、超时保活这几个绕不开的点都摆在了明面上,改一处就能看到一处的影响。希望帮到你。
本文还有配套的精品资源,点击获取