键值服务器
7.1 请求 - 响应消息格式
从当前的协议开始,让我们把 “msg” 部分替换成有实际意义的内容:一个仅支持 get
、set
、del
命令的键值对存储系统。
┌─────┬──────┬─────┬──────┬────────
│ len │ msg1 │ len │ msg2 │ more...
└─────┴──────┴─────┴──────┴────────
4B ... 4B ...
Redis 请求是一个字符串列表,就像 Linux 命令一样。将列表表示为一段字节流是序列化和反序列化的任务。我们将使用与外部消息格式相同的长度前缀方案。
源代码在文章末尾
┌────┬───┬────┬───┬────┬───┬───┬────┐
│nstr│len│str1│len│str2│...│len│strn│
└────┴───┴────┴───┴────┴───┴───┴────┘
4B 4B ... 4B ...
nstr
是列表中的项数,后面跟着每个项。每个字符串项同样以其长度为前缀。可能有人会想直接连接以空字符结尾的字符串,或者用空格分隔它们,但这种分隔格式只会带来问题,因为数据中可能包含分隔符。
目前,响应只是一个整数状态码和另一个字符串。一些 Redis 命令可以返回除单个字符串之外的复杂数据类型,我们将在后面实现。
┌────────┬─────────┐
│ status │ data... │
└────────┴─────────┘
4B ...
7.2 处理请求
要做什么?
处理请求分三个步骤:
- 1. 解析命令。
- 2. 处理命令并生成响应。
- 3. 将响应追加到输出缓冲区。
static bool try_one_request(Conn *conn) {
// ...
// 收到一个请求,执行一些应用逻辑
std::vector<std::string> cmd;
if (parse_req(request, len, cmd) < 0) {
conn->want_close = true;
return false; // 错误
}
Response resp;
do_request(cmd, resp);
make_response(resp, conn->outgoing);
// ...
}
步骤 1:解析请求命令
带长度前缀的数据解析起来很简单:
static int32_t
parse_req(const uint8_t *data, size_t size, std::vector<std::string> &out) {
const uint8_t *end = data + size;
uint32_t nstr = 0;
if (!read_u32(data, end, nstr)) {
return -1;
}
if (nstr > k_max_args) {
return -1; // 安全限制
}
while (out.size() < nstr) {
uint32_t len = 0;
if (!read_u32(data, end, len)) {
return -1;
}
out.push_back(std::string());
if (!read_str(data, end, len, out.back())) {
return -1;
}
}
if (data != end) {
return -1; // 尾部有多余数据
}
return 0;
}
┌────┬───┬────┬───┬────┬───┬───┬────┐
│nstr│len│str1│len│str2│...│len│strn│
└────┴───┴────┴───┴────┴───┴───┴────┘
4B 4B ... 4B ...
我们添加了一些辅助函数,这样就不用一直处理数组索引了。这使得代码更不容易出错。
static bool read_u32(const uint8_t *&cur, const uint8_t *end, uint32_t &out) {
if (cur + 4 > end) {
return false;
}
memcpy(&out, cur, 4);
cur += 4;
return true;
}
static bool
read_str(const uint8_t *&cur, const uint8_t *end, size_t n, string &out) {
if (cur + n > end) {
return false;
}
out.assign(cur, cur + n);
cur += n;
return true;
}
const uint8_t *&cur
是一个指针引用,在从指针中读取一些数据后,指针会被调整。C++ 引用只是语法不同的指针,你也可以使用 const uint8_t **cur
来代替。
步骤 2:处理命令
定义响应类型:
struct Response {
uint32_t status = 0;
std::vector<uint8_t> data;
};
键值对存储用 STL 映射来模拟:
// 占位符;后续实现
static std::map<std::string, std::string> g_data;
static void do_request(std::vector<std::string> &cmd, Response &out) {
if (cmd.size() == 2 && cmd[0] == "get") {
auto it = g_data.find(cmd[1]);
if (it == g_data.end()) {
out.status = RES_NX; // 未找到
return;
}
const std::string &val = it->second;
out.data.assign(val.begin(), val.end());
} else if (cmd.size() == 3 && cmd[0] == "set") {
g_data[cmd[1]].swap(cmd[2]);
} else if (cmd.size() == 2 && cmd[0] == "del") {
g_data.erase(cmd[1]);
} else {
out.status = RES_ERR; // 未识别的命令
}
}
我们将在下一章用自己的哈希表替换它。但为什么不直接使用 std::unordered_map
呢?除了学习目的之外,生产级的键值对存储有更具体的要求;通过网络使用 STL 容器只是个玩具。
步骤 3:序列化响应
这很简单:
static void make_response(const Response &resp, std::vector<uint8_t> &out) {
uint32_t resp_len = 4 + (uint32_t)resp.data.size();
buf_append(out, (const uint8_t *)&resp_len, 4);
buf_append(out, (const uint8_t *)&resp.status, 4);
buf_append(out, resp.data.data(), resp.data.size());
}
然而,这里有改进的空间:响应数据被复制了两次,第一次从键值复制到 Response::data
,然后从 Response::data
复制到 Conn::outgoing
。练习:优化代码,使响应数据直接进入 Conn::outgoing
。
7.3 接下来做什么?
我们已经实现了一些能工作的代码。但让代码工作并不是编程中困难的部分,接下来是更高级的主题:
- • 编写超越简单示例代码的底层数据结构。
- • 将数据结构应用到实际问题中,例如定时器、有序集合。
源代码