极客时间已完结课程限时免费阅读

37|代码操练:怎么实现一个TCP服务器?(中)

37|代码操练:怎么实现一个TCP服务器?(中)-极客时间

37|代码操练:怎么实现一个TCP服务器?(中)

讲述:Tony Bai

时长14:56大小13.65M

你好,我是 Tony Bai。
上一讲中,我们讲解了解决 Go 语言学习“最后一公里”的实用思路,那就是“理解问题” -> “技术预研与储备” -> “设计与实现”的三角循环,并且我们也完成了“理解问题”和“技术预研与储备”这两个环节,按照“三角循环”中的思路,这一讲我们应该针对实际问题进行一轮设计与实现了。
今天,我们的目标是实现一个基于 TCP 的自定义应用层协议的通信服务端,要完成这一目标,我们需要建立协议的抽象、实现协议的打包与解包、服务端的组装、验证与优化等工作。一步一步来,我们先在程序世界建立一个对上一讲中自定义应用层协议的抽象。

建立对协议的抽象

程序是对现实世界的抽象。对于现实世界的自定义应用协议规范,我们需要在程序世界建立起对这份协议的抽象。在进行抽象之前,我们先建立这次实现要用的源码项目 tcp-server-demo1,建立的步骤如下:
$mkdir tcp-server-demo1
$cd tcp-server-demo1
$go mod init github.com/bigwhite/tcp-server-demo1
go: creating new go.mod: module github.com/bigwhite/tcp-server-demo1
为了方便学习,我这里再将上一讲中的自定义协议规范贴出来对照参考:

深入协议字段

上一讲,我们没有深入到协议规范中对协议的各个字段进行讲解,但在建立抽象之前,我们有必要了解一下各个字段的具体含义。
这是一个高度简化的、基于二进制模式定义的协议。二进制模式定义的特点,就是采用长度字段标识独立数据包的边界。
在这个协议规范中,我们看到:请求包和应答包的第一个字段(totalLength)都是包的总长度,它就是用来标识包边界的那个字段,也是在应用层用于“分割包”的最重要字段。
请求包与应答包的第二个字段也一样,都是 commandID,这个字段用于标识包类型,这里我们定义四种包类型:
连接请求包(值为 0x01)
消息请求包(值为 0x02)
连接响应包(值为 0x81)
消息响应包(值为 0x82)
换为对应的代码就是:
const (
CommandConn = iota + 0x01 // 0x01,连接请求包
CommandSubmit // 0x02,消息请求包
)
const (
CommandConnAck = iota + 0x81 // 0x81,连接请求的响应包
CommandSubmitAck // 0x82,消息请求的响应包
)
请求包与应答包的第三个字段都是 ID,ID 是每个连接上请求包的消息流水号,顺序累加,步长为 1,循环使用,多用来请求发送方后匹配响应包,所以要求一对请求与响应消息的流水号必须相同。
请求包与响应包唯一的不同之处,就在于最后一个字段:请求包定义了有效载荷(payload),这个字段承载了应用层需要的业务数据;而响应包则定义了请求包的响应状态字段(result),这里其实简化了响应状态字段的取值,成功的响应用 0 表示,如果是失败的响应,无论失败原因是什么,我们都用 1 来表示。
明确了应用层协议的各个字段定义之后,我们接下来就看看如何建立起对这个协议的抽象。

建立 Frame 和 Packet 抽象

首先我们要知道,TCP 连接上的数据是一个没有边界的字节流,但在业务层眼中,没有字节流,只有各种协议消息。因此,无论是从客户端到服务端,还是从服务端到客户端,业务层在连接上看到的都应该是一个挨着一个的协议消息流。
现在我们建立第一个抽象:Frame。每个 Frame 表示一个协议消息,这样在业务层眼中,连接上的字节流就是由一个接着一个 Frame 组成的,如下图所示:
我们的自定义协议就封装在这一个个的 Frame 中。协议规定了将 Frame 分割开来的方法,那就是利用每个 Frame 开始处的 totalLength,每个 Frame 由一个 totalLength 和 Frame 的负载(payload)构成,比如你可以看看下图中左侧的 Frame 结构:
这样,我们通过 Frame header: totalLength 就可以将 Frame 之间隔离开来。
在这个基础上,我们建立协议的第二个抽象:Packet。我们将 Frame payload 定义为一个 Packet。上图右侧展示的就是 Packet 的结构。
Packet 就是业务层真正需要的消息,每个 Packet 由 Packet 头和 Packet Body 部分组成。Packet 头就是 commandID,用于标识这个消息的类型;而 ID 和 payload(packet payload)或 result 字段组成了 Packet 的 Body 部分,对业务层有价值的数据都包含在 Packet Body 部分。
那么到这里,我们就通过 Frame 和 Packet 两个类型结构,完成了程序世界对我们私有协议规范的抽象。接下来,我们要做的就是基于 Frame 和 Packet 这两个概念,实现对我们私有协议的解包与打包操作。

