请求-响应协议

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. 1. 用于将字节流分割成消息的高层结构。
  2. 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. 1. 以一个固定大小的部分开始。
  2. 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
阅读剩余
THE END