【LevelDB 源码阅读】03 | 打开一个数据库:DBImpl::Open 与恢复

之前几章我们看到了写入一条数据后,LevelDB 目录下的一些文件:CURRENT、MANIFEST-000002、000003.log,还有一把 LOCK。然后我们的进程就推出了,如果我们再另起一个进程来读取这些文件,可这些文件是否可信,我们要想一个办法进行判断。

这一篇文章中,我们就可以回答:一个数据库被打开时,这些文件按什么顺序被读出来?如果上次没关干净,又怎么判断该信谁?

这一篇文章我们会了解 Open 的完整流程,然后把 01 篇留下的两个小谜团解掉:

  • 为什么新库的第一个 Manifest 是 MANIFEST-000002,而日志偏偏叫 000003.log?
  • LOG 里那句 Delete type=3 #1 又是什么意思?

Open 一个数据库时发生了什么?一堆没有入口的文件,怎么恢复出可用的状态?


第一步:先获取锁,再决定是否建库

所有事情从 DB::Open 开始(db_impl.cc:1483)。它构造出 DBImpl、锁住内部互斥量,然后调用 DBImpl::Recover(db_impl.cc:292)。Recover 这个名字容易让人以为"恢复是重启时才做的事",其实它囊括了每次打开都要走的全部流程,包括第一次建库。

Recover 做的第一件事是拿锁:

cpp 复制代码
env_->CreateDir(dbname_);
Status s = env_->LockFile(LockFileName(dbname_), &db_lock_);

LockFileName 就是数据库目录下的 LOCK 文件(filename.cc:55)。POSIX 环境里这个锁是 fcntl(F_SETLK) 的文件写锁(env_posix.cc:628、439-448),最直白的效果是同一个库不能被两个进程同时打开 。如果锁拿不到,Open 会直接返回 IOError。

拿到锁之后才轮到判断库是否存在:CURRENT 不存在就走 create_if_missing 分支(db_impl.cc:306),存在而又要求 error_if_exists 就报错(db_impl.cc:318)。第一次建库时,NewDB(db_impl.cc:181)会写一个只有一条记录的 Manifest------记录 comparator、NextFile = 2、LogNumber = 0、序列号为 0------然后通过 SetCurrentFile 生成 CURRENT。

这里就顺带解释了 01 篇的第一个疑问。为什么建库写完的 MANIFEST-000001 后来不见了?因为 NewDB 只是写了一张"出生证明",真正的运行时 Manifest 是在后面的 LogAndApply 里生成的:文件号计数器已经走到 2,于是创建 MANIFEST-000002 并通过 SetCurrentFile 把 CURRENT 指向它(version_set.cc:808、837)。旧的 MANIFEST-000001 随即被当成过时文件删除,LOG 里那句 Delete type=3 #1(type=3 正是 kDescriptorFile)就是它。

顺带看一下 SetCurrentFile(filename.cc:123):它先往临时文件里写一行形如 MANIFEST-000002\n 的内容,再 Rename 成 CURRENT。重命名是原子的 ,所以任何时刻 CURRENT 要么指向旧的 Manifest,要么指向新的,不存在"指到一半"的中间态。这个原子替换是整套元数据安全的地基。

从 CURRENT 到 MANIFEST:拼出"当前版本"

Recover 接下来的重头戏是 versions_->Recover(save_manifest)(db_impl.cc:324),实现落在 db/version_set.cc 的 VersionSet::Recover(version_set.cc:862)。代码看着很长,但实际流程剥离出来可以分为:

  1. 读 CURRENT,拿到当前 Manifest 的文件名。注意一个细节:文件内容必须以换行结尾,否则直接判损坏(version_set.cc:876);
cpp 复制代码
if (current.empty() || current[current.size() - 1] != '\n') {
  return Status::Corruption("CURRENT file does not end with newline");
}
  1. 打开这个 Manifest,从头到尾读 VersionEdit 记录;
  2. 每读一条就 builder.Apply(&edit),把变更应用到正在重建的版本上(version_set.cc:924)。

Manifest 本质上是一本变更流水:最新写入的日志号、下一个可用文件号、全局最大序列号,以及每一层新增/删除了哪些文件,都记录在这里面。把整本流水从头到尾过一遍,就还原出了数据库的"当前状态"。我们先对此有个概念,后面会专门讲它的格式与轮转。