协议的解包与打包

所谓协议的解包(decode),就是指识别 TCP 连接上的字节流,将一组字节“转换”成一个特定类型的协议消息结构,然后这个消息结构会被业务处理逻辑使用。
打包(encode)刚刚好相反,是指将一个特定类型的消息结构转换为一组字节,然后这组字节数据会被放在连接上发送出去。
具体到我们这个自定义协议上,解包就是指字节流 -> Frame,打包是指Frame -> 字节流。你可以看一下针对这个协议的服务端解包与打包的流程图:
我们看到,TCP 流数据先后经过 frame decode 和 packet decode,得到应用层所需的 packet 数据,而业务层回复的响应,则先后经过 packet 的 encode 与 frame 的 encode,写入 TCP 数据流中。
到这里,我们实际上已经完成了协议抽象的设计与解包打包原理的设计过程了。接下来,我们先来看看私有协议部分的相关代码实现。

Frame 的实现

前面说过,协议部分最重要的两个抽象是 Frame 和 Packet,于是我们就在项目中建立 frame 包与 packet 包,分别与两个协议抽象对应。frame 包的职责是提供识别 TCP 流边界的编解码器,我们可以很容易为这样的编解码器,定义出一个统一的接口类型 StreamFrameCodec:
// tcp-server-demo1/frame/frame.go
type FramePayload []byte
type StreamFrameCodec interface {
Encode(io.Writer, FramePayload) error // data -> frame,并写入io.Writer
Decode(io.Reader) (FramePayload, error) // 从io.Reader中提取frame payload,并返回给上层
}
StreamFrameCodec 接口类型有两个方法 Encode 与 Decode。Encode 方法用于将输入的 Frame payload 编码为一个 Frame,然后写入 io.Writer 所代表的输出(outbound)TCP 流中。而 Decode 方法正好相反,它从代表输入(inbound)TCP 流的 io.Reader 中读取一个完整 Frame,并将得到的 Frame payload 解析出来并返回。
这里,我们给出一个针对我们协议的 StreamFrameCodec 接口的实现:
// tcp-server-demo1/frame/frame.go
var ErrShortWrite = errors.New("short write")
var ErrShortRead = errors.New("short read")
type myFrameCodec struct{}
func NewMyFrameCodec() StreamFrameCodec {
return &myFrameCodec{}
}
func (p *myFrameCodec) Encode(w io.Writer, framePayload FramePayload) error {
var f = framePayload
var totalLen int32 = int32(len(framePayload)) + 4
err := binary.Write(w, binary.BigEndian, &totalLen)
if err != nil {
return err
}
n, err := w.Write([]byte(f)) // write the frame payload to outbound stream
if err != nil {
return err
}
if n != len(framePayload) {
return ErrShortWrite
}
return nil
}
func (p *myFrameCodec) Decode(r io.Reader) (FramePayload, error) {
var totalLen int32
err := binary.Read(r, binary.BigEndian, &totalLen)
if err != nil {
return nil, err
}
buf := make([]byte, totalLen-4)
n, err := io.ReadFull(r, buf)
if err != nil {
return nil, err
}
if n != int(totalLen-4) {
return nil, ErrShortRead
}
return FramePayload(buf), nil
}
在在这段实现中,有三点事项需要我们注意:
网络字节序使用大端字节序(BigEndian),因此无论是 Encode 还是 Decode,我们都是用 binary.BigEndian;
binary.Read 或 Write 会根据参数的宽度,读取或写入对应的字节个数的字节,这里 totalLen 使用 int32,那么 Read 或 Write 只会操作数据流中的 4 个字节;
这里没有设置网络 I/O 操作的 Deadline,io.ReadFull 一般会读满你所需的字节数,除非遇到 EOF 或 ErrUnexpectedEOF。
在工程实践中,保证打包与解包正确的最有效方式就是编写单元测试,StreamFrameCodec 接口的 Decode 和 Encode 方法的参数都是接口类型,这让我们可以很容易为 StreamFrameCodec 接口的实现编写测试用例。下面是我为 myFrameCodec 编写了两个测试用例:
// tcp-server-demo1/frame/frame_test.go
func TestEncode(t *testing.T) {
codec := NewMyFrameCodec()
buf := make([]byte, 0, 128)
rw := bytes.NewBuffer(buf)
err := codec.Encode(rw, []byte("hello"))
if err != nil {
t.Errorf("want nil, actual %s", err.Error())
}
// 验证Encode的正确性
var totalLen int32
err = binary.Read(rw, binary.BigEndian, &totalLen)
if err != nil {
t.Errorf("want nil, actual %s", err.Error())
}
if totalLen != 9 {
t.Errorf("want 9, actual %d", totalLen)
}
left := rw.Bytes()
if string(left) != "hello" {
t.Errorf("want hello, actual %s", string(left))
}
}
func TestDecode(t *testing.T) {
codec := NewMyFrameCodec()
data := []byte{0x0, 0x0, 0x0, 0x9, 'h', 'e', 'l', 'l', 'o'}
payload, err := codec.Decode(bytes.NewReader(data))
if err != nil {
t.Errorf("want nil, actual %s", err.Error())
}
if string(payload) != "hello" {
t.Errorf("want hello, actual %s", string(payload))
}
}
我们看到,测试 Encode 方法,我们其实不需要建立真实的网络连接,只要用一个满足 io.Writer 的 bytes.Buffer 实例“冒充”真实网络连接就可以了,同时 bytes.Buffer 类型也实现了 io.Reader 接口,我们可以很方便地从中读取出 Encode 后的内容,并进行校验比对。
为了提升测试覆盖率,我们还需要尽可能让测试覆盖到所有可测的错误执行分支上。这里,我模拟了 Read 或 Write 出错的情况,让执行流进入到 Decode 或 Encode 方法的错误分支中:
type ReturnErrorWriter struct {
W io.Writer
Wn int // 第几次调用Write返回错误
wc int // 写操作次数计数
}
func (w *ReturnErrorWriter) Write(p []byte) (n int, err error) {
w.wc++
if w.wc >= w.Wn {
return 0, errors.New("write error")
}
return w.W.Write(p)
}
type ReturnErrorReader struct {
R io.Reader
Rn int // 第几次调用Read返回错误
rc int // 读操作次数计数
}
func (r *ReturnErrorReader) Read(p []byte) (n int, err error) {
r.rc++
if r.rc >= r.Rn {
return 0, errors.New("read error")
}
return r.R.Read(p)
}
func TestEncodeWithWriteFail(t *testing.T) {
codec := NewMyFrameCodec()
buf := make([]byte, 0, 128)
w := bytes.NewBuffer(buf)
// 模拟binary.Write返回错误
err := codec.Encode(&ReturnErrorWriter{
W: w,
Wn: 1,
}, []byte("hello"))
if err == nil {
t.Errorf("want non-nil, actual nil")
}
// 模拟w.Write返回错误
err = codec.Encode(&ReturnErrorWriter{
W: w,
Wn: 2,
}, []byte("hello"))
if err == nil {
t.Errorf("want non-nil, actual nil")
}
}
func TestDecodeWithReadFail(t *testing.T) {
codec := NewMyFrameCodec()
data := []byte{0x0, 0x0, 0x0, 0x9, 'h', 'e', 'l', 'l', 'o'}
// 模拟binary.Read返回错误
_, err := codec.Decode(&ReturnErrorReader{
R: bytes.NewReader(data),
Rn: 1,
})
if err == nil {
t.Errorf("want non-nil, actual nil")
}
// 模拟io.ReadFull返回错误
_, err = codec.Decode(&ReturnErrorReader{
R: bytes.NewReader(data),
Rn: 2,
})
if err == nil {
t.Errorf("want non-nil, actual nil")
}
}
为了实现错误分支的测试,我们在测试代码源文件中创建了两个类型:ReturnErrorWriter 和 ReturnErrorReader,它们分别实现了 io.Writer 与 io.Reader。
我们可以控制在第几次调用这两个类型的 Write 或 Read 方法时,返回错误,这样就可以让 Encode 或 Decode 方法按照我们的意图,进入到不同错误分支中去。有了这两个用例,我们的 frame 包的测试覆盖率(通过 go test -cover . 可以查看)就可以达到 90% 以上了。

