04. 协议解析

# 04. 协议解析

咱们的服务器得能处理来自客户端的多个请求,要实现这一点,就得搞个“协议”出来,至少得把TCP字节流里的各个请求给区分开。把请求区分开最简单的办法,就是在请求开头声明请求的长度。咱们就用下面这种方案。

+-----+------+-----+------+--------
| len | msg1 | len | msg2 | more...
+-----+------+-----+------+--------
1
2
3

这个协议由两部分组成:一个4字节的小端序(little - endian)整数,用来表示后面请求的长度,还有一个长度可变的请求内容。

从咱们上一章的代码开始,服务器的循环部分要修改一下,来处理多个请求:

while  (true)  {
    //  accept
    struct  sockaddr_in client_addr =  {};
    socklen_t socklen =  sizeof(client_addr);
    int connfd =  accept(fd,   (struct  sockaddr * )&client_addr,  &socklen);
    if  (connfd <  0)  {
        continue;      // 出错了,跳过这次循环
    }

    // 一次只处理一个客户端连接
    while  (true)  {
        int32_t err =  one_request(connfd);
        if  (err)  {
            break;
        }
    }
    close(connfd);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

one_request函数只负责解析一个请求并回复,直到出问题或者客户端连接断开。在后面章节引入事件循环(event loop)之前,咱们的服务器一次只能处理一个连接。

在列出one_request函数之前,先添加两个辅助函数:

static int32_t read_full(int fd,  char *buf,  size_t n)  {
    while  (n >  0)  {
        ssize_t rv =  read(fd,  buf,  n);
        if  ( rv <=  0)  {
            return  -1;    // 出错了,或者遇到意外的文件结束符(EOF)
        }
        assert((size_t)rv <=  n);
        n -=  (size_t)rv;
        buf +=  rv;
    }
    return  0;
}

static int32_t write_all(int fd,  const char *buf,  size_t n)  {
    while  (n >  0)  {
        ssize_t rv =  write(fd,  buf,  n);
        if  ( rv <=  0)  {
            return  -1;   // 出错了
        }
        assert((size_t)rv <=  n);
        n -=  (size_t)rv;
        buf +=  rv;
    }
    return  0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

这里有两点要注意:

  1. read()系统调用只会返回内核里可用的数据,如果没有数据就会阻塞。处理数据不足的情况得靠应用程序。read_full()函数会一直从内核读取数据,直到读到恰好n字节。
  2. 同样,write()系统调用如果遇到内核缓冲区满了,可能只写入部分数据就返回成功。当write()返回的字节数比我们期望的少时,就得继续尝试写入。

one_request函数才是真正干活的:

const size_t k_max_msg =  4096;

static int32_t one_request(int connfd)  {
    //  4字节的头部
    char rbuf[4 +  k_max_msg +  1];
    errno =  0;
    int32_t err =  read_full(connfd,  rbuf,  4);
    if  (err)  {
        if  (errno ==  0)  {
            msg("EOF");
        }  else  {
            msg("read() error");
        }
        return  err;
    }

    uint32_t len =  0;
    memcpy(&len,  rbuf,  4);    // 假设是小端序
    if  (len >  k_max_msg)  {
        msg("too long");
        return  -1;
    }

    // 请求体
    err =  read_full(connfd,  &rbuf[4],  len);
    if  (err)  {
        msg("read() error");
        return  err;
    }

    // 做点什么
    rbuf[4 +  len]  =   '\0';
    printf("client says: %s\n",  &rbuf[4]);

    // 用同样的协议回复
    const char reply[]  =  "world";
    char wbuf[4 +  sizeof(reply)];
    len =  (uint32_t)strlen(reply);

    memcpy(wbuf,  &len,  4);
    memcpy(&wbuf[4],  reply,  len);
    return  write_all(connfd,  wbuf,  4 +  len);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

为了方便,我们给最大请求大小设了个限制,还弄了个足够大的缓冲区来存放请求。以前解析协议的时候,字节序(Endianness)是个需要考虑的问题,不过现在没那么重要啦,所以我们直接用memcpy来处理整数。

客户端发送请求和接收回复的代码:

static int32_t query(int fd,  const char *text)  {
    uint32_t len =  (uint32_t)strlen(text);
    if  (len >  k_max_msg)  {
        return  -1;
    }

    char wbuf[4 +  k_max_msg];
    memcpy(wbuf,  &len,  4);    // 假设是小端序
    memcpy(&wbuf[4],  text,  len);
    if  (int32_t err =  write_all(fd,  wbuf,  4 +  len))  {
        return  err;
    }

    //  4字节的头部
    char rbuf[4 +  k_max_msg +  1];
    errno =  0;
    int32_t err =  read_full(fd,  rbuf,  4);
    if  (err)  {
        if  (errno ==  0)  {
            msg("EOF");
        }  else  {
            msg("read() error");
        }
        return  err;
    }

    memcpy(&len,  rbuf,  4);    // 假设是小端序
    if  (len >  k_max_msg)  {
        msg("too long");
        return  -1;
    }

    // 回复体
    err =  read_full(fd,  &rbuf[4],  len);
    if  (err)  {
        msg("read() error");
        return  err;
    }

    // 做点什么
    rbuf[4 +  len]  =   '\0';
    printf("server says: %s\n",  &rbuf[4]);
    return  0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

用多个命令来测试咱们的服务器:

int main()  {
    int fd =  socket(AF_INET,  SOCK_STREAM,  0);
    if  (fd <  0)  {
        die("socket()");
    }

    // 省略部分代码...

    // 多个请求
    int32_t err =  query(fd,   "hello1");
    if  (err)  {
        goto  L_DONE;
    }
    err =  query(fd,  "hello2");
    if  (err)  {
        goto  L_DONE;
    }
    err =  query(fd,  "hello3");
    if  (err)  {
        goto  L_DONE;
    }

L_DONE:
    close(fd);
    return  0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

运行服务器和客户端:

$ ./server
client says: hello1
client says: hello2
client says: hello3
EOF
1
2
3
4
5
$ ./client
server says: world
server says: world
server says: world
1
2
3
4

解析协议的代码每个请求至少得用两次read()系统调用。要是用“缓冲I/O(Buffered IO)”的话,系统调用的次数能减少。也就是说:一次性把尽可能多的数据读到缓冲区里,然后试着从这个缓冲区里解析出多个请求。强烈建议大家把这个当作练习题试试,因为这对理解后面的章节可能会有帮助。

关于协议的一些说明:本章用的协议是最简单实用的那种。现实里大多数协议可比这复杂多了。有些协议用文本而不是二进制数据。虽然文本协议有可读性强的优点,但比起二进制协议,文本协议需要更多的解析操作,写代码的时候更麻烦,还容易出错。让协议解析更复杂的是,有些协议没办法直接把消息区分开,这些协议可能会用分隔符,或者需要进一步解析才能拆分消息。要是协议里携带任意数据,使用分隔符就又多了个麻烦事儿,因为数据里的分隔符得进行“转义”处理。后面的章节咱们还是用这个简单的二进制协议。

  • 04_client.cpp
  • 04_server.cpp
上次更新: 2025/03/25, 00:48:42

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>