翻流水时有一道校验不能省:Manifest 里记录的 comparator 名字必须和当前使用的 comparator 一致,否则直接返回 InvalidArgument(version_set.cc:915)。

cpp 复制代码
if (edit.has_comparator_ &&
    edit.comparator_ != icmp_.user_comparator()->Name()) {
  s = Status::InvalidArgument(
      edit.comparator_ + " does not match existing comparator ",
      icmp_.user_comparator()->Name());
}

数据的有序性是靠 comparator 定义的,用另一把尺子去读同一批数据,结果没有意义------这种问题越早暴露越好。

重放 WAL:把还没落盘的数据找回来

Manifest 给了我们"磁盘上有哪些文件",但最新的一批写很可能还没变成 SSTable ,它们只在 WAL 里。所以 Recover 还有第二步(db_impl.cc:337-376):

  1. 找出所有编号不小于 LogNumber 的 .log 文件(以及历史遗留的 PrevLogNumber);
  2. 按编号排序------恢复必须按日志产生的顺序重放,否则序列号会乱;
  3. 逐个交给 RecoverLogFile(db_impl.cc:385)。
cpp 复制代码
// Recover from all newer log files than the ones named in the
// descriptor (new log files may have been added by the previous
// incarnation without registering them in the descriptor).
//
// Note that PrevLogNumber() is no longer used, but we pay
// attention to it in case we are recovering a database
// produced by an older version of leveldb.
const uint64_t min_log = versions_->LogNumber();
const uint64_t prev_log = versions_->PrevLogNumber();
std::vector<std::string> filenames;
s = env_->GetChildren(dbname_, &filenames);
if (!s.ok()) {
  return s;
}
std::set<uint64_t> expected;
versions_->AddLiveFiles(&expected);
uint64_t number;
FileType type;
std::vector<uint64_t> logs;
for (size_t i = 0; i < filenames.size(); i++) {
  if (ParseFileName(filenames[i], &number, &type)) {
    expected.erase(number);
    if (type == kLogFile && ((number >= min_log) || (number == prev_log)))
    logs.push_back(number);
  }
}
if (!expected.empty()) {
  char buf[50];
  std::snprintf(buf, sizeof(buf), "%d missing files; e.g.",
                static_cast<int>(expected.size()));
  return Status::Corruption(buf, TableFileName(dbname_, *(expected.begin())));
}

// Recover in the order in which the logs were generated
std::sort(logs.begin(), logs.end());
for (size_t i = 0; i < logs.size(); i++) {
  s = RecoverLogFile(logs[i], (i == logs.size() - 1), save_manifest, edit,
                    &max_sequence);
  if (!s.ok()) {
    return s;
  }

  // The previous incarnation may not have written any MANIFEST
  // records after allocating this log number.  So we manually
  // update the file number allocation counter in VersionSet.
  versions_->MarkFileNumberUsed(logs[i]);
}

RecoverLogFile 打开日志文件,挂上一个 log::Reader,然后一条条读出记录。每条记录是一个 WriteBatch,直接 InsertInto 到一个全新的 MemTable(db_impl.cc:444)------恢复和正常写入走的是同一条插入代码,这也是它可靠的原因之一:没有第二条容易被忽略的"恢复专用路径"。

值得一提的是,这里的 reader 强制打开校验(db_impl.cc:422):哪怕把 paranoid_checks 关掉,校验和也照算不误。源码注释给的理由是,宁可让一整条损坏的提交被跳过,也不能把错误信息(比如一个异常大的序列号)带进内存。

cpp 复制代码
// We intentionally make log::Reader do checksumming even if
// paranoid_checks==false so that corruptions cause entire commits
// to be skipped instead of propagating bad information (like overly
// large sequence numbers).
log::Reader reader(file, &reporter, true /*checksum*/, 0 /*initial_offset*/);
Log(options_.info_log, "Recovering log #%llu",
    (unsigned long long)log_number);

重放全部结束后,如果 MemTable 没有被复用,它会被整表写成一张 L0 表(db_impl.cc:493-500)。

cpp 复制代码
if (mem != nullptr) {
  // mem did not get reused; compact it.
  if (status.ok()) {
    *save_manifest = true;
    status = WriteLevel0Table(mem, edit, nullptr);
  }
  mem->Unref();
}