Packet 的实现

接下来,我们再看看 Packet 这个抽象的实现。和 Frame 不同,Packet 有多种类型(这里只定义了 Conn、submit、connack、submit ack)。所以我们要先抽象一下这些类型需要遵循的共同接口:
// tcp-server-demo1/packet/packet.go
type Packet interface {
Decode([]byte) error // []byte -> struct
Encode() ([]byte, error) // struct -> []byte
}
其中,Decode 是将一段字节流数据解码为一个 Packet 类型,可能是 conn,可能是 submit 等,具体我们要根据解码出来的 commandID 判断。而 Encode 则是将一个 Packet 类型编码为一段字节流数据。
考虑到篇幅与复杂性,我们这里只完成 submit 和 submitack 类型的 Packet 接口实现,省略了 conn 流程,也省略 conn 以及 connack 类型的实现,你可以课后自己思考一下有 conn 流程时代码应该如何调整。
// tcp-server-demo1/packet/packet.go
type Submit struct {
ID string
Payload []byte
}
func (s *Submit) Decode(pktBody []byte) error {
s.ID = string(pktBody[:8])
s.Payload = pktBody[8:]
return nil
}
func (s *Submit) Encode() ([]byte, error) {
return bytes.Join([][]byte{[]byte(s.ID[:8]), s.Payload}, nil), nil
}
type SubmitAck struct {
ID string
Result uint8
}
func (s *SubmitAck) Decode(pktBody []byte) error {
s.ID = string(pktBody[0:8])
s.Result = uint8(pktBody[8])
return nil
}
func (s *SubmitAck) Encode() ([]byte, error) {
return bytes.Join([][]byte{[]byte(s.ID[:8]), []byte{s.Result}}, nil), nil
}
这里各种类型的编解码被调用的前提,是明确数据流是什么类型的,因此我们需要在包级提供一个导出的函数 Decode,这个函数负责从字节流中解析出对应的类型(根据 commandID),并调用对应类型的 Decode 方法:
// tcp-server-demo1/packet/packet.go
func Decode(packet []byte) (Packet, error) {
commandID := packet[0]
pktBody := packet[1:]
switch commandID {
case CommandConn:
return nil, nil
case CommandConnAck:
return nil, nil
case CommandSubmit:
s := Submit{}
err := s.Decode(pktBody)
if err != nil {
return nil, err
}
return &s, nil
case CommandSubmitAck:
s := SubmitAck{}
err := s.Decode(pktBody)
if err != nil {
return nil, err
}
return &s, nil
default:
return nil, fmt.Errorf("unknown commandID [%d]", commandID)
}
}
同样,我们也需要包级的 Encode 函数,根据传入的 packet 类型调用对应的 Encode 方法实现对象的编码:
// tcp-server-demo1/packet/packet.go
func Encode(p Packet) ([]byte, error) {
var commandID uint8
var pktBody []byte
var err error
switch t := p.(type) {
case *Submit:
commandID = CommandSubmit
pktBody, err = p.Encode()
if err != nil {
return nil, err
}
case *SubmitAck:
commandID = CommandSubmitAck
pktBody, err = p.Encode()
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown type [%s]", t)
}
return bytes.Join([][]byte{[]byte{commandID}, pktBody}, nil), nil
}
不过,对 packet 包中各个类型的 Encode 和 Decode 方法的测试,与 frame 包的相似,这里我就把为 packet 包编写单元测试的任务就交给你自己完成了,如果有什么问题欢迎在留言区留言。
好了,万事俱备,只欠东风!下面我们就来编写服务端的程序结构,将 tcp conn 与 Frame、Packet 连接起来。

