简介:这是一份面向Go语言初学者与网络编程爱好者的局域网聊天程序源码,基于控制台实现,适合用来理解TCP通信、并发处理与客户端/服务端交互的基本流程。资源包共6个文件,以4个Go源码文件为主,另含服务端与客户端各一个可执行文件,压缩包约3.72MB,结构精简,便于快速阅读与二次开发。程序已实现基础聊天功能,读者可在此基础上扩展私聊、群发、用户列表或消息持久化等特性。目前已有221人学习下载,说明其作为入门练手项目具有一定参考价值。整体代码量不大,适合作为学习Go网络编程的起点,也可用于课程设计或小型实验场景,帮助读者掌握socket编程与goroutine协作的核心思路。
1. 用 Go 写一个局域网聊天程序:从终端里长出来的即时通讯
你有没有想过,办公室里几台机器不装任何客户端、不连外网,只靠一个命令行窗口就能互相发消息?我最近拆了一个用 Go 实现的基于控制台的局域网聊天程序,整个项目没有一行前端代码,没有数据库,没有 Web 框架,就是纯终端交互。它的核心思路很直接:一台机器跑服务端监听端口,其他机器用 TCP 连上来,服务端把消息广播给所有在线连接。听起来简单,但真写起来,并发连接管理、消息粘包、昵称冲突、断线清理这些坑一个都不会少。这个项目适合两类人:一是刚学完 Go 基础语法想找个能跑起来的练手项目,二是想理解 TCP 长连接和并发模型但不想被 Web 框架干扰的开发者。下面我按实际拆解的顺序,从跑起来到改起来,把这份源码讲透。
2. 项目定位与技术选型:为什么用原生 net 包而不是 WebSocket
2.1 这个项目到底解决了什么场景
局域网聊天程序的需求场景其实很具体:一个没有外网的环境,比如实验室内部、临时办公点、家庭多设备之间,需要快速建立一个文字沟通渠道。你不可能为了发几句话去部署一套完整的 IM 系统,也不需要消息持久化、离线推送、已读回执这些功能。这个项目瞄准的就是「最小可用」——启动服务端,其他人连上来,能群聊,能看在线列表,退出时其他人能感知到。
从代码结构上看,它没有引入任何第三方依赖,标准库的net、bufio、sync、fmt就撑起了全部功能。这意味着你go build出来的就是一个静态二进制文件,拷到任何同架构的机器上都能直接跑,不需要装运行时、不需要配环境变量。对于局域网这种临时性、低维护成本的场景,这个选择非常务实。
另一个值得说的点是控制台交互。很多人第一反应是做个 Web 页面,但 Web 方案要处理 HTTP 路由、WebSocket 升级、前端渲染,代码量至少翻三倍。而控制台程序用bufio.Scanner读标准输入,用fmt.Println输出,交互逻辑清晰到可以一眼看完。对于学习 TCP 编程来说,少一层抽象就少一层干扰。
2.2 技术栈拆解:标准库够用,但每块都有讲究
项目用到的标准库包不多,但每个都有明确的职责。net包负责 TCP 监听和连接建立,bufio包负责按行读取消息解决粘包问题,sync包负责保护共享的客户端列表,time包用于设置连接超时和心跳检测。下面这张表是我整理的核心包与用途对照:
| 包名 | 用途 | 关键类型/函数 |
|---|---|---|
| net | TCP 监听与连接 | net.Listen、net.Conn |
| bufio | 按行读取消息 | bufio.Scanner、bufio.Reader |
| sync | 并发保护 | sync.Mutex、sync.WaitGroup |
| fmt | 格式化输入输出 | fmt.Scanln、fmt.Printf |
| time | 超时与心跳 | time.Duration、time.Tick |
这里重点说bufio.Scanner的作用。TCP 是字节流协议,没有消息边界。如果客户端连续发送两条消息,服务端一次Read可能把两条都读出来,也可能只读到半条。常见做法是约定一个分隔符,比如换行符\n,然后用bufio.Scanner按行切分。这样每条消息天然有边界,处理起来简单可靠。我一般会在 Scanner 初始化时设置Buffer的最大长度,防止单条消息过大导致内存问题。
2.3 并发模型:一个连接一个 goroutine
服务端的并发模型是这个项目的核心。每 accept 一个新连接,就启动一个独立的 goroutine 去处理这个连接的读写。这个 goroutine 内部再分两条线:一条持续读取客户端发来的消息,另一条负责把广播消息写回客户端。读和写分离的好处是,读操作阻塞不会影响写操作,写操作阻塞也不会卡住读。
但这里有个经典问题:多个 goroutine 同时往同一个net.Conn写数据会出问题。TCP 连接不是并发安全的,两个 goroutine 同时调用Write可能导致数据交错。常见做法是给每个客户端维护一个发送通道chan string,所有要发给该客户端的消息先塞进通道,再由一个专门的写 goroutine 从通道里取出来写。这样写操作就串行化了,不会冲突。
// 客户端结构体,包含连接、昵称和发送通道 type Client struct { conn net.Conn nickname string sendCh chan string // 带缓冲的发送通道,避免写阻塞 } // 写 goroutine:从通道取消息写入连接 func (c *Client) writeLoop() { for msg := range c.sendCh { _, err := c.conn.Write([]byte(msg + "\n")) if err != nil { return // 写失败说明连接已断,退出 goroutine } } }上面这段代码里,sendCh建议带一个缓冲,比如make(chan string, 64)。缓冲的作用是削峰:当某个客户端网络慢、写操作阻塞时,消息先堆在通道里,不会立刻卡住广播逻辑。但如果缓冲满了,说明这个客户端已经跟不上了,这时候可以选择丢弃消息或断开连接。我一般会设一个阈值,超过就主动踢掉,避免拖累整个服务端。
3. 从零跑起来:编译、启动与多端联调
3.1 环境准备与编译命令
这个项目对 Go 版本的要求不高,1.16 以上都能跑。如果你还没装 Go,去官网下载对应平台的安装包,装完后go version能输出版本号就行。源码目录结构通常是这样:
lanchat/ ├── server/ │ └── main.go ├── client/ │ └── main.go └── go.mod编译服务端和客户端分别执行:
# 编译服务端 cd server && go build -o lanchat-server main.go # 编译客户端 cd client && go build -o lanchat-client main.go编译完成后会得到两个可执行文件。Windows 下是.exe,Linux/macOS 下没有后缀。如果你想交叉编译,比如在 Mac 上编 Linux 版本,用GOOS=linux GOARCH=amd64 go build就行。这个项目没有 CGO 依赖,交叉编译非常干净。
3.2 启动服务端与客户端连接
先在一台机器上启动服务端,默认监听:8888端口:
./lanchat-server -addr :8888启动后终端会打印「服务端已启动,监听 :8888」。然后在同一台机器或局域网内其他机器上启动客户端:
./lanchat-client -server 192.168.1.100:8888 -name 张三-server参数填服务端的局域网 IP 和端口,-name填你的昵称。连接成功后,你会看到欢迎信息和在线用户列表。之后直接输入文字按回车就能发送,输入/quit退出,输入/list查看在线用户。
这里有个细节:服务端启动时最好显式绑定0.0.0.0而不是127.0.0.1,否则其他机器连不上。net.Listen("tcp", ":8888")默认就是监听所有网卡,但如果你写成net.Listen("tcp", "127.0.0.1:8888"),那就只有本机能连。这个坑我踩过,排查了半天才发现是绑定地址写死了。
3.3 多端联调与消息验证
要验证广播功能,至少开三个终端窗口:一个跑服务端,两个跑客户端。在客户端 A 输入消息,客户端 B 应该立刻收到。如果 B 没收到,按这个顺序排查:先看服务端有没有打印「收到来自 A 的消息」,有的话说明服务端读到了,问题在广播逻辑或 B 的写通道;没有的话说明 A 的消息根本没发出去,检查 A 的输入读取和发送逻辑。
验证在线列表时,客户端 C 加入后,A 和 B 都应该收到「C 加入了聊天室」的系统消息。C 退出后,A 和 B 应该收到「C 离开了聊天室」。如果退出消息没收到,大概率是服务端没有正确处理连接关闭事件。常见做法是在读 goroutine 里检测Read返回的 error,一旦非 nil 就触发清理逻辑,从客户端列表移除并广播离开消息。
提示:局域网内测试时,确保防火墙允许了 8888 端口的 TCP 入站连接。Windows 防火墙默认会弹窗询问,选允许即可;Linux 下用
ufw allow 8888/tcp或firewall-cmd放行。
4. 核心模块拆解:连接管理、消息广播与昵称处理
4.1 服务端主循环:Accept 与客户端注册
服务端的主循环非常简洁,就是一个死循环不断 Accept 新连接。每来一个连接,先创建 Client 结构体,把连接和发送通道初始化好,然后启动读 goroutine 和写 goroutine,最后把 Client 加入全局列表。
// 全局客户端列表,用 Mutex 保护 var ( clients = make(map[*Client]bool) clientsMu sync.Mutex ) func main() { listener, err := net.Listen("tcp", ":8888") if err != nil { log.Fatal("监听失败:", err) } defer listener.Close() for { conn, err := listener.Accept() if err != nil { log.Println("接受连接失败:", err) continue } // 每个连接一个 goroutine 处理 go handleConnection(conn) } } func handleConnection(conn net.Conn) { client := &Client{ conn: conn, sendCh: make(chan string, 64), } // 先读取昵称,再注册到列表 reader := bufio.NewReader(conn) nickname, _ := reader.ReadString('\n') client.nickname = strings.TrimSpace(nickname) clientsMu.Lock() clients[client] = true clientsMu.Unlock() // 广播加入消息 broadcast(client.nickname + " 加入了聊天室") go client.writeLoop() client.readLoop(reader) // 阻塞在这里,直到连接断开 }这段代码的关键点有三个。第一,clients用 map 存储,方便增删和遍历,但 map 不是并发安全的,所以所有读写都必须加锁。第二,昵称在注册前先读取,这样广播加入消息时就能带上名字。第三,readLoop是阻塞的,它返回就意味着连接断了,后面的清理逻辑要跟上。
4.2 消息广播:遍历列表与写通道投递
广播函数的逻辑是:加锁遍历所有客户端,把消息投递到每个客户端的发送通道。注意这里只投递,不直接写连接,写操作由各自的写 goroutine 负责。
func broadcast(msg string) { clientsMu.Lock() defer clientsMu.Unlock() for c := range clients { select { case c.sendCh <- msg: // 投递成功 default: // 通道满了,说明客户端处理不过来,踢掉 close(c.sendCh) delete(clients, c) c.conn.Close() } } }select加default的写法是防止阻塞的关键。如果某个客户端的发送通道满了,default分支会执行,直接关闭通道并移除客户端。这样广播函数永远不会因为某个慢客户端而卡住。这里有个细节:关闭通道后,写 goroutine 的range循环会自动退出,不会 panic。但要注意,关闭通道后不能再往里面发数据,所以delete和close的顺序要保证在锁内完成。
4.3 昵称冲突与命令解析
昵称冲突是聊天室常见问题。如果两个人用同一个昵称,在线列表会混乱,@ 功能也没法做。常见做法是在注册时检查昵称是否已存在,如果存在就自动加后缀,比如「张三_2」。
func generateNickname(base string) string { clientsMu.Lock() defer clientsMu.Unlock() name := base suffix := 1 for { conflict := false for c := range clients { if c.nickname == name { conflict = true break } } if !conflict { return name } suffix++ name = fmt.Sprintf("%s_%d", base, suffix) } }命令解析方面,客户端输入以/开头的字符串时,不直接广播,而是当作命令处理。/quit断开连接,/list请求在线列表,/nick 新名字修改昵称。服务端收到命令后执行对应逻辑,而不是把原始命令广播出去。这个设计让聊天室有了基本的交互能力,同时保持代码简单。
注意:命令解析要在服务端做,不要只在客户端做。因为客户端可以被修改,如果只在客户端过滤,恶意用户可以直接发原始 TCP 包绕过限制。服务端做命令解析是安全底线。
5. 避坑与排查:粘包、并发写、断线清理的实战记录
5.1 消息粘包导致昵称和第一条消息连在一起
现象:客户端连接后发送昵称,紧接着发送第一条消息,服务端收到的昵称变成了「张三你好」,把消息内容也带进去了。
原因:TCP 是字节流,客户端连续两次Write可能被服务端一次Read全部读出。如果服务端用conn.Read直接读固定缓冲区,就会把两次写入的内容混在一起。
解决:统一用bufio.Scanner或bufio.Reader按行读取,约定每条消息以\n结尾。这样无论底层怎么粘包,上层都能正确切分。客户端发送时也要确保每条消息末尾有换行符。
5.2 多个 goroutine 同时写连接导致数据交错
现象:广播频繁时,客户端收到的消息出现乱码或半截消息,比如「张三说你好」变成「张三说你」和「好」两条。
原因:服务端有多个 goroutine 可能同时往同一个net.Conn写数据,TCP 连接不是并发安全的,两个Write调用可能交错执行。
解决:每个客户端维护一个发送通道,所有写操作由单独的写 goroutine 串行执行。广播时只往通道投递,不直接写连接。这个模式在 Go 并发编程里非常经典,值得记住。
5.3 客户端异常断开后服务端未清理
现象:客户端直接关窗口或断网,服务端没有广播离开消息,在线列表里还留着这个用户,再发消息给他会报错。
原因:服务端的读 goroutine 阻塞在Read上,客户端异常断开时,Read会返回 error,但如果没有处理这个 error,goroutine 就卡住了,清理逻辑也不会执行。
解决:在读循环里检测Read返回的 error,一旦非 nil 就跳出循环,执行清理:从 clients 列表移除、关闭发送通道、广播离开消息。同时给连接设置读超时,比如conn.SetReadDeadline(time.Now().Add(60 * time.Second)),配合心跳机制检测死连接。
5.4 发送通道未关闭导致 goroutine 泄漏
现象:程序运行一段时间后内存持续增长,goroutine 数量只增不减。
原因:客户端断开后,写 goroutine 还在range一个没有关闭的通道,永远阻塞,无法退出。读 goroutine 虽然退出了,但写 goroutine 泄漏了。
解决:在清理逻辑里显式close(client.sendCh),这样写 goroutine 的range循环会收到通道关闭信号并退出。注意关闭通道的操作要在锁内做,并且确保不会重复关闭。可以用sync.Once或者标志位来保证只关一次。
5.5 局域网 IP 绑定错误导致其他机器连不上
现象:本机客户端能连上,同一局域网的其他机器连不上,提示连接超时。
原因:服务端监听地址写成了127.0.0.1:8888,只绑定了回环网卡,外部机器无法访问。
解决:监听地址写成:8888或0.0.0.0:8888,绑定所有网卡。同时检查防火墙是否放行了对应端口。Linux 下用ss -tlnp | grep 8888查看监听地址是否正确。
6. 二次开发改造:从群聊到私聊、文件传输与心跳保活
6.1 私聊功能的实现思路
群聊广播是遍历所有客户端投递消息,私聊只需要把目标限定为特定客户端。改造点有两个:一是命令解析增加/msg 昵称 内容格式,二是广播函数改成定向发送。
// 定向发送:只发给指定昵称的客户端 func sendTo(nickname string, msg string) bool { clientsMu.Lock() defer clientsMu.Unlock() for c := range clients { if c.nickname == nickname { select { case c.sendCh <- msg: return true default: return false } } } return false // 目标不在线 }命令解析时,用strings.SplitN(input, " ", 3)把/msg 张三 你好拆成三部分,第二部分是目标昵称,第三部分是消息内容。然后调用sendTo定向发送。如果返回 false,给发送者回一条「用户不在线」的提示。这个改造不复杂,但要注意昵称的唯一性,否则私聊会发错人。
6.2 文件传输的协议设计
文件传输比文字消息复杂,因为涉及二进制数据和分块。常见做法是定义一套简单的应用层协议:先发一个头部,包含文件名和文件大小,然后按固定大小分块发送文件内容,接收端按同样规则解析。
// 文件传输头部结构(简化版) type FileHeader struct { FileName string FileSize int64 } // 发送文件:先发头部,再发内容 func sendFile(conn net.Conn, filePath string) error { file, err := os.Open(filePath) if err != nil { return err } defer file.Close() info, _ := file.Stat() header := FileHeader{ FileName: info.Name(), FileSize: info.Size(), } // 用 gob 或 json 序列化头部,写入连接 encoder := gob.NewEncoder(conn) if err := encoder.Encode(header); err != nil { return err } // 分块发送文件内容 buf := make([]byte, 4096) for { n, err := file.Read(buf) if n > 0 { conn.Write(buf[:n]) } if err == io.EOF { break } if err != nil { return err } } return nil }接收端先解码头部,拿到文件名和大小,然后循环读取直到收满指定字节数,写入本地文件。这里要注意:文件传输会阻塞文字消息的读写,所以最好单独开一个连接传文件,或者用多路复用协议区分消息类型。对于练手项目,单独开连接更简单。
6.3 心跳保活与超时踢出
局域网虽然稳定,但客户端可能异常断电或休眠,导致服务端一直保留死连接。心跳机制的做法是:服务端每隔 30 秒向所有客户端发送一个PING消息,客户端收到后回复PONG。如果服务端连续三次没收到某个客户端的PONG,就判定该连接已死,主动断开并清理。
// 心跳检测 goroutine func heartbeat() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { clientsMu.Lock() for c := range clients { select { case c.sendCh <- "PING": // 发送成功,等待客户端回复 PONG default: // 通道满,可能已经卡死,直接清理 close(c.sendCh) delete(clients, c) c.conn.Close() } } clientsMu.Unlock() } }客户端收到PING后自动回复PONG,这个逻辑在客户端的读循环里处理。服务端收到PONG后更新该客户端的最后活跃时间。如果超过 90 秒没收到PONG,就踢掉。这个机制能有效清理死连接,但要注意心跳消息不要和普通消息混淆,可以用特殊前缀区分。
6.4 改造后的验证方法
每次改造完,按这个清单验证一遍:私聊功能——开三个客户端,A 给 B 发私聊,C 不应该收到;文件传输——传一个 10MB 的文件,对比 MD5 是否一致;心跳保活——客户端直接杀进程,服务端应该在 90 秒内广播离开消息。我一般会写一个简单的测试脚本,用nc或telnet模拟客户端连接,手动发几条消息看服务端反应。这个习惯帮我省了很多来回编译的时间。
从那以后我每次改完并发相关的代码,都强制走一遍「三客户端 + 异常断开 + 高频广播」的测试流程,确认没有 goroutine 泄漏和消息交错才提交。希望这个项目的拆解能帮到你,拿到源码后先跑通,再按自己的需求一步步改,比一上来就大改要稳得多。
本文还有配套的精品资源,点击获取