用C/C++构建自己的Redis——第二章、协议解析
文章目录
前言
数据结构,很多初学者对它的实际用处了解较少,《Build Your Own Redis with C/C++》讲述了如何从0使用C/C++,运用基本的数据结构,构建属于自己的Redis。前面一章“TCP Server和Client”我们一起学习了,如何通过Socket实现TCP的通信——Client发送请求到Server以及Server返回响应到Client。这一章我们学习如何构建一个简单的Redis服务器,包括协议解析、IO辅助函数、请求处理、客户端代码和测试。
原书链接:https://build-your-own.org/redis/
一、概况
服务器能够同时处理来自客户端的多个请求,因此我们需要实现一种“协议”来将TCP字节流中的请求分隔开,最简单的方式是在请求的开始处声明请求的长度。有如下方案
+-----+------+-----+------+--------
| len | msg1 | len | msg2 | more...
+-----+------+-----+------+--------
该协议由两部分组成:长度申明(这个长度申明的长度为4字节),表示请求的长度,长度为长度申明的请求体。
将上一章的服务器循环代码进行修改,使其可以处理多个请求。
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; // error
}
// only serves one client connection at once
while (true) {
int32_t err = one_request(connfd);
if (err) {
break;
}
}
close(connfd);
}
其中,one_quest函数每次只解析一个请求并作出响应,如果发生异常或者client连接丢失。这里我们只演示处理一个连接,在后面的章节章学习时间循环。
二、实现方法
2.1 I/O Helpers
在学习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; // error, or unexpected 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; // error
}
assert((size_t)rv <= n);
n -= (size_t)rv;
buf += rv;
}
return 0;
}
- read()系统调用:当使用read()函数读取文件或设备数据时,会返回内核中当前可用数据。如果内核没有可用数据,read()会阻塞,知道有数据可以读取。如果读取的数据量不足(即实际读取数据少于请求的数据量),则由应用程序处理,read()只返回当前可用数据,不会等待更多的数据到来。
- read_full()函数:为应用层面函数,它使用read()系统调用读取数据。read_full()会持续调用read(),知道读取指定数量的字节(程序中为n)。这一位着如果read()返回不了足够数据,read_full()会等待并持续读取,知道积累读取到足够的数据。
- write()系统调用:write()函数将数据写入文件或设备。如果内核缓冲区已满,write()可能只写入部分数据,即使返回成功。应用程序需要处理这种部分写入的情况。如果write()返回的字节数少于请求写入的字节数,应用程序应当继续写入剩余的数据,直到所有数据都被写入。
2.2 解析
one_request函数为上述I/O helpers的又一次封装,里面包含繁杂的工作。
// 定义了一个常量k_max_msg,表示允许的最大消息长度
const size_t k_max_msg = 4096;
// 定义了一个静态函数one_request,接受整数参数connfd,表示连接文件的描述符
static int32_t one_request(int connfd) {
// 初始化字符数组rbuf,大小为4+k_max_msg+1。用于存储从客户端接收的数据,其中4字节为存储消息长度,k_max_msg用于存储消内容,额外的1字节为结束符'\0'
char rbuf[4 + k_max_msg + 1];
// 用于错误检测
errno = 0;
// 从连接中读取4字节到rbuf,这4字节表示后续消息的长度。
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;
// 将读取的4字节数据复制到len中
memcpy(&len, rbuf, 4); // assume little endian
// 如果超出允许最大长度,输出错误信息并返回-1
if (len > k_max_msg) {
msg("too long");
return -1;
}
// 从连接中读取len字节的数据到rbuf从第5字节开始的位置
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";
// 初始化字符数组wbuf,存储响应数据
char wbuf[4 + sizeof(reply)];
// 计算reply长度
len = (uint32_t)strlen(reply);
// 将长度信息复制到wbuf的前4字节
memcpy(wbuf, &len, 4);
// 将reply的内容复制到wbuf的第5字节开始的位置
memcpy(&wbuf[4], reply, len);
// 发送响应,将全部数据写入连接中
return write_all(connfd, wbuf, 4 + len);
}
为了方便,这里对最大请求设定了限制,并使用了一个足够大的缓冲区来存储请求。在进行协议解析时,字节序列变的不那么重要了,这里秩序简单的使用memcpy函数复制整数。这里的memcpy是C语言中用于复制内存的函数,他将数据从一个内存位置复制到另一个位置。
2.3 客户端(The Client)
客户端代码:做出请求和接受响应(这部分代码与2.2代码很多字段相似,这里不再进行每一行注释)
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); // assume little endian
memcpy(&wbuf[4], text, len);
if (int32_t err = write_all(fd, wbuf, 4 + len)) {
return err;
}
// 4 bytes header
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); // assume little endian
if (len > k_max_msg) {
msg("too long");
return -1;
}
// reply body
err = read_full(fd, &rbuf[4], len);
if (err) {
msg("read() error");
return err;
}
// do something
rbuf[4 + len] = '\0';
printf("server says: %s\n", &rbuf[4]);
return 0;
}
2.4 测试
测试多行命令
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
die("socket()");
}
// code omitted ...
// multiple requests
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;
}
运行server和client:
$ ./server
client says: hello1
client says: hello2
client says: hello3
EOF
$ ./client
server says: world
server says: world
server says: world
三、更多关于协议的设计
3.1 文本&二进制
在协议设计中,首先要确定的是使用文本协议还是二进制协议。文本协议的有点是易于人类阅读,使得调试过程更加容易。如HTTP协议。
当然这也存在缺点,文本协议较为复杂。即使最简单的文本协议,也需要很多的工作,并且容易出错。例如,文本协议中的字符串长度是可变的,解析代码需要进行大量的长度计算,边界检查和决策。相反使用固定宽度的二进制整数则简单很多
为了避免不必要的复杂性质,建议在设计协议时避免使用可变长的组件。如果可能的话,考虑使用固定长度的字符串,或者考虑是否真的需要使用字符串。
3.2 流式数据
在本章的例子中的协议,在消息开始处包含消息的长度/。然而,在真实应用场景中的协议,通常使用不太明显的方式指示消息的结束。一些应用程序支持“流式”数据,即在不知道输出的完整长度的情况下连续传输数据。HTTP协议中的“分块传输编码”(Chunked Transfer Encoding)就是一个例子。
分块编码将传入数据包装成消息格式,该格式以消息的长度开始。客户端接收到一系列消息,直到一个特殊消息指示流的结束。
还有一种不好的方式是使用特殊字符(分隔符)来指示流的结束。问题是有效负载荷数据编码不能包含分割符,可能需要某种“转义”机制,这增加了协议复杂性。
3.3 进一步考虑
协议解析代码每次请求至少需要2次read()系统调用。通过使用“缓冲IO”,可以减少系统调用的数量。也就是说,一次性尽可能多的读到缓冲区,然后尝试从该缓冲区解析多个请求。这将会在后面章节深入了解。
常见错误:
- 未处理read和write的返回值:这两个函数可能返回比预期更少的字节数,详见read_full函数中的注释。
- 人们常常认为read和write系统调用是“消息”而不是字节流,导致协议无法解析。
- 不要进行过于复杂的设计。
总结
这一章介绍了如何构建一个简单的Redis服务器,包括协议解析、IO辅助函数、请求处理、客户端代码和测试。文章详细描述了如何通过TCP字节流分割请求,实现一个基于长度前缀的协议,并提供了相关的C/C++代码示例。同时,讨论了协议设计中的一些常见问题和优化建议。代码总和如下:
client.cpp:https://build-your-own.org/redis/04/04_client.cpp.htm
server.cpp:https://build-your-own.org/redis/04/04_server.cpp.htm