服务端的组装

在上一讲中,我们按照每个连接一个 Goroutine 的模型,给出了典型 Go 网络服务端程序的结构,这里我们就以这个结构为基础,将 Frame、Packet 加进来,形成我们的第一版服务端实现:
// tcp-server-demo1/cmd/server/main.go
package main
import (
"fmt"
"net"
"github.com/bigwhite/tcp-server-demo1/frame"
"github.com/bigwhite/tcp-server-demo1/packet"
)
func handlePacket(framePayload []byte) (ackFramePayload []byte, err error) {
var p packet.Packet
p, err = packet.Decode(framePayload)
if err != nil {
fmt.Println("handleConn: packet decode error:", err)
return
}
switch p.(type) {
case *packet.Submit:
submit := p.(*packet.Submit)
fmt.Printf("recv submit: id = %s, payload=%s\n", submit.ID, string(submit.Payload))
submitAck := &packet.SubmitAck{
ID: submit.ID,
Result: 0,
}
ackFramePayload, err = packet.Encode(submitAck)
if err != nil {
fmt.Println("handleConn: packet encode error:", err)
return nil, err
}
return ackFramePayload, nil
default:
return nil, fmt.Errorf("unknown packet type")
}
}
func handleConn(c net.Conn) {
defer c.Close()
frameCodec := frame.NewMyFrameCodec()
for {
// decode the frame to get the payload
framePayload, err := frameCodec.Decode(c)
if err != nil {
fmt.Println("handleConn: frame decode error:", err)
return
}
// do something with the packet
ackFramePayload, err := handlePacket(framePayload)
if err != nil {
fmt.Println("handleConn: handle packet error:", err)
return
}
// write ack frame to the connection
err = frameCodec.Encode(c, ackFramePayload)
if err != nil {
fmt.Println("handleConn: frame encode error:", err)
return
}
}
}
func main() {
l, err := net.Listen("tcp", ":8888")
if err != nil {
fmt.Println("listen error:", err)
return
}
for {
c, err := l.Accept()
if err != nil {
fmt.Println("accept error:", err)
break
}
// start a new goroutine to handle the new connection.
go handleConn(c)
}
}
这个程序的逻辑非常清晰,服务端程序监听 8888 端口,并在每次调用 Accept 方法后得到一个新连接,服务端程序将这个新连接交到一个新的 Goroutine 中处理。
新 Goroutine 的主函数为 handleConn,有了 Packet 和 Frame 这两个抽象的加持,这个函数同样拥有清晰的代码调用结构:
// handleConn的调用结构
read frame from conn
->frame decode
-> handle packet
-> packet decode
-> packet(ack) encode
->frame(ack) encode
write ack frame to conn
到这里,一个基于 TCP 的自定义应用层协议的经典阻塞式的服务端就完成了。不过这里的服务端依旧是一个简化的实现,比如我们这里没有考虑支持优雅退出、没有捕捉某个链接上出现的可能导致整个程序退出的 panic 等,这些我也想作为作业留给你。
接下来,我们就来验证一下这个服务端实现是否能正常工作。

