请求-响应协议
4.1 单个连接中的多个请求
服务器循环
我们暂时先忽略并发连接的情况。我们将通过一个循环让服务器在单个连接中处理多个请求。
while (true) {
// 接受连接
struct sockaddr_in client_addr = {};
socklen_t addrlen = sizeof(client_addr);
int connfd = accept(fd, (struct sockaddr *)&client_addr, &addrlen);
if (connfd < 0) {
continue; // 错误
}
// 一次仅服务一个客户端连接
while (true) {
int32_t err = one_request(connfd);
if (err) {
break;
}
}
close(connfd);
}
one_request
函数将读取一个请求并回复一个响应。
源代码在文章末尾
问题在于,它如何知道要读取多少字节的数据呢?这是应用层协议的主要功能。通常,一个协议有两层结构:
- 1. 用于将字节流分割成消息的高层结构。
- 2. 消息内部的结构,也就是反序列化。
一个简单的二进制协议
我们要做的第一步是将字节流分割成消息。目前,请求消息和响应消息都只是字符串。
┌─────┬──────┬─────┬──────┬────────
│ len │ msg1 │ len │ msg2 │ more...
└─────┴──────┴─────┴──────┴────────
4B ... 4B ...
每个消息都由一个4字节的小端序整数开头,该整数表示请求的长度,后面跟着可变长度的负载。这不是真正的Redis协议。我们稍后会讨论其他的协议设计。
4.2 解析协议
检查read/write的返回值
read
/write
会返回实际读取/写入的字节数。如果出错,返回值为 -1。read
在遇到文件结束(EOF,end of file/connection)时也会返回 0。
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
要读取一个消息,首先读取4字节的整数,然后读取负载部分。你可以这样想象读取的过程:
// 错误示例!
uint32_t n;
char payload[MAX_PAYLOAD];
rv = read(fd, &n, 4);
if (rv != 4) { /* 错误 */ }
rv = read(fd, &payload, n);
if (rv != n) { /* 错误 */ }
写入的过程可以这样想象:
// 错误示例!
rv = write(fd, &n, 4);
if (rv != 4) { /* 错误 */ }
rv = write(fd, &payload, n);
if (rv != n) { /* 错误 */ }
这两种处理TCP套接字的方式都是错误的,因为在正常情况下(没有错误,也没有到达EOF),read
/write
返回的字节数可能会少于请求的字节数。为什么会这样呢?稍后会解释。
一个常见的错误是认为 read
操作在某种程度上与对等方的 write
操作相对应。这是不可能的,因为字节流不会保留任何边界信息。
read_full
和 write_all
要从TCP套接字中实际读取/写入 n
字节的数据,你必须使用循环来实现。
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;
}
read
返回的任何数据都会累积到一个缓冲区中。重要的是你拥有了多少数据,而不是单个 read
操作返回了多少数据。
解析请求并生成响应
在服务器程序中,使用 read_full
和 write_all
来代替 read
和 write
。
const size_t k_max_msg = 4096;
static int32_t one_request(int connfd) {
// 4 字节的头部
char rbuf[4 + k_max_msg];
errno = 0;
int32_t err = read_full(connfd, rbuf, 4);
if (err) {
msg(errno == 0 ? "EOF" : "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;
}
// 进行一些处理
printf("client says: %.*s\n", len, &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);
}
errno
的注意事项
如果系统调用失败,errno
会被设置为错误码。然而,如果系统调用成功,errno
并不会被设置为 0,它只是保持之前的值。这就是为什么上面的代码在调用 read_full()
之前将 errno
设置为 0,以便区分到达 EOF 的情况。
只有在调用失败时,你才可以读取 errno
的值。但是有些 libc
函数除了先清除 errno
之外,没有其他方法来判断调用是否失败:
errno = 0;
int val = atoi("0"); // 出错时返回 0,但 0 也是一个有效的结果
if (errno) { /* 失败 */ }
errno
在C语言中是一个不太好的设计。Linux内核根本不使用它;系统调用实际上会将错误码作为一个负整数返回,是 libc
中的系统调用包装器将错误码放入了 errno
中。将错误码和结果混在一起仍然是一个不好的设计,一种更合理的方式是这样的:
int32_t read(int fd, void *buf, size_t size, size_t *actually_read);
// 返回错误码,通过指针输出结果。
4.3 客户端和测试
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) {
msg(errno == 0 ? "EOF" : "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;
}
// 进行一些处理
printf("server says: %.*s\n", len, &rbuf[4]);
return 0;
}
通过发送几个命令来测试我们的服务器:
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;
}
L_DONE:
close(fd);
return 0;
}
运行服务器和客户端:
$ ./server
client says: hello1
client says: hello2
EOF
$ ./client
server says: world
server says: world
4.4 理解read/write
TCP套接字与磁盘文件
为什么需要 read_full
呢?尽管读取磁盘文件和读取套接字共享相同的 read
/write
API,但它们之间还是存在差异的。当读取磁盘文件时,如果返回的字节数少于请求的字节数,这意味着要么到达了 EOF,要么出现了错误。但是对于套接字,即使在正常情况下,它也可能返回较少的数据。这可以用基于拉取的输入输出(pull-based IO)和基于推送的输入输出(push-based IO)来解释。
网络上的数据是由远程对等方推送的。远程对等方在发送数据之前不需要等待 read
调用。内核会分配一个接收缓冲区来存储接收到的数据。read
只是将接收缓冲区中可用的数据复制到用户空间缓冲区中,因为无法确定是否还有更多正在传输的数据。
来自本地文件的数据是从磁盘中拉取的。数据总是被认为是 “准备好的”,并且文件大小是已知的。除非到达 EOF,否则没有理由返回少于请求的数据量。
被中断的系统调用
为什么需要 write_all
呢?通常,write
只是将数据追加到内核侧的缓冲区中,实际的网络传输会延迟到操作系统进行处理。缓冲区的大小是有限的,所以当缓冲区满了时,调用者必须等待缓冲区中的数据被发送出去,然后才能复制剩余的数据。在等待过程中,系统调用可能会被一个信号中断,导致 write
返回时只写入了部分数据。
read
也可能会被信号中断,因为如果缓冲区为空,它必须等待。在这种情况下,读取的字节数为 0,但返回值为 -1,并且 errno
被设置为 EINTR
。这不是一个错误。给读者留一个练习:在 read_full
中处理这种情况。
4.5 关于协议的更多内容
文本协议与二进制协议
为什么不使用像HTTP和JSON这样更简单、更好的协议,而要处理二进制数据呢?纯文本协议看起来 “简单”,因为它是人类可读的。但是由于实现的复杂性,它们对于机器来说并不是很友好。
人类可读的协议处理的是字符串,而字符串的长度是可变的,所以你需要不断地检查字符串的长度,这既繁琐又容易出错。而二进制协议避免了不必要的字符串操作,没有什么比复制一个结构体更简单的了。
长度前缀与分隔符
本章遵循一种常见的模式:
- 1. 以一个固定大小的部分开始。
- 2. 接着是可变长度的数据,其长度由固定大小的部分指示。
当解析这样的协议时,你总是知道要读取多少数据。
另一种模式是使用分隔符来指示可变长度数据的结束。要解析一个使用分隔符的协议,你需要不断读取数据,直到找到分隔符。但是如果负载中包含分隔符怎么办呢?这时你就需要使用转义序列,这会增加更多的复杂性。
案例研究:现实世界中的协议
HTTP头部是由 \r\n
分隔的字符串,每个头部都是由冒号分隔的键值对(KV pair)。URL中可能包含 \r\n
,所以请求行中的URL必须进行转义或编码。你可能会忘记在头部值中不允许出现 \r\n
,这已经导致了一些安全漏洞。
GET /index.html HTTP/1.1
Host: example.com
Foo: bar
如果你将HTTP协议的实现作为一个练习,你可能只会实现一个有缺陷的子集,因为有很多工作要做,比如对数据进行编码/转义、检查禁止的字符等等。HTTP是一个关于如何不设计网络协议的反面教材。
真正的Redis协议也是人类可读的,但不像HTTP那么复杂。它同时使用了分隔符和长度前缀。字符串使用长度前缀表示,但是长度是一个由换行符分隔的十进制数字。字符串后面也有一个换行符,但这只是为了提高可读性。例如:
$5\r\nhello\r\n
你可以尝试实现真正的Redis协议作为一个挑战,因为这需要更多的工作。但不要花费太多精力,因为事件循环的下一步更为重要,并且你不能重用本章中的代码。
源代码:
- •
04_client.cpp
- •
04_server.cpp