键值服务器

7.1 请求 - 响应消息格式

从当前的协议开始,让我们把 “msg” 部分替换成有实际意义的内容:一个仅支持 getsetdel 命令的键值对存储系统。

┌─────┬──────┬─────┬──────┬────────
│ 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. 1. 解析命令。
  2. 2. 处理命令并生成响应。
  3. 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::stringstd::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 接下来做什么?

我们已经实现了一些能工作的代码。但让代码工作并不是编程中困难的部分,接下来是更高级的主题:

  • • 编写超越简单示例代码的底层数据结构。
  • • 将数据结构应用到实际问题中,例如定时器、有序集合。
    源代码
阅读剩余
THE END