在 Go 里写 Socket 通信,难点往往不在 net.Listen 或 net.Dial 这些 API 本身,而是在把“连接建立、持续收消息、主动发消息、连接关闭”几件事组织清楚。本文基于一个简化的 TCP 封装示例,把服务端和客户端分别拆开来看,再串起来说明最基本的使用方式。
如果你正想快速写一个能双向收发消息的 TCP 通信样例,这套代码足够作为起点;同时,文中也会顺着源码指出几个实现细节,方便你判断哪些部分可以直接用,哪些地方还需要自己补强。
先看基础:三个事件回调分别负责什么
这份示例先定义了三个回调类型,用来把连接建立、收到消息和连接关闭这三类事件分离出来。这样做的好处是,服务端和客户端可以共用一套事件模型,业务逻辑也不必直接写死在底层读写流程里。
// listener.go
package socketer
import "net"
// ConnectListener 连接建立回调
type ConnectListener func(conn net.Conn)
// ReceiveListener 收到消息回调
type ReceiveListener func(data []byte, conn net.Conn)
// CloseListener 连接关闭事件
type CloseListener func(conn net.Conn)从职责上看,它们分别对应三种常见场景:
ConnectListener:连接刚建立时触发,适合记录来源、初始化状态。ReceiveListener:收到一段消息时触发,适合处理业务请求或返回结果。CloseListener:连接结束时触发,适合做清理和日志记录。
后面的服务端和客户端结构体,都是围绕这三个事件来挂接逻辑的。
服务端怎么实现:监听连接并持续读取消息
服务端这部分主要做三件事:创建监听对象、注册事件回调、接受连接后持续读取数据。对应的核心代码如下。

// server.go
package socketer
import (
"bufio"
"io"
"net"
)
type Server struct {
listener net.Listener
address string
network string
closeListener CloseListener
receiveListener ReceiveListener
connectListener ConnectListener
}
func NewServer(network, address string) *Server {
return &Server{
network: network,
address: address,
}
}
func (s *Server) OnConnect(l ConnectListener) {
s.connectListener = l
}
func (s *Server) OnReceive(l ReceiveListener) {
s.receiveListener = l
}
func (s *Server) OnClose(l CloseListener) {
s.closeListener = l
}
func (s *Server) Send(conn net.Conn, data []byte) (int, error) {
return conn.Write(data)
}
func (s *Server) Close(conn net.Conn) error {
err := conn.Close()
if s.closeListener != nil {
s.connectListener(conn)
}
return err
}
func (s *Server) Listen() error {
var err error
s.listener, err = net.Listen(s.network, s.address)
if err != nil {
return err
}
for {
conn, err := s.listener.Accept()
if err != nil {
continue
}
if s.connectListener != nil {
s.connectListener(conn)
}
go s.accept(conn)
}
}
func (s *Server) accept(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
message, err := reader.ReadBytes('n')
if err != nil {
if err == io.EOF {
s.Close(conn)
}
break
}
if s.receiveListener != nil {
go s.receiveListener(message, conn)
}
}
}
1. 初始化和事件注册
NewServer(network, address string) 用来保存网络类型和监听地址,比如示例中的 "tcp" 和 "127.0.0.1:6000"。随后通过 OnConnect()、OnReceive()、OnClose() 把事件处理函数挂上去。
这种写法的好处是简单直接,适合把“网络层”和“业务层”拆开。服务端本身只负责接连接、读消息、发消息,至于收到数据后怎么处理,则交给回调完成。
2. Listen() 的工作流程
Listen() 内部先调用 net.Listen(s.network, s.address) 开始监听,然后进入一个无限循环,不断执行 Accept()。

每当有新的客户端接入时,代码会先触发一次 connectListener,然后用一个新的 goroutine 执行 s.accept(conn)。这意味着每个连接都可以独立处理,不会因为某一个客户端的读写阻塞整个服务端。
3. accept() 如何持续接收消息
accept(conn net.Conn) 是单个连接的读取循环。这里通过 bufio.NewReader(conn) 创建读取器,再反复调用 ReadBytes('n') 读取消息。

