CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 从零用C语言写一个Redis 引言
  • 02. 套接字入门
  • 03. 简易服务器/客户端
  • 04. 协议解析
    • 04. 协议解析
  • 05. 事件循环与非阻塞I/O
  • 06. 事件循环的实现
  • 07. 基础服务器:实现get、set、del功能
  • 08. 数据结构:哈希表
  • 09. 数据序列化
  • 10. AVL树:实现与测试
  • 11. AVL树和有序集合
  • 12. 事件循环和定时器
  • 13. 堆数据结构和生存时间(TTL)
  • 14. 线程池与异步任务
目录

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
03. 简易服务器/客户端
05. 事件循环与非阻塞I/O

← 03. 简易服务器/客户端 05. 事件循环与非阻塞I/O→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式