minikv 重构项目 · 任务 1 / 8

m01:把日志做成深模块

对应第 4 章(模块应该深)、第 5 章(信息隐藏与泄漏)

背景与对应章节

"日志文件长什么样"这个设计决定,现在有几个类知道?改它要动几个文件?

minikv 把每次修改追加写到一个日志文件,启动时重放。负责这件事的代码是团队里一位同事"按职责拆分"写的:写一个类、读一个类、解析一个类,再加上文件描述符、字节编解码几个小工具类。每个类都很短,单看都挺整洁。

这个任务练的是第 4、5 章的核心判断:模块的价值在于它替使用者藏住了多少东西,而不在于它有多小。做之前请读完 第 4 章 · 模块应该深 和 第 5 章 · 信息隐藏(与泄漏),尤其是原书 4.6(类炎)、5.2~5.4(泄漏与按时间顺序分解)、5.7(默认值与过度暴露)。

你要找的问题

先别动手改,花 20 分钟只读代码,回答下面几个问题。需要看的文件:

文件读的时候问自己
src/log_writer.*
src/log_reader.*
src/record_parser.*
如果要把记录头里的两个长度字段从 4 字节改成 varint,要改哪几个文件?每个文件里是哪几行"知道"头部布局?
src/store.cpp搜一下 kRecordHeaderSize。Store 为什么需要知道头部有多少字节?
src/store.cpp 的 load()这个函数里有多少行是在"搬运":把一个类得到的信息交给另一个类?如果有人把两段代码的顺序调换,会出什么事?
src/file_handle.*
src/byte_buffer.h
这些类替使用者处理了哪些难点?哪些难点没处理,结果在调用方重复出现?
LogWriter::open 的签名最常见的调用者只想"打开日志往后追加"。它需要传哪些参数、知道哪些规则?哪些其实只有压缩那一处才用得到?

把你的答案写下来(下面的笔记框会保存在本机浏览器里)。能说出"同一个知识出现在哪几处",这个任务就完成了一半。

先确认基线能编译、测试全绿,再看看格式知识散落在哪里:

cd project/minikv
make test
grep -n "kRecordHeaderSize\|skip(5)\|putU32\|getU32" src/*.cpp src/*.h

任务与约束

  1. 重新划分日志模块,让"记录在磁盘上的格式"只出现在一个源文件里。
  2. 让最常见的用法最简单:Store 打开日志时不应该再接触 open(2) 标志、权限位、起始偏移、errno。
  3. Store 不再自己计算记录大小。
  4. 处理部分写、EINTR 的循环各只保留一份。

约束:

自查清单

提示

卡住了再一条一条打开。

提示 1:先数一数调用者要知道多少东西

把 Store::load() 和 Store::compact() 里所有跟文件打交道的代码列出来,每一行问一句:"Store 为什么要知道这个?"你会发现几乎每一行的答案都是"因为下层没替它做"。这份清单就是新模块该藏住的东西。

提示 2:按知识分,不按时间分

写、读、解析三个阶段用的是同一份知识(格式)。书里对按时间顺序分解给的建议是:把共享同一份知识的代码放到一起(原书 5.3 节)。试试一个类同时负责写和读。至于 FileHandle、ByteWriter 这些小类:把它们合进去以后,还有谁需要它们?

提示 3:顺序约束怎么消失

"必须先重放完才能追加"是现在接口里最隐蔽的一条规则。如果打开日志的那个操作本身就包含重放(比如构造时传一个回调,每读出一条记录就交给调用者),这条规则就不需要任何人记住了。压缩时的"写临时文件、rename、重新打开"同理:它们全是文件层面的知识,可以收进同一个模块,对外只露一个"用这些记录整体替换日志"的操作。

参考答案与讨论

先自己做完、测试全绿,再打开下面的内容。参考答案只是一种合理设计;你的方案不同不代表错,关键是能不能讲清楚"使用者要知道的东西变少了多少、代价是什么"。

打开参考答案

参考解法把 5 个类合并成一个 LogFile(solutions/m01/src/log_file.h)。先看 Store 打开日志的代码前后对比:

void Store::load() {
    LogReader reader;
    uint64_t end = 0;
    if (reader.open(path_)) {
        std::string raw;
        while (reader.next(raw)) {
            Record r;
            try {
                r = RecordParser::parse(raw);
            } catch (const std::exception& e) {
                throw std::runtime_error(path_ + ": bad record at offset " +
                    std::to_string(reader.offset() - raw.size()) + ": " + e.what());
            }
            handle(r);
        }
        end = reader.offset();
    } else if (errno != ENOENT) {
        throw std::runtime_error("cannot open " + path_ + ": " + std::strerror(errno));
    }
    if (!writer_.open(path_, O_WRONLY | O_CREAT | O_APPEND, 0644, end)) {
        throw std::runtime_error("cannot open " + path_ + " for writing: " + std::strerror(errno));
    }
}

三个类、一个 errno 判断、一组 open 标志、一个要从读端搬到写端的偏移,还有"先读后写"的顺序。

Store::Store(const std::string& path, const Options& options)
    : index_(options.index_reserve),
      log_(path, [this](const Record& r, uint64_t size) { handle(r, size); }) {}

// log_file.h
class LogFile {
public:
    // Opens the log at `path`, creating an empty one if it does not exist,
    // and passes every record already in it to `visit`, oldest first. ...
    LogFile(const std::string& path, const RecordVisitor& visit);
    uint64_t append(const Record& rec, const WriteOptions& options); // returns bytes used
    void rewrite(const std::function<void(const RecordSink&)>& writeRecords);
    uint64_t size() const;
};

格式、校验、重试、文件不存在、偏移、原子替换,全部在 log_file.cpp 里。Store 只看见记录和字节数。

// log_writer.cpp
w.putU32(0);  // checksum, filled in below
w.putU8(static_cast<uint8_t>(rec.type));
w.putU32(static_cast<uint32_t>(rec.key.size()));
...
// log_reader.cpp
ByteReader r(header, sizeof header);
r.skip(5);                       // crc (4) + type (1)
uint32_t keyLen = r.getU32();
...
// record_parser.cpp
uint32_t storedCrc = r.getU32();
uint8_t type = r.getU8();
...
// store.cpp(出现 5 次)
uint64_t size = kRecordHeaderSize + key.size() + value.size();
// log_file.cpp, anonymous namespace
constexpr size_t kHeaderSize = 13;
constexpr size_t kTypeOffset = 4;
constexpr size_t kKeyLenOffset = 5;
constexpr size_t kValueLenOffset = 9;

std::string encode(const Record& rec);   // used by append() and rewrite()
uint64_t replay(int fd, const std::string& path,
                const LogFile::RecordVisitor& visit);

// store.cpp
uint64_t size = log_.append(rec, wo);

使用者要知道的东西

改之前,Store 需要知道改之后
记录格式头部 13 字节、记录大小的公式不需要
打开文件3 个 open 标志、权限位、ENOENT 的含义、起始偏移只传路径
调用顺序先 LogReader 读完,再 LogWriter 打开构造即完成
压缩临时文件名、O_TRUNC、fsync、rename、重新打开、新文件大小交出记录即可

日志相关代码从 9 个文件 421 行变成 3 个文件 258 行,store.cpp 从 205 行变成 151 行。行数不是目标,但"浅类合并后总代码变少"很常见:浅类之间的胶水代码(搬偏移、转错误信息、重复的重试循环)随着接口一起消失了。

顺带修掉一个潜在 bug:基线的 compact() 先关写端再 rename,rename 失败后写端已经关了,之后所有写都会失败。rewrite() 先 rename 成功再切换描述符。

代价与取舍

也可以设计成 open(path) + forEach(visit) 两步,更"常规"。但那样"必须先重放再追加"的顺序约束又回到了接口上。参考答案选择让构造一次做完,代价是 Store 的成员声明顺序变得重要(重放时要写 index_,所以 log_ 必须最后声明),代码里为此留了一句注释。这是一个真实的权衡,你选两步式也说得过去,只要把顺序约束写进接口注释。

rewrite 接收"一个接收 sink 的函数",读起来不如 rewrite(const std::vector<Record>&) 直观。vector 版的代价是压缩时多一份全部数据的内存拷贝。小项目里选 vector 版完全合理;这里选回调,是因为 minikv 本来就把全部数据放在内存里,再翻一倍就太多了。

书中在 5.5 节也提醒过:类变大能改善信息隐藏,但不是越大越好。LogFile 只收了"同一份知识"(文件格式和文件生命周期)相关的代码;内存索引、垃圾统计、压缩时机都留在外面,因为它们用的是另一份知识。

审稿人仍会指出的问题

  • Store 的 set/incr/append/del 仍然各自重复"组记录 → 追加 → 记垃圾 → 更新索引":这是 m02 的题目。
  • WriteOptions 仍然一层层透传到 LogFile::append:m03。
  • Store::compact() 的注释还在讲实现步骤,total_、dead_ 这样的名字仍然含糊:m07。
  • rewrite() 只保证进程崩溃时旧日志完好;要扛住掉电,rename 之后还得 fsync 所在目录。好在现在加这几行只需要改 log_file.cpp,这正是深模块的回报。

完整的讨论在 solutions/m01/DISCUSSION.md。对照自己的改动:

cd project            # 仓库里 minikv/ 和 solutions/ 所在的目录
make -C minikv clean && make -C solutions/m01 clean
diff -ru minikv solutions/m01
make -C solutions/m01 test