按这段代码的意图,它是希望以某个结束符来切分消息,读到结束符后就把完整消息交给 receiveListener 处理。收到消息后,回调会被放进 goroutine 中异步执行:
- 连接不断开,循环就会一直读。
- 只要读到一条完整消息,就触发一次
OnReceive绑定的逻辑。 - 如果读到
io.EOF,说明对端关闭连接,此时调用s.Close(conn)。
这里有两个实现细节值得特别注意。第一,代码里使用的是 ReadBytes('n'),示例发送的数据也用了 "okn"、"啊哈哈n",从源码字面看它是以字符 n 作为消息结束标记,而不是换行符 '\n'。如果你原本想按换行分包,这里需要自己调整。
第二,Close() 方法里判断的是 s.closeListener != nil,但真正调用的却是 s.connectListener(conn)。按命名和语义看,这里更像是一个回调调用写错了:关闭连接时本应触发 closeListener,而不是再次触发连接建立回调。作为演示代码问题不大,但如果直接用于项目,最好先修正。
客户端怎么实现:建立连接、发送消息和持续接收
客户端对应的是另一套相对轻量的封装,重点放在主动连接服务端,以及选择“一次收一条”还是“保持长连接持续接收”。
// client.go
package socketer
import (
"bufio"
"net"
)
type Client struct {
Conn net.Conn
address string
network string
closeListener CloseListener
receiveListener ReceiveListener
connectListener ConnectListener
}
func NewClient(network, address string) (*Client, error) {
var err error
client := &Client{
address: address,
network: network,
}
err = client.dial()
return client, err
}
func (c *Client) dial() error {
var err error
c.Conn, err = net.Dial(c.network, c.address)
if c.connectListener != nil {
c.connectListener(c.Conn)
}
return err
}
func (c *Client) Send(data []byte) (int, error) {
return c.Conn.Write(data)
}
func (c *Client) OnConnect(l ConnectListener) {
c.connectListener = l
}
func (c *Client) OnReceive(l ReceiveListener) {
c.receiveListener = l
}
func (c *Client) OnClose(l CloseListener) {
c.closeListener = l
}
func (c *Client) Close() error {
err := c.Conn.Close()
if c.closeListener != nil {
c.closeListener(c.Conn)
}
return err
}
func (c *Client) Recv() ([]byte, error) {
reader := bufio.NewReader(c.Conn)
return reader.ReadBytes('n')
}
func (c *Client) Open() {
reader := bufio.NewReader(c.Conn)
defer c.Conn.Close()
for {
message, err := reader.ReadBytes('n')
if err != nil {
break
}
if c.receiveListener != nil {
go c.receiveListener(message, c.Conn)
}
}
}1. NewClient() 建立连接
NewClient(network, address string) 会先创建一个 Client 实例,再立即调用内部的 dial()。而 dial() 通过 net.Dial(c.network, c.address) 去连接服务端。
这意味着客户端对象一创建,就会尝试连到目标地址;如果连接失败,错误会直接从 NewClient() 返回。
2. Send() 和 Recv() 适合一次请求一次响应
Send(data []byte) 很直接,就是调用 c.Conn.Write(data)。Recv() 则创建一个 reader,然后读取一条以 'n' 结尾的消息:
func (c *Client) Recv() ([]byte, error) {
reader := bufio.NewReader(c.Conn)
return reader.ReadBytes('n')
}如果你的通信模式比较简单,比如客户端发一条、服务端回一条,那么 Send() 配合 Recv() 就够用了。它的特点是调用方自己掌握节奏,适合请求-响应式的交互。
3. Open() 适合保持长连接
如果你不想每次都手动调用 Recv(),而是希望客户端在连接建立后一直监听服务端推送的数据,就可以使用 Open()。
这个方法内部同样是一个读取循环,不断执行 ReadBytes('n')。每读到一条消息,就异步触发一次 receiveListener。这样一来,客户端既能主动 Send(),也能被动持续接收服务端发来的内容。
不过要注意一个顺序问题:示例中 NewClient() 创建客户端时就已经调用了 dial(),而 OnConnect() 往往是在对象创建后才注册。也就是说,如果你在 NewClient() 之后再设置 OnConnect(),那这次初始连接建立事件其实已经错过了。这也是这份简化封装在实际使用时需要考虑的地方。
把服务端和客户端串起来:最基本的用法示例
理解了两边的结构后,再看示例的实际调用方式会更清楚。
服务端先注册消息回调并启动监听
serv := NewServer("tcp", "127.0.0.1:6000")
// 消息回调
serv.OnReceive(func(data []byte, conn net.Conn) {
fmt.Println("Server Received: ", string(data))
serv.Send(conn, []byte("okn"))
})
// 开始监听
serv.Listen()这段服务端代码完成了两件事:
- 监听地址
127.0.0.1:6000,网络类型是tcp。 - 每当收到客户端消息时,先打印内容,再回写一条
"okn"。
因为 Listen() 会持续阻塞等待连接,所以它通常就是服务端主流程的入口。
客户端连接后发送消息,再读取返回结果
client, err := NewClient("tcp", "127.0.0.1:6000")
if err != nil {
panic(err)
}
defer client.Close()
// 消息回调
client.OnReceive(func(data []byte, conn net.Conn) {
fmt.Println("Client Received: ", string(data))
})
// 发送消息
client.Send([]byte("啊哈哈n"))
data, err := client.Recv()
if err != nil {
panic(err)
}
fmt.Println("Client Received: ", string(data))
// 如果想保持长连接,持续接收发送消息
client.Open()客户端先调用 NewClient("tcp", "127.0.0.1:6000") 建立连接,失败就直接 panic(err)。连接成功后,先通过 defer client.Close() 保证退出前关闭连接。
接下来它发送一条 "啊哈哈n",随后调用 Recv() 等待服务端响应,并打印返回结果。按服务端示例逻辑,这里应该能读到 "okn"。
最后的 client.Open() 表示另一种运行模式:如果你不满足于“一发一收”这一轮交互,而是希望连接保持着、后续持续收发消息,就可以进入长连接读取循环。
这份示例适合怎么用,落地前要注意什么
作为入门示例,这套代码的价值很明确:它把 Go 里 TCP Socket 最核心的几步都摆出来了,包括监听、连接、读取、回调、发送,以及如何用 goroutine 让每个连接独立运行。对于理解客户端和服务端的交互关系,这样的拆分已经足够直观。
但如果准备继续往真实项目里用,至少要先关注下面几件事:
- 消息分隔符目前写成了
'n',对应发送内容里的"okn"、"啊哈哈n"。如果你的协议不是这样设计的,就需要修改分包方式。 - 服务端
Close()中触发的是connectListener,从语义上看应当检查是否应改为closeListener。 - 客户端的
OnConnect()如果在NewClient()之后再注册,初始连接回调可能不会被触发。 Recv()和Open()都依赖同样的读取规则,使用时要避免和你的协议设计冲突。
如果只是做本地实验、学习 Go Socket 双向通信的基本流程,这份代码已经能跑通一个最小闭环:服务端监听,客户端连接,双方都能发送和接收消息。若要进一步扩展到更稳定的网络程序,再沿着错误处理、协议边界和回调时机这几个方向完善即可。













