TCP协议位于TCP/IP协议栈的传输层,它是一种面向连接的可靠流式传输协议,应用非常广泛,网络编程中很多功能都可以基于TCP来实现。Go语言标准库提供的net
包能够很方便的实现基于套接字的TCP编程,这篇笔记我们介绍如何开发TCP服务端和客户端程序。
使用Go语言实现TCP服务端非常简单,和UnixC网络编程或是其它编程语言类似,都遵循如下几个步骤:
下面例子代码实现了一个简单的TCP服务器,它能够并发处理多个连接,服务端接收文本数据,并附加一些内容返回。
package main
import (
"bufio"
"io"
"log/slog"
"net"
)
func handleConn(conn net.Conn) {
// 处理完成关闭TCP连接
defer func(conn net.Conn) {
if err := conn.Close(); err != nil {
slog.Error(err.Error())
return
}
}(conn)
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
buffer := make([]byte, 1024)
for {
// 从连接中读取数据
n, err := reader.Read(buffer)
if err != nil {
if err == io.EOF {
slog.Info("Client EOF disconnected")
} else {
slog.Error(err.Error())
}
return
}
data := string(buffer[:n])
slog.Info("Recv: " + data)
// 向连接写入数据
if _, err := writer.Write([]byte("Echo: " + data)); err != nil {
slog.Error(err.Error())
return
}
if err = writer.Flush(); err != nil {
slog.Error(err.Error())
return
}
}
}
func main() {
// 启动TCP服务端监听localhost:8000
listen, err := net.Listen("tcp", "192.168.1.100:8000")
if err != nil {
slog.Error(err.Error())
return
}
slog.Info("TCP server listening on 192.168.1.100:8000...")
for {
conn, err := listen.Accept()
if err != nil {
slog.Error(err.Error())
continue
}
slog.Info("Connected from " + conn.RemoteAddr().String())
// 启动新协程处理连接
go handleConn(conn)
}
}
代码中,我们使用net
包的Listen()
函数建立套接字监听,参数中我们指定协议为TCP,主机和地址为192.168.1.100:8000
,其中192.168.1.100
是当前主机一块网卡的IP地址,如果我们不确定或者想在当前主机所有的IP地址上监听,则可以使用0.0.0.0:8000
监听所有网络接口,或者直接简写为:8000
。
随后我们编写了一个无限循环,循环内部使用listen.Accept()
方法阻塞等待新的连接,当新的连接建立时,就会启动新的协程来处理连接。
连接处理协程函数handleConn()
内部逻辑非常简单,它的内部也是一个无限循环,连接对象conn
实现了io.Reader
和io.Writer
方法,代码中我们还使用了带缓冲区的bufio
对其进行包装,我们调用Read()
方法从连接中阻塞等待数据,读取到数据时就附加一个Echo:
前缀原样写回连接中。当客户端希望断开连接时,n, err := reader.Read(buffer)
这行代码会收到EOF
错误,这样我们就知道客户端要断开连接了,我们此时即可关闭连接,退出协程函数。
此外,网络编程新手还有一个格外要注意的点,网络编程中我们经常编写一些循环处理数据,像reader
、writer
、缓冲区数组等能在循环外部创建的就不必在循环内部重复创建,这样效率不高。
我们可以使用Netcat工具测试TCP服务端,执行以下命令建立到服务端的TCP连接。
nc 192.168.1.100 8000
我们可以随意输入文本并查看服务端返回的消息。
以上代码仅用于演示Go语言中如何使用TCP传输数据,实际上,如果有网络编程的相关经验就会发现上面代码有个问题。对于我们前面写的这个很不完善的TCP服务端,如果你的手速够快或者写一个程序快速发送数据,你可能会发现有时发给服务端的两条“消息”黏在了一起,有时一条“消息”又被拆开了以至于服务端只处理了半条,这是正常现象。我们期待这个TCP服务端工作于一种类似请求/响应的模式,但TCP本身是一种流协议,也就是说在传输层并没有什么所谓的“消息”,我们的服务端和客户端接收和发送的都是数据流。
如果想要实现一个真正的基于消息(或者称之为报文、数据包等)的TCP服务端,我们还需要适当的消息分割机制,通过代码从数据流中真正的提取和组装出一条一条的“消息”,最简单的解决方式包括定长消息、长度前缀或是消息分隔符等,这本质上其实就是基于TCP实现一个新的应用层协议,具体将在后续章节介绍。
实现TCP客户端的代码相对简单,我们只需要打开TCP连接,并向连接写入和读取数据就行了。
下面代码我们实现了TCP客户端,它能接收从标准输入(终端)键入的内容并发送到服务端,此外代码中我们还单独启动了一个新的协程打印服务端返回的数据。
package main
import (
"bufio"
"io"
"log/slog"
"net"
"os"
)
func printText(conn net.Conn) {
reader := bufio.NewReader(conn)
buffer := make([]byte, 1024)
for {
// 从连接中读取数据
n, err := reader.Read(buffer)
if err != nil {
if err == io.EOF {
slog.Info("Server EOF disconnected")
} else {
slog.Error(err.Error())
}
return
}
slog.Info(string(buffer[:n]))
}
}
func main() {
// 创建TCP连接
conn, err := net.Dial("tcp", "192.168.1.100:8000")
if err != nil {
slog.Error(err.Error())
return
}
// 运行结束后关闭连接
defer func(conn net.Conn) {
if err := conn.Close(); err != nil {
slog.Error(err.Error())
return
}
}(conn)
// 启动新协程用于接收消息
go printText(conn)
reader := bufio.NewReader(os.Stdin)
writer := bufio.NewWriter(conn)
for {
// 从标准输入读取用户输入的内容
line, err := reader.ReadString('\n')
if err != nil {
slog.Error(err.Error())
return
}
// 写数据到连接中
if _, err = writer.Write([]byte(line)); err != nil {
slog.Error(err.Error())
return
}
if err = writer.Flush(); err != nil {
slog.Error(err.Error())
return
}
}
}
TCP客户端中,我们使用net
包的Dial()
函数建立TCP连接,代码中我们采用了一个子协程的方式同时处理写数据和读数据操作,其中printText()
函数就用于从TCP连接中读数据并打印,根据官方文档,net.Conn
支持并发读写,即一个协程读数据、一个协程写数据是被允许的,但如果对一个连接用多个协程并发读或并发写则可能会产生不可预期的结果。其它对于连接写入和读取数据的写法和服务端大同小异,这里就不赘述了。
这里我们可以直接用前面写的TCP服务端测试TCP客户端,或者用Netcat建立一个新的TCP服务端来测试也可以,效果类似如下图。