重放途中若 MemTable 提前涨过了 write_buffer_size,也会先落一张表(db_impl.cc:455-466)。

cpp 复制代码
if (mem->ApproximateMemoryUsage() > options_.write_buffer_size) {
  compactions++;
  *save_manifest = true;
  status = WriteLevel0Table(mem, edit, nullptr);
  mem->Unref();
  mem = nullptr;
  if (!status.ok()) {
    // Reflect errors immediately so that conditions like full
    // file-systems cause the DB::Open() to fail.
    break;
  }
}

两处调用传的 base 都是 nullptr,拿不到 PickLevelForMemTableOutput 的直推机会,所以恢复产出的表固定落在 L0(常规 Flush 可能直推 L1/L2,区别留待后面文章详细讨论)。实验里的日志只有 64 字节,走的是前一条路径------重启之后,原本只在日志里的数据变成了 .ldb 文件。

重放结束还有一个默认行为值得点明。源码里有一段"复用旧日志"的逻辑(db_impl.cc:472),但它由 reuse_logs 控制,而这个选项默认是 false(options.h:137)。所以默认路径下:恢复出的 MemTable 会被写成一个 SSTable,旧的 .log 作废;DB::Open 随后再分配一个全新的日志文件号、开一个空 MemTable(db_impl.cc:1492-1505)。每次重启,日志文件号都会往前走,目录里会多出一个 .ldb------这正是本节实验要验证的事。

cpp 复制代码
if (s.ok() && impl->mem_ == nullptr) {
  // Create new log and a corresponding memtable.
  uint64_t new_log_number = impl->versions_->NewFileNumber();
  WritableFile* lfile;
  s = options.env->NewWritableFile(LogFileName(dbname, new_log_number),
                                   &lfile);
  if (s.ok()) {
    edit.SetLogNumber(new_log_number);
    impl->logfile_ = lfile;
    impl->logfile_number_ = new_log_number;
    impl->log_ = new log::Writer(lfile);
    impl->mem_ = new MemTable(impl->internal_comparator_);
    impl->mem_->Ref();
  }
}

收尾时,如果本次恢复产生了新版本,就把新的 LogNumber 等信息通过 LogAndApply 写进 Manifest(db_impl.cc:1507-1511),再调 RemoveObsoleteFiles 清掉旧日志和旧 Manifest(db_impl.cc:1513),最后 MaybeScheduleCompaction 安排后台工作(db_impl.cc:1514)。到这一步,数据、元数据、后台任务三者都对齐了,Open 返回一个可用的 DB*。

cpp 复制代码
if (s.ok() && save_manifest) {
  edit.SetPrevLogNumber(0);  // No older logs needed after recovery.
  edit.SetLogNumber(impl->logfile_number_);
  s = impl->versions_->LogAndApply(&edit, &impl->mutex_);
}
if (s.ok()) {
  impl->RemoveObsoleteFiles();
  impl->MaybeScheduleCompaction();
}

动手实践:重启短链库,看文件号怎么变化

为了不破坏 01、02 篇的实验数据,这次用一个全新的库 .output/recovery-demo:

bash 复制代码
./.output/demo_v0 .output/recovery-demo

写入后目录是这样的:

plain 复制代码
000003.log        64 字节    还在日志里,没成表
CURRENT           16 字节    内容是 "MANIFEST-000002\n"
LOCK               0 字节
LOG              151 字节    信息日志
MANIFEST-000002   50 字节    只有两条 VersionEdit

现在重启一次,只读那条短链:

bash 复制代码
./.output/reopen .output/recovery-demo c:Ab3xK

reopen 的源码我会放到文章末尾,不影响连续阅读的体验。

运行之后,我们就能看到输出:

rust 复制代码
c:Ab3xK -> https://example.com/a-very-long-url

正是我们之前写入的数据,说明数据可以正确读取。更有意思的是运行之后,目录变成了:

plain 复制代码
000005.ldb       154 字节    ← 从 WAL 恢复出来的 L0 表
000006.log         0 字节    ← 新开的空日志
CURRENT           16 字节    现在指向 MANIFEST-000004
LOG.old          151 字节    上一轮的 LOG
LOG              304 字节
MANIFEST-000004   87 字节    比原来长了

