m03:拆掉透传层
对应第 7 章(不同的层,不同的抽象)
背景与对应章节
一条 GET 命令要穿过几个类才碰到哈希表?每一层各自提供了什么别的层没有的抽象?
minikv 的分层看上去很"规范":Server 处理文本协议,Database 是库的入口,Store 管存储,Index 管内存索引。写的人大概觉得层越多越清楚。可顺着一条命令往下跟,你会发现好几层几乎什么都没做,只是把参数原样交给下一层;还有一个参数被一路带下去,只为在最底下读一次。
这个任务练的是第 7 章的判断:每一层都应该提供和上下层不同的抽象,否则这一层就是负担。做之前请读完 第 7 章 · 不同的层,不同的抽象,尤其是 7.1(透传方法和三种处理方式)、7.4(接口与实现)、7.5(透传变量与上下文对象)。
你要找的问题
先别改,拿一条命令顺着调用链读下去:
| 从哪里开始 | 读的时候问自己 |
|---|---|
Server::handleGet → src/database.cpp → src/store.cpp → src/index.cpp | 画出 GET 的调用链。每一层的方法签名长什么样?哪一层真正做了事? |
src/database.h | 逐个方法标出:纯转发、转发加一点点、真正干活。它真正干的那几件事,用到的信息在谁手里?它是怎么拿到的? |
src/index.h | 把它的公有方法和 std::unordered_map 的成员函数对照一下,多出来的有几个? |
WriteOptions | 在 src/ 里搜它:从哪里构造、穿过哪些方法、最后在哪里被读?它的值在一次运行中会变吗? |
Store::compact() | 这里也用到了 WriteOptions,而且传的值和别处不一样。这说明了什么? |
确认基线全绿,然后看看 WriteOptions 走了多远:
cd project/minikv
make test
grep -n "WriteOptions\|writeOptions_\|, wo)" src/*.h src/*.cpp任务与约束
- 去掉透传层:让 GET 从协议层到哈希表之间,只经过真正提供不同抽象的层。
Database里真正有内容的工作(压缩时机、STATS、Options 校验)要找到合适的新家,不能丢,也不能变成上层要记着调用的步骤。- 去掉透传变量:
WriteOptions只在真正需要它的地方出现。
约束:
make test全绿,不许修改tests/;外部行为完全不变。- 只做本任务的事。
Store里重复的写方法(m02)、分散的日志格式(m01)、调参(m04)、含糊的名字(m07)都先别碰。 - 更新
README.md里的文件结构。
自查清单
- GET 的调用链上,不再有两个相邻的类提供签名相同、意思也相同的方法。
- 没有哪个类只是为了把信息"要过来"再算一下而存在;用到某个信息的计算,在拥有这个信息的类里。
- 写完一个值以后"检查要不要压缩"不需要调用者记着做。
WriteOptions不再出现在命令层和存储层的写方法签名里。- 你能说清楚:在剩下还用
WriteOptions的那一层,它为什么是合理的参数。 make test全绿,编译零警告。
提示
提示 1:三种处理方式各试一下
书中 7.1 节给了三条路:让调用者直接用下层、在两层之间重新分配功能、把两层合并。对 Database 分别想一想:如果只是让 Server 直接用 Store,"写完检查压缩"这件事谁来做?如果把它挪进 Store,Database 还剩什么?
提示 2:Index 值不值得单独存在
一个类的接口和它内部的数据结构一一对应时(7.4 节),它替使用者藏住的东西很少。Store 直接持有一个 std::unordered_map,再加一个私有的查找辅助函数,会不会更简单?
提示 3:变量该在哪一层固定下来
问一句:这个值由谁决定、什么时候决定、会不会变?如果它在启动时决定、一次运行里不变,它就是某个对象的属性,可以在构造时交给真正用它的那一层。但留意 compact():在最底下那一层,不同调用确实传了不同的值。
参考答案与讨论
先自己做完、测试全绿,再打开下面的内容。透传问题往往有不止一种合理解法;关键是能说清楚每一层现在提供的抽象是什么。
打开参考答案
参考解法删掉了 Database 和 Index,调用链变成 Server(文本协议)→ Store(持久化的内存键值表)→ LogWriter(记录编码与写文件)。
// server.cpp
if (!db_.exists(key)) return "(nil)";
auto r = db_.get(key);
// database.cpp
std::pair<bool, std::string> Database::get(const std::string& key) const {
return store_.get(key);
}
void Database::set(const std::string& key, const std::string& value, const WriteOptions& wo) {
store_.set(key, value, wo);
maybeCompact(); // asks store_ for dead() and total()
}
// store.cpp
std::optional<std::string> v = index_.get(key);
// index.cpp
auto it = map_.find(key);
// server.cpp
if (!store_.exists(key)) return "(nil)";
auto r = store_.get(key);
// store.h
class Store {
public:
// `options` are fixed for the lifetime of the Store; throws
// std::invalid_argument if they are invalid.
Store(const std::string& path, const Options& options);
void set(const std::string& key, const std::string& value);
...
std::string stats() const; // the STATS line
private:
const Entry* lookup(const std::string& key) const;
void maybeCompact(); // called at the end of every write
WriteOptions writeOptions_; // derived from options_.sync_writes
std::unordered_map<std::string, Entry> index_;
...
};
三种处理方式,各用在哪里
- 让调用者直接用下层:对
Database不合适。只删Database、让Server直接用Store,"写完检查压缩"就得由Server记着做,等于把一条调用顺序约束推给上层。 - 重新分配功能:
Database真正做的三件事(压缩时机、STATS 那一行、Options 校验)用到的信息全在Store手里,挪进Store后,count()/total()/dead()这几个只为Database存在的 getter 也一起消失。 - 合并:挪完以后
Database只剩转发,删除。Index的接口照搬unordered_map,并入Store,前缀查找写进Store::keys()。
WriteOptions:同一个参数,两种身份
// Server: built once, passed on every write
db_.set(key, rawValue_, writeOptions_);
// Database::set / del / incr / append (..., const WriteOptions& wo)
store_.set(key, value, wo);
// Store::set / del / incr / append (..., const WriteOptions& wo)
writer_.append(rec, wo);
// LogWriter::append: the only reader
if (options.sync && !file_.sync()) ...
Store::Store(const std::string& path, const Options& options)
: options_(checked(options)), path_(path) {
writeOptions_.sync = options_.sync_writes;
...
}
// normal writes
writer_.append(rec, writeOptions_);
// compaction: many records unsynced, one fsync at the end
WriteOptions noSync;
out.append(rec, noSync);
相关的出现次数从 31 次降到 7 次,全在 Store 和 LogWriter 之间。WriteOptions 留在了 LogWriter::append 上,因为在那一层它确实会变:普通写按配置决定,压缩写一律不 fsync、最后统一 fsync。判断透传变量的标准不是"它传了几层",而是"中间这几层用不用它、它的值在调用之间变不变"。
数字:Database+Index+Store+Server 共 770 行,合并后 Store+Server 600 行;src/ 总计 1418 → 1248 行。
边界与取舍
LevelDB、RocksDB 的写接口也带一个 WriteOptions,在那里它不是透传变量:调用者确实会给不同的写做不同的选择(账务记录要落盘,缓存数据不必),它是接口的一部分,每一层都必须如实传下去。minikv 的 sync 由启动参数决定、一次运行里不变,所以它是 Store 的属性。如果哪天要支持 SET key value SYNC 这样的逐条选择,就该把它重新做成写方法的参数,而且从命令层到 LogWriter 每一层都有意义。
书中 7.5 节对透传变量的常用解法是上下文对象:把配置和共享状态放进一个对象,主要对象在构造时拿到它的引用。用在 minikv 上大概是这样(草图,参考答案没有采用):
// All process-wide settings and shared counters of one minikv instance.
struct Context {
const Options options; // fixed at startup
Stats stats; // counters that several layers update
};
class LogWriter {
public:
explicit LogWriter(Context& ctx) : ctx_(ctx) {}
uint64_t append(const Record& rec); // reads ctx_.options.sync_writes
private:
Context& ctx_;
};
它比全局变量好:一个进程里能开多个实例(我们的测试同时开很多个 Server),测试也能通过它改配置。但作者自己也承认它的毛病:和全局变量一样看不出某个字段为什么存在、谁在用;容易变成杂物袋,制造不明显的依赖;多线程时字段最好不可变。minikv 现在只有一个变量、一个使用者,构造参数就够了。当配置、统计、时钟、日志器都要到达好几个类时,再考虑它。
用 make bench 量一下:基线和 m03 的 GET 耗时差别在两次运行的波动范围内。-O2 下这些转发大多被内联了。透传层是设计问题,不一定是性能问题;GET 的时间花在哪里,是 m08 要先测量再回答的。
store.cpp 205 → 247 行。它现在负责内存表、日志和压缩时机,这些都围绕同一份信息(键、记录大小、垃圾量),放在一起是合理的。如果将来压缩策略变复杂(分段日志、后台压缩),可以把策略拆成一个有实际深度的类,前提是它的接口比原来 Database 那堆转发窄得多。
审稿人仍会指出的问题
Store的set/incr/append/del仍各自重复写日志和簿记,命令语义仍在存储层:m02。- 日志格式仍然分散在三个类里:m01。
- 压缩阈值、检查间隔、索引预留仍然推给用户去调:m04。
完整讨论在 solutions/m03/DISCUSSION.md。对照自己的改动:
cd project # 仓库里 minikv/ 和 solutions/ 所在的目录
make -C minikv clean && make -C solutions/m03 clean
diff -ru minikv solutions/m03
make -C solutions/m03 test