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任务与约束
- 重新划分日志模块,让"记录在磁盘上的格式"只出现在一个源文件里。
- 让最常见的用法最简单:
Store打开日志时不应该再接触open(2)标志、权限位、起始偏移、errno。 Store不再自己计算记录大小。- 处理部分写、
EINTR的循环各只保留一份。
约束:
make test必须全绿,不许修改tests/。测试只通过Server的命令接口和重新打开日志文件来检查行为,所以只要外部行为不变,你可以随意重组内部。- 日志文件格式保持不变(用基线写出的旧日志,改完的程序要能读)。
- 只做本任务的事。
Store里那些看着重复的set/incr/append、一路往下传的WriteOptions、含糊的成员名,都是后面任务的题目,先别碰,免得改动混在一起没法对照。 - 更新
README.md里的文件结构。
自查清单
- 在
src/里搜头部大小(13)和各字段的偏移:它们只出现在一个 .cpp 文件里,而且不在任何头文件里。 store.cpp不再 include 任何与文件格式、fcntl.h、errno(打开文件相关)有关的东西。- 新模块的公有接口,你能用 3~5 句话写完注释,而且注释里不需要提"头部""偏移""标志"。
- 不存在"必须先调 A 再调 B"的调用顺序约束;如果有,它被构造函数或类型系统强制了。
- 用基线程序写的日志文件,改完后的程序能正常打开(自己用
./minikv试一次)。 make test全绿,编译零警告。
提示
卡住了再一条一条打开。
提示 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