LOG 把这次恢复的每一步都记了下来:

plain 复制代码
2026/09/19-13:58:31.034155 0x10ff59600 Recovering log #3
2026/09/19-13:58:31.035426 0x10ff59600 Level-0 table #5: started
2026/09/19-13:58:31.041049 0x10ff59600 Level-0 table #5: 154 bytes OK
2026/09/19-13:58:31.069607 0x10ff59600 Delete type=0 #3
2026/09/19-13:58:31.069647 0x10ff59600 Delete type=3 #2

回忆我们刚刚阅读过的源码流程,可以发现 LOG 中记录的和我们预想的完全一致:读日志 3、把重放出的 MemTable 写成 5 号表、然后删掉旧日志(type=0)和旧 Manifest(type=3)。而建库那一刻的 Delete type=3 #1,就留在这个目录的 LOG.old 里------01 篇里那句一模一样的记录,也是这么来的。

bash 复制代码
2026/09/19-13:58:15.456095 0x110f0a600 Creating DB .output/recovery-demo since it was missing.
2026/09/19-13:58:15.526857 0x110f0a600 Delete type=3 #1

再看新 Manifest,它多了一条 AddFile:

plain 复制代码
VersionEdit {
  LogNumber: 6
  PrevLogNumber: 0
  NextFile: 7
  LastSeq: 1
  AddFile: 0 5 154 'c:Ab3xK' @ 1 : 1 .. 'c:Ab3xK' @ 1 : 1
}

意思是:0 层新增了 5 号文件,154 字节,key 范围是 c:Ab3xK(序列号 1)。到这一步,c:Ab3xK 才算真正从"日志里的字节"变成了"磁盘上的表"。

动手实践:文件丢了,Open 会拒绝启动

既然 Manifest 记录了每个文件,那文件真丢了会怎样?把刚才那张表删掉再打开:

bash 复制代码
rm .output/recovery-demo/000005.ldb
./.output/reopen .output/recovery-demo c:Ab3xK

得到的是:

plain 复制代码
open error: Corruption: 1 missing files; e.g.: .output/recovery-demo/000005.ldb

这来自 Recover 里的一段"点名"逻辑(db_impl.cc:344-361):先把 Manifest 里所有存活文件放进一个期望集合,再拿目录里实际存在的文件去抵消,最后如果集合还有剩的,就报 Corruption 并带上一个缺失文件名。

cpp 复制代码
std::set<uint64_t> expected;
versions_->AddLiveFiles(&expected);
uint64_t number;
FileType type;
std::vector<uint64_t> logs;
for (size_t i = 0; i < filenames.size(); i++) {
  if (ParseFileName(filenames[i], &number, &type)) {
    expected.erase(number);
    if (type == kLogFile && ((number >= min_log) || (number == prev_log)))
      logs.push_back(number);
  }
}
if (!expected.empty()) {
  char buf[50];
  std::snprintf(buf, sizeof(buf), "%d missing files; e.g.",
                static_cast<int>(expected.size()));
  return Status::Corruption(buf, TableFileName(dbname_, *(expected.begin())));
}

我们完全可以理解 LevelDB 选择拒绝启动的原因。数据损坏对于存储引擎而言是致命的,就算不影响之后的写入读取流程,某些 key 凭空消失也是不能为用户所接受的,所以"尽早报错"才是正确的选择。

LevelDB 另给了 RepairDB(修复思路是扫描所有可用的表文件、重建元数据),但这属于坏盘之后的应急手段,不是正常路径。

动手实践:kill -9 之后,数据靠什么回来

最后一个实验正面检验 WAL 的作用。写一个程序,连续写入三条短链,然后不给任何清理机会,直接把自己杀掉:

bash 复制代码
./.output/crash_write .output/crash-demo    # puts c:Crash1/2/3 后 SIGKILL,退出码 137

crash_write 的源码同样放在文章末尾。

此刻目录里没有 .ldb,三条记录全在 000003.log 里:

plain 复制代码
--- offset 0; sequence 1
  put 'c:Crash1' 'https://example.com/Crash1'
--- offset 56; sequence 2
  put 'c:Crash2' 'https://example.com/Crash2'
--- offset 112; sequence 3
  put 'c:Crash3' 'https://example.com/Crash3'

