简介这是一份面向Go语言初学者与网络编程爱好者的局域网聊天程序源码基于控制台实现适合用来理解TCP通信、并发处理与客户端/服务端交互的基本流程。资源包共6个文件以4个Go源码文件为主另含服务端与客户端各一个可执行文件压缩包约3.72MB结构精简便于直接运行与二次开发。程序已实现基础聊天功能读者可在此基础上扩展私聊、群组、文件传输或消息持久化等特性。目前已有221人学习下载说明其作为练手项目具备一定参考价值。通过阅读服务端与客户端的代码组织方式能够掌握Go中goroutine、channel及网络IO的实际用法适合作为课程设计、自学练手或面试准备的小型实战案例。1. 一个终端窗口就能聊起来的局域网工具你有没有遇到过这种场景会议室里几台电脑要临时传个文件、对个接口结果微信登录不上、网盘限速、U 盘又找不到。这时候如果有个纯命令行的小工具敲一行地址就能连上隔壁工位的机器直接开聊那才叫顺手。这个用 Go 实现的基于控制台的局域网聊天程序干的就是这件事——它不依赖任何中心服务器不依赖外网只要两台机器在同一个网段就能通过 TCP 直连互发消息。整个项目编译出来就是一个可执行文件扔到 Windows、Linux、macOS 上都能跑特别适合内网环境下的快速沟通、调试协作或者拿来当学习 Go 网络编程和并发模型的练手项目。如果你正在找一份能直接跑起来、代码量不大但结构完整的 Go 实战源码这个项目值得你花半小时拆一拆。2. 项目架构拆解从 main 到连接管理的三层结构2.1 整体目录与模块划分这个项目没有引入任何第三方框架标准库就是它的全部依赖。常见的目录组织方式是这样lan-chat/ ├── main.go // 程序入口解析命令行参数启动服务或连接 ├── server.go // 服务端逻辑监听端口、接受连接、广播消息 ├── client.go // 客户端逻辑连接服务端、发送消息、接收消息 ├── message.go // 消息结构体定义与编解码 └── go.mod // 模块定义这种拆法很朴素但职责清晰。main.go只负责判断当前是「开房间」还是「进房间」server.go管连接池和消息分发client.go管单机上的输入输出循环message.go定义传输格式。我一般会把这种结构叫做「三文件骨架」——入口、服务端、客户端各一个文件再加一个协议定义对于千行以内的项目来说足够用也方便二次开发时快速定位。2.2 消息协议设计定长头 变长体局域网聊天最怕的就是粘包。TCP 是字节流你发两条消息对面可能一次全收到也可能分两次收到半条。所以必须自己定一个边界。这个项目采用的是最常见的「长度前缀」方案// message.go package main import ( encoding/binary io ) // Message 定义一条聊天消息 type Message struct { Nickname string // 发送者昵称 Content string // 消息正文 } // Encode 把消息编码成字节流4 字节长度 昵称长度 昵称 内容 func (m *Message) Encode() []byte { nickLen : len(m.Nickname) contentLen : len(m.Content) // 总长度 4(昵称长度字段) nickLen contentLen total : 4 nickLen contentLen buf : make([]byte, 4total) binary.BigEndian.PutUint32(buf[0:4], uint32(total)) binary.BigEndian.PutUint32(buf[4:8], uint32(nickLen)) copy(buf[8:8nickLen], m.Nickname) copy(buf[8nickLen:], m.Content) return buf } // Decode 从连接中读取一条完整消息 func Decode(r io.Reader) (*Message, error) { lenBuf : make([]byte, 4) if _, err : io.ReadFull(r, lenBuf); err ! nil { return nil, err } total : binary.BigEndian.ReadUint32(lenBuf) body : make([]byte, total) if _, err : io.ReadFull(r, body); err ! nil { return nil, err } nickLen : binary.BigEndian.Uint32(body[0:4]) nickname : string(body[4 : 4nickLen]) content : string(body[4nickLen:]) return Message{Nickname: nickname, Content: content}, nil }这里有几个关键点。第一长度字段用BigEndian写入网络字节序统一跨平台不会翻车。第二io.ReadFull保证读满指定字节数才返回避免半包。第三昵称长度单独用一个字段而不是用分隔符去切这样昵称里带空格或冒号也不会解析错。参数上total是消息体总长度不包含最外层那 4 字节nickLen是昵称的字节长度不是字符数中文昵称要注意 UTF-8 编码下 1 个汉字占 3 字节。2.3 服务端连接管理map 读写锁服务端要维护所有在线连接并在收到消息时广播给其他人。Go 里最直接的做法是用map[net.Conn]string存连接和昵称但 map 不是并发安全的多个 goroutine 同时读写会 panic。所以必须加锁// server.go package main import ( fmt net sync ) type Server struct { listener net.Listener clients map[net.Conn]string // 连接 - 昵称 mu sync.RWMutex // 保护 clients 的读写锁 } func NewServer(addr string) (*Server, error) { ln, err : net.Listen(tcp, addr) if err ! nil { return nil, err } return Server{ listener: ln, clients: make(map[net.Conn]string), }, nil } func (s *Server) Start() { for { conn, err : s.listener.Accept() if err ! nil { fmt.Println(accept error:, err) continue } go s.handleConn(conn) // 每个连接一个 goroutine } } func (s *Server) handleConn(conn net.Conn) { defer func() { s.mu.Lock() delete(s.clients, conn) s.mu.Unlock() conn.Close() }() // 第一条消息当作昵称注册 msg, err : Decode(conn) if err ! nil { return } s.mu.Lock() s.clients[conn] msg.Nickname s.mu.Unlock() s.broadcast(fmt.Sprintf([系统] %s 加入了聊天室, msg.Nickname), conn) for { msg, err : Decode(conn) if err ! nil { s.broadcast(fmt.Sprintf([系统] %s 离开了聊天室, s.getNick(conn)), conn) return } s.broadcast(fmt.Sprintf(%s: %s, msg.Nickname, msg.Content), conn) } } func (s *Server) broadcast(text string, exclude net.Conn) { s.mu.RLock() defer s.mu.RUnlock() out : (Message{Nickname: server, Content: text}).Encode() for c : range s.clients { if c exclude { continue } c.Write(out) } } func (s *Server) getNick(conn net.Conn) string { s.mu.RLock() defer s.mu.RUnlock() return s.clients[conn] }这段代码是整个项目的核心。handleConn里每个连接独立一个 goroutine互不阻塞。broadcast用读锁因为只遍历不修改delete和写入用写锁。注意broadcast里对每个连接直接Write如果某个客户端网络卡住这里会阻塞整个广播循环。生产环境常见做法是给每个连接配一个带缓冲的发送 channel由单独的写 goroutine 消费但作为学习项目直接写也能跑只是客户端一多会感觉到延迟。2.4 客户端双 goroutine一个收、一个发客户端要同时处理「用户键盘输入」和「服务端消息到达」两件事单线程做不了所以拆成两个 goroutine// client.go package main import ( bufio fmt net os ) func StartClient(addr, nickname string) error { conn, err : net.Dial(tcp, addr) if err ! nil { return err } defer conn.Close() // 先发昵称完成注册 reg : Message{Nickname: nickname, Content: } if _, err : conn.Write(reg.Encode()); err ! nil { return err } // 收消息 goroutine go func() { for { msg, err : Decode(conn) if err ! nil { fmt.Println(连接已断开) os.Exit(0) } fmt.Printf(\r%s\n , msg.Content) } }() // 主 goroutine 读键盘输入并发送 scanner : bufio.NewScanner(os.Stdin) fmt.Print( ) for scanner.Scan() { text : scanner.Text() if text /quit { return nil } msg : Message{Nickname: nickname, Content: text} if _, err : conn.Write(msg.Encode()); err ! nil { return err } fmt.Print( ) } return nil }收消息的 goroutine 里用\r回到行首再打印是为了不让服务端消息和当前输入行叠在一起。/quit是自定义的退出指令不走网络直接关连接。这里有个小细节bufio.NewScanner默认单行最大 64KB如果粘贴超长文本会被截断需要调scanner.Buffer加大上限。3. 跑起来编译、启动服务端、多端接入的完整步骤3.1 环境准备与编译先确认本机装了 Go 1.18 以上版本然后进入项目目录# 初始化模块如果源码里没有 go.mod go mod init lan-chat # 编译当前平台可执行文件 go build -o lan-chat . # 交叉编译 Windows 版本在 Linux/macOS 上 GOOSwindows GOARCHamd64 go build -o lan-chat.exe .编译完只有一个二进制文件没有任何运行时依赖。GOOS和GOARCH是交叉编译的关键参数常见组合还有GOOSlinux GOARCHarm64给树莓派用。如果编译报错找不到包先执行go mod tidy拉齐依赖——虽然这个项目只用标准库但模块声明缺失时也会报错。3.2 启动服务端并确认监听服务端需要指定监听地址和端口。常见做法是监听0.0.0.0表示所有网卡都接受连接# 启动服务端监听 8888 端口 ./lan-chat -modeserver -addr0.0.0.0:8888启动后终端会打印类似listening on 0.0.0.0:8888的日志。这时候在另一台机器上先用telnet或nc测一下端口通不通# 在另一台机器上测试端口连通性 nc -zv 192.168.1.100 8888如果显示succeeded或open说明网络层没问题。如果卡住或拒绝先检查服务端机器防火墙有没有放行 8888 端口。Linux 上常见做法是用ufw allow 8888/tcp或firewall-cmd --add-port8888/tcpWindows 则要在「高级安全 Windows Defender 防火墙」里新建入站规则。3.3 客户端接入与多开测试客户端启动时传入服务端 IP 和自己的昵称# 客户端 A ./lan-chat -modeclient -addr192.168.1.100:8888 -nick张三 # 客户端 B同一台机器再开一个终端 ./lan-chat -modeclient -addr192.168.1.100:8888 -nick李四两个客户端都连上后在 A 里输入「接口文档发我一下」B 的终端会立刻显示张三: 接口文档发我一下。服务端终端也会打印加入和离开的系统消息。如果想模拟多人同一台机器开多个终端窗口即可每个窗口一个昵称。注意昵称不要重复否则广播时无法区分——虽然代码没做去重校验但实际使用中重名会让对话变得混乱。3.4 参数说明与常用配置参数含义默认值示例-mode运行模式server 或 clientclient-modeserver-addr服务端监听地址或客户端连接地址127.0.0.1:8888-addr0.0.0.0:8888-nick客户端昵称匿名-nick张三这些参数在main.go里用flag包解析改默认值直接改flag.String的第二个参数即可。如果想让服务端启动时自动打印本机所有网卡 IP方便别人连接可以在Start之前加一段遍历net.Interfaces()的逻辑这是我在多个内网工具里都会加的小功能能省去用户自己查 IP 的麻烦。4. 避坑与排查粘包、并发写、防火墙这三类问题最常遇到4.1 消息显示不全或两条消息粘在一起现象客户端偶尔只收到半句话或者两条消息连成一行显示。原因TCP 是流式协议没有消息边界。如果接收端直接用conn.Read读固定大小缓冲区就会把两条消息读进同一个 buffer或者一条消息分两次读完。解决严格按Decode里的逻辑先读 4 字节长度再按长度读满消息体。不要用bufio.Reader.ReadString(\n)这种按分隔符读的方式因为消息内容里可能包含换行符。如果自己改造协议记住一个原则长度字段本身必须是定长的否则长度字段自己也会粘包。4.2 并发写同一个连接导致数据错乱现象多个 goroutine 同时向一个net.Conn写数据接收端解析出的消息内容错位或乱码。原因net.Conn的Write方法在 Go 里是并发安全的但「编码 写入」这个组合操作不是原子的。两个 goroutine 同时调用Encode再Write字节流可能交错。解决给每个连接配一个sync.Mutex写之前加锁或者更优雅的做法是每个连接一个发送 channel单独一个 goroutine 负责从 channel 取数据并写入连接。这个项目里服务端广播时是单 goroutine 顺序写所以没暴露这个问题但一旦你加了「私聊」功能多个 goroutine 同时写同一个连接就会翻车。4.3 局域网内其他机器连不上现象本机客户端能连上隔壁机器连不上提示connection refused或超时。原因三种可能——服务端监听在127.0.0.1而不是0.0.0.0服务端机器防火墙拦截了入站连接两台机器不在同一网段。解决先确认启动参数是-addr0.0.0.0:8888然后检查防火墙规则最后用ping和ipconfig/ifconfig确认双方 IP 前三段一致。如果公司网络做了 VLAN 隔离即使 IP 看起来在同一网段也可能不通这种情况需要找网络管理员确认不是代码能解决的。4.4 客户端退出后服务端没有清理连接现象客户端直接关窗口或 CtrlC 后服务端clientsmap 里还留着那条连接广播时向已关闭的连接写入会报错。原因客户端异常退出时没有发送关闭通知服务端的Decode会返回io.EOF但如果没有正确处理这个错误并执行delete连接就会泄漏。解决在handleConn里用defer确保无论正常还是异常退出都执行delete(s.clients, conn)和conn.Close()。另外广播时忽略写入错误不要因为一个死连接影响其他客户端。我一般还会加一个心跳机制每隔 30 秒向所有连接发一个空消息连续三次写失败就主动清理这样能处理拔网线这种极端情况。4.5 中文昵称或消息乱码现象英文聊天正常一发中文就显示成乱码或问号。原因Windows 终端默认编码可能是 GBK而 Go 字符串是 UTF-8直接输出到终端时编码不匹配。解决在 Windows 上运行前执行chcp 65001切换到 UTF-8 代码页。或者在代码里引入golang.org/x/text/encoding/simplifiedchinese做转码但这会增加依赖。更简单的做法是文档里注明「Windows 用户请先执行 chcp 65001」这是我在多个控制台工具里踩过的坑写清楚能省掉大量反馈。5. 二次开发加私聊、传文件、改协议的三条改造路线拿到这份源码后最值得动手改的方向有三个我按难度从低到高排一下。第一条路线加私聊指令。现在的广播是「一人说话全房间听」改造成支持昵称 消息的私聊只需要在服务端broadcast之前判断消息内容是否以开头解析出目标昵称然后只向对应连接写入。核心改动在handleConn的消息处理循环里加一个分支// 伪代码示意私聊分支 if strings.HasPrefix(msg.Content, ) { parts : strings.SplitN(msg.Content, , 2) target : strings.TrimPrefix(parts[0], ) s.sendTo(target, fmt.Sprintf([私聊] %s: %s, msg.Nickname, parts[1])) } else { s.broadcast(...) }sendTo方法需要遍历clients找到昵称匹配的连接找不到就回一条「用户不在线」。这里要注意锁的粒度——查找时用读锁找到后释放锁再写入避免持锁写网络导致其他操作阻塞。第二条路线加文件传输。文件传输的本质是把文件切成块每块当作一条消息发送接收端按序号拼接。可以在Message结构体里加一个Type字段区分「文本」和「文件块」再加FileID、Seq、Total三个字段。发送端读文件按 32KB 分块接收端收到所有块后按FileID合并写入磁盘。这个改造会显著增加协议复杂度建议先把Message的编解码改成用encoding/gob或 JSON省去手动处理字段的麻烦。但要注意JSON 编码后的长度不固定仍然需要外层长度前缀来定边界。第三条路线换传输层协议。如果想让聊天支持离线消息或跨网段可以把 TCP 换成 WebSocket服务端用gorilla/websocket库客户端用浏览器页面。这样部署时只需要一个 HTTP 端口也方便做内网穿透。但这就偏离了「控制台程序」的初衷适合作为进阶练手。改完之后怎么验证我的习惯是写一个简单的压测脚本用 Go 起 50 个 goroutine 模拟 50 个客户端同时连接、发送、断开观察服务端内存和 CPU 是否稳定以及有没有连接泄漏。命令大概是go run stress_test.go -clients50 -duration30s。如果 50 个客户端跑 30 秒后服务端clientsmap 长度归零说明连接清理逻辑没问题。最后说一个我自己的教训早期改这类网络程序时我总喜欢在broadcast里直接conn.Write觉得简单。结果有一次某个客户端网络抖动整个聊天室卡了十几秒所有人都收不到消息。从那以后我每次写广播逻辑都强制走一遍「发送 channel 独立写 goroutine」的模式哪怕代码多二十行也比线上被投诉强。希望这个项目的拆解能帮你少走一段弯路把这份源码跑起来、改起来真正变成自己顺手的内网工具。本文还有配套的精品资源点击获取