用C/C++构建自己的Redis——第二章、协议解析

用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;
}
  1. read()系统调用:当使用read()函数读取文件或设备数据时,会返回内核中当前可用数据。如果内核没有可用数据,read()会阻塞,知道有数据可以读取。如果读取的数据量不足(即实际读取数据少于请求的数据量),则由应用程序处理,read()只返回当前可用数据,不会等待更多的数据到来。
  2. read_full()函数:为应用层面函数,它使用read()系统调用读取数据。read_full()会持续调用read(),知道读取指定数量的字节(程序中为n)。这一位着如果read()返回不了足够数据,read_full()会等待并持续读取,知道积累读取到足够的数据。
  3. 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”,可以减少系统调用的数量。也就是说,一次性尽可能多的读到缓冲区,然后尝试从该缓冲区解析多个请求。这将会在后面章节深入了解。
常见错误:

  1. 未处理read和write的返回值:这两个函数可能返回比预期更少的字节数,详见read_full函数中的注释。
  2. 人们常常认为read和write系统调用是“消息”而不是字节流,导致协议无法解析。
  3. 不要进行过于复杂的设计。

总结

这一章介绍了如何构建一个简单的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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值