重新打开:

bash 复制代码
./.output/reopen .output/crash-demo c:Crash1 c:Crash2 c:Crash3

三条数据都能正确读取。原因很简单:Put 在修改 MemTable 之前先追加了 WAL,进程被杀不影响已经写进日志文件的内容,重启时按上文的流程重放一遍即可。

不过有一个边界必须说清楚:"进程被杀不丢"不等于"机器掉电不丢" 。默认 sync = false(options.h:182)时,Put 只把数据交给操作系统页缓存就返回,日志还没真正落盘;进程崩溃时内核还在,页缓存还在,数据安全;整机断电时页缓存一起丢,最后几条写就可能消失。要跨过这条线,得让 Put 带上 sync = true,用一次磁盘同步换取确定的持久性。这条边界的物理细节留给后面的文章。

对比参照:三种恢复,三种目标

同样是"重启后恢复",三个系统的目标并不一样。

InnoDB 恢复到一个事务一致的状态。 它靠 redo 把已提交的改动补齐,再靠 undo 把崩溃时没提交的事务回滚掉,中间还有 doublewrite 处理页撕裂。目标是:所有已提交事务在,所有未提交事务不在。

Redis 的 AOF 从头重放整个命令流。 它没有"部分恢复"一说------把文件里记录的所有命令重新执行一遍,在内存里重建出数据集;RDB 则是直接加载一个快照。目标更接近"把内存状态重新构建出来"。

LevelDB 只重放到最后一个完整提交。 它没有事务语义,一个 WriteBatch 就是一个提交单位:完整就应用,损坏或写了一半就丢弃(后面会讲怎么判断)。没有回滚,也不需要回滚------因为不存在"跨多个提交的整体一致性"这个承诺。

恢复目标的差异,根本在于有没有事务语义。要保证事务,就必须额外记录能回滚、能判断未提交的信息;只要保证"单次写要么整体在要么整体不在",日志结构本身就已经足够了。

工程启示

用好 LevelDB:把 Open 当成一次性成本

恢复时长基本等于 WAL 的重放时长。日志越大、越多,启动越慢:

  • write_buffer_size 越大,同样数据量下日志切换越少,但每次重启要重放的日志也越大 ,内存占用(mem_ + imm_ 两份)同步上升;
  • 频繁重启的场景要把这笔账算进去:与其反复开关数据库,不如让它常驻;
  • 默认 reuse_logs = false,只要日志里还有未落盘的数据,重启就会把它重放成新表、再开新日志,目录里的 .ldb 会因重启而增加------这是设计行为,不是泄漏;
  • 怀疑数据损坏时,先在 paranoid_checks = true 下跑一次,让损坏尽早暴露;确认需要抢救再用 RepairDB。

设计借鉴:分层恢复与幂等重放

把 Open 的流程抽象出来,是一套通用的恢复模式:

  • 先元数据、后数据 :先用 CURRENT → MANIFEST 确定"磁盘上应该有哪些文件",再用 WAL 补齐"还差哪些内容"。顺序颠倒,就得在数据里大海捞针;
  • 自描述 + 校验:每条记录自带长度和校验和,读得懂才用,读不懂就丢,不必依赖外部索引;
  • 幂等重放 :数据的落盘和元数据的登记是分开的两步。文件先落盘,CURRENT 的原子替换才算"安装";安装之前产生的任何中间产物,下次启动都会被当成孤儿清理掉。于是"恢复做到一半又崩了"不会留下半吊子状态,重来一次即可。

一句话:元数据给确定性,日志给完整性,原子替换给安全性。 这套组合拳不只适用于存储引擎,也适合任何需要"重启后对齐状态"的系统。


数据库已经打开、数据恢复到位。接下来是 02 篇数据流图上留下的那个问题:多个线程同时往一个库写,LevelDB 为什么能让它们排队、还顺便把请求合并成一批?下一篇,从 Write 的队列说起。


reopen.cc 代码。