验证测试

要验证服务端的实现是否可以正常工作,我们需要实现一个自定义应用层协议的客户端。这里,我们同样基于 frame、packet 两个包,实现了一个自定义应用层协议的客户端。下面是客户端的 main 函数:
// tcp-server-demo1/cmd/client/main.go
func main() {
var wg sync.WaitGroup
var num int = 5
wg.Add(5)
for i := 0; i < num; i++ {
go func(i int) {
defer wg.Done()
startClient(i)
}(i + 1)
}
wg.Wait()
}
我们看到,客户端启动了 5 个 Goroutine,模拟 5 个并发连接。startClient 函数是每个连接的主处理函数,我们来看一下:
func startClient(i int) {
quit := make(chan struct{})
done := make(chan struct{})
conn, err := net.Dial("tcp", ":8888")
if err != nil {
fmt.Println("dial error:", err)
return
}
defer conn.Close()
fmt.Printf("[client %d]: dial ok", i)
// 生成payload
rng, err := codename.DefaultRNG()
if err != nil {
panic(err)
}
frameCodec := frame.NewMyFrameCodec()
var counter int
go func() {
// handle ack
for {
select {
case <-quit:
done <- struct{}{}
return
default:
}
conn.SetReadDeadline(time.Now().Add(time.Second * 5))
ackFramePayLoad, err := frameCodec.Decode(conn)
if err != nil {
if e, ok := err.(net.Error); ok {
if e.Timeout() {
continue
}
}
panic(err)
}
p, err := packet.Decode(ackFramePayLoad)
submitAck, ok := p.(*packet.SubmitAck)
if !ok {
panic("not submitack")
}
fmt.Printf("[client %d]: the result of submit ack[%s] is %d\n", i, submitAck.ID, submitAck.Result)
}
}()
for {
// send submit
counter++
id := fmt.Sprintf("%08d", counter) // 8 byte string
payload := codename.Generate(rng, 4)
s := &packet.Submit{
ID: id,
Payload: []byte(payload),
}
framePayload, err := packet.Encode(s)
if err != nil {
panic(err)
}
fmt.Printf("[client %d]: send submit id = %s, payload=%s, frame length = %d\n",
i, s.ID, s.Payload, len(framePayload)+4)
err = frameCodec.Encode(conn, framePayload)
if err != nil {
panic(err)
}
time.Sleep(1 * time.Second)
if counter >= 10 {
quit <- struct{}{}
<-done
fmt.Printf("[client %d]: exit ok", i)
return
}
}
}
关于 startClient 函数,我们需要简单说明几点。
首先,startClient 函数启动了两个 Goroutine,一个负责向服务端发送 submit 消息请求,另外一个 Goroutine 则负责读取服务端返回的响应;
其次,客户端发送的 submit 请求的负载(payload)是由第三方包 github.com/lucasepe/codename 负责生成的,这个包会生成一些对人类可读的随机字符串,比如:firm-iron、 moving-colleen、game-nova 这样的字符串;
另外,负责读取服务端返回响应的 Goroutine,使用 SetReadDeadline 方法设置了读超时,这主要是考虑该 Goroutine 可以在收到退出通知时,能及时从 Read 阻塞中跳出来。
好了,现在我们就来构建和运行一下这两个程序。
我在 tcp-server-demo1 目录下提供了 Makefile,如果你使用的是 Linux 或 macOS 操作系统,可以直接敲入 make 构建两个程序,如果你是在 Windows 下构建,可以直接敲入下面的 go build 命令构建:
$make
go build github.com/bigwhite/tcp-server-demo1/cmd/server
go build github.com/bigwhite/tcp-server-demo1/cmd/client
构建成功后,我们先来启动 server 程序:
$./server
server start ok(on *.8888)
然后,我们启动 client 程序,启动后 client 程序便会向服务端建立 5 条连接,并发送 submit 请求,client 端的部分日志如下:
$./client
[client 5]: dial ok
[client 1]: dial ok
[client 5]: send submit id = 00000001, payload=credible-deathstrike-33e1, frame length = 38
[client 3]: dial ok
[client 1]: send submit id = 00000001, payload=helped-lester-8f15, frame length = 31
[client 4]: dial ok
[client 4]: send submit id = 00000001, payload=strong-timeslip-07fa, frame length = 33
[client 3]: send submit id = 00000001, payload=wondrous-expediter-136e, frame length = 36
[client 5]: the result of submit ack[00000001] is 0
[client 1]: the result of submit ack[00000001] is 0
[client 3]: the result of submit ack[00000001] is 0
[client 2]: dial ok
... ...
[client 3]: send submit id = 00000010, payload=bright-monster-badoon-5719, frame length = 39
[client 4]: send submit id = 00000010, payload=crucial-wallop-ec2d, frame length = 32
[client 2]: send submit id = 00000010, payload=pro-caliban-c803, frame length = 29
[client 1]: send submit id = 00000010, payload=legible-shredder-3d81, frame length = 34
[client 5]: send submit id = 00000010, payload=settled-iron-monger-bf78, frame length = 37
[client 3]: the result of submit ack[00000010] is 0
[client 4]: the result of submit ack[00000010] is 0
[client 1]: the result of submit ack[00000010] is 0
[client 2]: the result of submit ack[00000010] is 0
[client 5]: the result of submit ack[00000010] is 0
[client 4]: exit ok
[client 1]: exit ok
[client 3]: exit ok
[client 5]: exit ok
[client 2]: exit ok
client 在每条连接上发送 10 个 submit 请求后退出。这期间服务端会输出如下日志:
recv submit: id = 00000001, payload=credible-deathstrike-33e1
recv submit: id = 00000001, payload=helped-lester-8f15
recv submit: id = 00000001, payload=wondrous-expediter-136e
recv submit: id = 00000001, payload=strong-timeslip-07fa
recv submit: id = 00000001, payload=delicate-leatherneck-4b12
recv submit: id = 00000002, payload=certain-deadpool-779d
recv submit: id = 00000002, payload=clever-vapor-25ce
recv submit: id = 00000002, payload=causal-guardian-4f84
recv submit: id = 00000002, payload=noted-tombstone-1b3e
... ...
recv submit: id = 00000010, payload=settled-iron-monger-bf78
recv submit: id = 00000010, payload=pro-caliban-c803
recv submit: id = 00000010, payload=legible-shredder-3d81
handleConn: frame decode error: EOF
handleConn: frame decode error: EOF
handleConn: frame decode error: EOF
handleConn: frame decode error: EOF
handleConn: frame decode error: EOF
从结果来看,我们实现的这一版服务端运行正常!

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在上一讲完成对 socket 编程模型、网络 I/O 操作的技术预研后,这一讲我们正式进入基于 TCP 的自定义应用层协议的通信服务端的设计与实现环节。
在这一环节中,我们首先建立了对协议的抽象,这是实现通信服务端的基石。我们使用 Frame 的概念来表示 TCP 字节流中的每一个协议消息,这使得在业务层的视角下,连接上的字节流就是由一个接着一个 Frame 组成的。接下来,我们又建立了第二个抽象 Packet,来表示业务层真正需要的消息。
在这两个抽象的基础上,我们实现了 frame 与 packet 各自的打包与解包,整个实现是低耦合的,我们可以在对 frame 编写测试用例时体会到这一点。
最后,我们把上一讲提到的、一个 Goroutine 负责处理一个连接的典型 Go 网络服务端程序结构与 frame、packet 的实现组装到一起,就实现了我们的第一版服务端。之后,我们还编写了客户端模拟器对这个服务端的实现做了验证。
这个服务端采用的是 Go 经典阻塞 I/O 的编程模型,你是不是已经感受到了这种模型在开发阶段带来的好处了呢!