cpp 复制代码
// reopen.cc:打开一个已存在的库,读回指定 key(验证数据从 WAL 恢复)。
// 构建 leveldb(仓库根目录执行,产物统一放 .output/ 下):
//   cmake -S leveldb-1.23 -B .output/build -DLEVELDB_BUILD_TESTS=OFF \
//         -DLEVELDB_BUILD_BENCHMARKS=OFF -DCMAKE_BUILD_TYPE=Release
//   cmake --build .output/build -j8
// 编译(仓库根目录,macOS 用 clang++,Linux 用 g++):
//   clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/reopen.cc \
//           .output/build/libleveldb.a -o .output/reopen
// 运行(默认库 .output/shorturl-demo,可传库路径,后接要读的 key):
//   ./.output/reopen .output/recovery-demo c:Ab3xK
#include <cstdio>
#include <string>
#include <vector>

#include "leveldb/db.h"

int main(int argc, char* argv[]) {
  const std::string dbpath = argc > 1 ? argv[1] : ".output/shorturl-demo";
  std::vector<std::string> keys;
  for (int i = 2; i < argc; ++i) keys.push_back(argv[i]);
  if (keys.empty()) keys.push_back("c:Ab3xK");

  leveldb::Options options;
  options.create_if_missing = false;  // 只打开已存在的库
  leveldb::DB* db = nullptr;
  leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
  if (!s.ok()) {
    std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
    return 1;
  }

  for (const std::string& key : keys) {
    std::string value;
    s = db->Get(leveldb::ReadOptions(), key, &value);
    if (s.ok()) {
      std::printf("%s -> %s\n", key.c_str(), value.c_str());
    } else if (s.IsNotFound()) {
      std::printf("%s -> (not found)\n", key.c_str());
    } else {
      std::printf("%s -> error: %s\n", key.c_str(), s.ToString().c_str());
    }
  }

  delete db;
  return 0;
}

crash_write.cc 代码。

cpp 复制代码
// crash_write.cc:写入若干 key 后直接 SIGKILL 自己,模拟 kill -9。
// 用来观察"进程没机会干净退出时,数据靠 WAL 恢复"。
// 编译(仓库根目录):
//   clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/crash_write.cc \
//           .output/build/libleveldb.a -o .output/crash_write
// 运行:
//   ./.output/crash_write .output/crash-demo
#include <csignal>
#include <cstdio>
#include <string>
#include <unistd.h>

#include "leveldb/db.h"

int main(int argc, char* argv[]) {
  const std::string dbpath = argc > 1 ? argv[1] : ".output/crash-demo";

  leveldb::Options options;
  options.create_if_missing = true;
  leveldb::DB* db = nullptr;
  leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
  if (!s.ok()) {
    std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
    return 1;
  }

  const char* codes[] = {"Crash1", "Crash2", "Crash3"};
  for (const char* code : codes) {
    std::string key = std::string("c:") + code;
    std::string value = std::string("https://example.com/") + code;
    s = db->Put(leveldb::WriteOptions(), key, value);
    if (!s.ok()) {
      std::fprintf(stderr, "put %s error: %s\n", key.c_str(),
                   s.ToString().c_str());
      return 1;
    }
    std::printf("put %s ok\n", key.c_str());
  }

  std::printf("killing myself with SIGKILL (simulating kill -9)\n");
  std::fflush(stdout);
  ::kill(::getpid(), SIGKILL);  // 不给 delete db / 析构任何机会
  return 0;                     // 不会执行到这里
}
相关推荐
程序猿阿越2 天前
containerd如何拉取镜像
后端·kubernetes·源码阅读
RobinXYuan4 天前
02 | 当数据放不进内存:从一次 Put 说起
源码阅读
Amos_Web12 天前
Rspack 源码解析(十一):资产 Hook 与增量构建
前端·rust·源码阅读
Amos_Web13 天前
Rspack 源码解析(十):Hash 与 Asset 生成
前端·rust·源码阅读
程序猿阿越14 天前
containerd如何创建Pod
后端·kubernetes·源码阅读
Amos_Web15 天前
Rspack 源码解析(八):Module、Chunk 与 Runtime ID
前端·rust·源码阅读
Amos_Web16 天前
Rspack 源码解析(七):Module、Chunk 与加载树优化
前端·rust·源码阅读
Amos_Web18 天前
Rspack 源码解析(五):CodeGenerationPass 与模块代码生成
前端·前端框架·源码阅读
Amos_Web19 天前
Rspack 源码解析(四):从 ModuleGraph 到 ChunkGraph
rust·源码阅读