思考题

在这讲的中间部分,我已经把作业留给你了:
为 packet 包编写单元测试;
为我们的服务端增加优雅退出机制,以及捕捉某个链接上出现的可能导致整个程序退出的 panic。

项目的源代码在这里!

分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 14

提建议

上一篇
36|打稳根基:怎么实现一个TCP服务器?(上)
下一篇
38|成果优化:怎么实现一个TCP服务器?(下)
unpreview
 写留言

精选留言(21)

  • Aeins
    2022-06-07
    几点疑问 1. 协议处理程序保证使用相同的字节序的情况下,有必要一定用大端序吗,改成小端序,也能成功。 2. TCP 保证顺序交付的,不指定字节序,顺序处理数据流可以吗。这时会有字节序问题吗,如果协议栈都使用同一种字节序呢。(我认为字节序和程序使用的字节序有关,如果每个程序都使用同一种字节序,那应该就不存在字节序问题了,比如本程序,收发都用相同的字节序处理,不知道这个结论对不对) 3. 协议头和协议体,分两次写入的,会不会有并发安全问题,为什么?这里应该没做到上节课说的,一次写入一个“业务包”吧。 4. 多次运行 client,错误偶发。有时 io.ReadFull 读不满数据,有时读取的数据长度不对,会是哪些原因导致的呢?
    展开

    作者回复: 问题很棒! 这里逐一回答一下: 1、2:网络字节序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。 3. 按照每个连接一个 Goroutine 的模型,不是并发写,不存在你说的问题。 4. go doc io.ReadFull一下,一般情况下,ReadFull都会读出你想要的长度的数据。你遇到错误时,ReadFull返回什么error呢。 [upd]: 发现问题了。是client的SetReadDeadline设置为1s,太短了。已改,请pull最新demo代码。

    5
  • 左耳朵东
    2022-02-12
    client 代码中的 done chan 好像没必要吧,去掉它也能正常退出

    作者回复: 这里的确没必要。但是如果handle ack的goroutine在退出前需要执行一些清理工作,那么done就有必要了。否则可能会出现handle ack的goroutine没有执行完清理工作,send goroutine就退出的进而导致main goroutine退出前某handle ack的goroutine都没有执行完清理工作。

    共 2 条评论
    3
  • 罗杰
    2022-01-26
    还是老师实现的代码优雅,我们项目的这块代码是刚开始学 Go 时实现的,只能说可以用。但对比老师的实现,我觉得我们的代码可以好好优化一下了。

    作者回复: 👍

    2
  • 2022-07-28 来自陕西
    // select { case <-quit: done <- struct{}{} return default: } 老师,client中读取服务端返回响应的这个goroutine中,这段select的作用不是很理解,如果没有从quit中收到值就会一直轮询,但是从quit中收到值又会return,那下面的代码不是一直都没有机会执行了吗
    展开

    作者回复: 如果没有从quit中收到值,是会轮询啊。不过每次轮询的间隔是5s,程序会先在socket上做阻塞读,直到超时。超时后就回到for开始处,这也给了goroutine一个优雅退出的机会。

    共 2 条评论
    1
  • Calvin
    2022-01-26
    老师,Conn 和 ConnAck 要实现的话,请问从业务中来讲,一般会需要发送一些什么 Payload 呢?我看这里的例子没有他们也可以正常运行整个流程,是类似 需要认证的系统中的登录账号和密码 的这种内容吗?

    作者回复: 对的,conn流程一般有一个身份验证的过程。考虑到篇幅,文中没有加conn和connack,如果加上,篇幅就要超出许多。想想思路就好,如果要实现,可以自定义一个conn消息体,然后练练手。

    1
  • 骨汤鸡蛋面
    2022-01-26
    老师,一些rpc框架学习 http2 的stream概念,在connection与协议之间加了一个stream层, 这块主要抽象了啥,很想听一下老师的看法。

    作者回复: 我觉得更多是对通信两端交互模式的抽象。

    共 2 条评论
    1
  • zhu见见
    2023-01-19 来自重庆
    done 这个chan的意义是啥呢?为了让startClient 晚于内部的go func 执行完吗?

    作者回复: 看一下本讲的评论区的类似的问题的答复吧。

  • 张尘
    2022-12-27 来自北京
    白老师好, 本节课受益颇多, 有点疑问, 还望有时间能够帮忙解答下: frameCodec.Decode返回值是自定义数据结构FramePayload packet.Decode的入参是[]byte client/server 代码中直接将FramePayload当做[]byte使用 frameCodec.Decode为什么要返回自定义数据结构FramePayload而不是[]byte呢? 是因为FramePayload的结构可能改变吗? FramePayload可能不是[]byte吗? FramePayload可能包含Packet之外的其它数据吗? 可是如果FramePayload的结构改变, 那client/server 的代码中直接将FramePayload当做[]byte的用法不是就有问题了吗?
    展开

    作者回复: 可以直接使用[]byte类型,这里定义FramePayload更多为了强调其是frame的payload,仅此而已。

  • Sunrise
    2022-11-24 来自辽宁
    有个小疑问: func (p *myFrameCodec) Encode(w io.Writer, framePayload FramePayload) error { var f = framePayload ... } var f = framePayload 这个地方有必要重新定义一个 f 吗,直接使用 framePayload 会有什么问题?
    展开

    作者回复: “直接使用 framePayload ” 也没有问题。

  • 农民园丁
    2022-11-02 来自北京
    请问老师,framePayload, err := frameCodec.Decode(c) 以上代码中"c"是net.Conn 类型, 而frameCodec.Decode(io.Reader)的输入参数是io.Reader, 这两个为什么可以不一样?

    作者回复: net.Conn可以理解为io.Reader这个接口类型的方法集合的超集,也就是说所有实现了net.Conn的类型,也都实现了io.Reader接口类型。

  • Jay
    2022-09-24 来自辽宁
    func (p *myFrameCodec) Encode(w io.Writer, framePayload FramePayload) error { var f = framePayload var totalLen int32 = int32(len(framePayload)) + 4 ... } 以上方法的第二行处有个疑问: 为什么要额外创建一个方法参数 framePayload 的拷贝 f 呢?直接使用 framePayload 传入 w.Write() 方法会有什么问题吗?
    展开

    作者回复: 这块的f可以不用,直接用framePayload应该没有问题。

  • 中年编程人员
    2022-06-30
    老师你好,frame中,var totalLen int32 = int32(len(framePayload)) + 4;这个totalLen为啥要加4呢??

    作者回复: 加上totalLen自身的长度啊:用4个字节表示totalLen。

  • Geek_25f93f
    2022-06-19
    老师,单元测试的代码是不是有点问题,就判断条件是if err == nil

    作者回复: 你指的是TestEncodeWithWriteFail这个unit test? 这个测试就是为了测试Encode失败的情况。只有err == nil的情况下,才不符合我们的预期。

  • qiutian
    2022-06-10
    // tcp-server-demo1/packet/packet.go func Encode(p Packet) ([]byte, error) { var commandID uint8 var pktBody []byte var err error switch t := p.(type) { case *Submit: commandID = CommandSubmit pktBody, err = p.Encode() if err != nil { return nil, err } case *SubmitAck: commandID = CommandSubmitAck pktBody, err = p.Encode() if err != nil { return nil, err } default: return nil, fmt.Errorf("unknown type [%s]", t) } return bytes.Join([][]byte{[]byte{commandID}, pktBody}, nil), nil } 老师,这段代码的最后的 return bytes.Join(), nil这个在什么情况下回运行到呢?不是很理解
    展开

    作者回复: return语句最后的nil是代表err=nil,就是一切ok,没有报错。Encode函数的原型,最后一个返回值是一个error类型。

    共 2 条评论
  • 南方虚心竹
    2022-06-07
    frame.go: func (p *myFrameCodec) Decode(r io.Reader) (FramePayload, error) { ... buf := make([]byte, totalLen-4) // 这行在运行的时候在跑的时候会panic ... } // panic: runtime error: makeslice: len out of range 打印出来是一个很大的负数 大概率会panic,小概率会pass,感觉是多线程下出现的问题 求老师解答一下
    展开

    作者回复: 的确是问题。但不是这里的问题。是demo中client的SetReadDeadline设置为1s,太短了。已改,请pull最新demo代码。

  • 南方虚心竹
    2022-06-07
    packet.go 中 Submit 映射的Decode方法 第二行中使用 s.ID = string(pktBody[:8]) 写法在运行client的时候和会出现 panic: runtime error: makeslice: len out of range 修改为老师git当中的s.ID = string(pktBody[0:8]) 后可以正常运行。(我的环境是MacOS12-intelCPU) 想请教下老师这里的 pktBody[:8] 和 pktBody[0:8] 不是等价的吗? // 附上文章中的代码 func (s *Submit) Decode(pktBody []byte) error { s.ID = string(pktBody[:8]) s.Payload = pktBody[8:] return nil }
    展开

    作者回复: pktBody[:8] 和 pktBody[0:8] 是等价的。

  • 晚枫
    2022-05-09
    为什么totalLen指定了字节序,payload不需要指定吗?

    作者回复: 字节序是针对size>=2个字节的整型数而言的。payload对于该协议来说只是一个“字节序列”。协议的任务就是解析出payload,然后交给上层处理。

    共 2 条评论
  • $侯
    2022-04-26
    请问 data := []byte{0x0, 0x0, 0x0, 0x9, 'h', 'e', 'l', 'l', 'o'} 中的0x0, 0x0, 0x0, 0x9代表的是什么意思

    作者回复: 0x0、0x9都是以16进制数表示的byte值啊。byte本质上就是uint8(type byte = uint8),一个整型数而已。

    共 2 条评论
  • 酒醒何处
    2022-03-06
    CommandConnAck = iota + 0x80 // 0x81,连接请求的响应包 这儿应该是: CommandConnAck = iota + 0x81 // 0x81,连接请求的响应包

    作者回复: 看的真细致!👍 感谢指出笔误,后续让编辑老师改一下。

  • 2022-03-02
    老师,Makefile的相关知识考虑在加餐要说下吗

    作者回复: Makefile在本专栏中不会展开说了。一方面,Makefile与Go并非紧耦合,也并非必须,只是构建辅助工具的一种。另一方面,本专栏中使用的Makefile的内容都是最简单的,一般只有一个target,容易理解,不需要展开大家应该都能理解。 学习过程中,有有关makefile的问题,可以在留言区提出,我尽力解答。

新人学习立返 50% 购课币
去订阅《Tony Bai · Go语言第一课》新人首单¥59