前置:第 01、02 课(03–06 可并行学) | 预计时长:60–90 分钟 | 动手环节:必须完成
你将学会
- Bundle 与 Profile 的确切定义,以及它们如何叠加成一棵插件条目树;
- 补丁算法为什么是「按行整体替换」而不是深合并;
- 启动六步流程,以及「配置错误大声失败」的具体实例;
- 一处与直觉相反的关键事实:仓库没有作为组合入口的
cordis.yml。
问题引入
第 02 课说插件条目来自显式声明,第 01 课你见过 --dump-config 的组合树。这一课补上中间的机制:dsh-base 的 80 余行插件、你的自定义行为、应用层,是怎么叠成一棵树的?
一句话答案:Profile = 有序 Bundle 列表 + 补丁文件,启动时逐层叠加,last-write-wins。
正文
7.1 Bundle 与 Profile 的定义
- Bundle = 一个 npm 包,三部分:
dsh.bundle清单字段(如packages/bundle/base/package.json:36-40)+ 一个cordis.patch.yml补丁文件 + 被补丁引用的插件依赖。dsh-base是所有 profile 的底座。 - Profile =
$DSH_HOME/profiles/<name>目录:package.json里dsh.profile.bundles是有序 bundle 列表,外加用户cordis.patch.yml。五个内置模板(packages/boot/app-boot/src/profile.ts:137-158):web/headless/sdk/sdk-minimal/acp,都是「dsh-base+ 一个应用层」。
7.2 关键纠正:组合全在补丁层
仓库根和各包都没有作为组合入口的 cordis.yml。 Profile 的根 cordis.yml 是启动时写入的空数组占位(profile-boot.ts:80-84);现存 cordis.yml 只存在于示例、测试 fixture 与快照。全部组合都发生在 cordis.patch.yml 补丁层:
// packages/boot/app-boot/src/profile.ts:854-861 简化
composeEntries(profile) {
layers = profile.layers.flat() // 各 bundle 层 + 用户层 + home 层 + --patch 层
return applyEntryPatches(EMPTY_ROOT, layers) // 根是空数组占位
}
// vendor/include/src/index.ts:58-128 简化
applyEntryPatches(rows, patches) {
for (patch of patches) {
if (patch.insert) { rows.push(...patch.insert); buildMap(插入的行) } // :96-101
if (patch 命中某行 by id) 整段覆盖该行 config // :121-124
if (未命中) warn('patch: entry %s not found', id) // :112 不静默
}
}
叠加顺序(docs/architecture.md:27):每个 bundle 按 bundles 列表顺序 → profile 的 cordis.patch.yml → home 级 → 任意 --patch overlay,最后加遥测开关补丁。dsh-base 的补丁主体就是对空根的一次大规模 insert(80 余行);dsh-web-app 在其上按 id 覆写 + insert Web 行。
7.3 补丁语义的三条规则
- 按行整体替换,非深合并:补丁按
id命中行,config等字段整段覆盖;insert进去的行可以被后续补丁再命中;冲突解决 = last-write-wins。 - 行顺序不携带加载语义:激活由服务可用性驱动(第 02 课 epoch 机制)。补丁只决定「有哪些条目、各自配置」,不决定「谁先启动」。
- 未命中的补丁不静默:警告 +
dsh --dump-config逐层重放并打印每行来源(provenance)。这是「配置错误大声失败」原则在组合层的落点。
7.4 启动六步
// apps/cli/src/bin.ts → profile-boot.ts → packages/boot/app-boot/src/index.ts
1. parseDshArgs;runProfile
2. loadProfile:解析 bundle 层;重写空根配置 `[]`
3. composeProfile:修复模块回退链 → 收集 bundle 层 → 用户层 → home 层 → --patch → 遥测补丁
4. installFailLoud:注册 unhandledRejection → fatal + exit(1)
5. boot:new Context() → ctx.plugin(Loader) → prepare(ctx) // 注入 environment/cmdlineArgs
→ mountRootInclude(...) // Include 读文件→打补丁→事务挂载
→ ctx.get('loader').await() → assertEntriesActivated(ctx) // 逐个检查 fiber 状态,失败即抛
6. 若 patchReload='live':挂 watch-only HMR 监听用户补丁
「大声失败」实例集:
- bundle 未声明
dsh.bundle即抛错(profile.ts:833-835); - 补丁文件损坏必抛——仅文件缺失返回
undefined,「unreadable, unparsable, or non-array file throws」(vendor/include/src/index.ts:280-289); - 插件配置用 schemastery 在挂载前校验,失败抛
ValidationError(vendor/cordis/src/fiber.ts:50-62); - 树结算后审计:无 fiber 的启用条目 / pending 条目(列出缺失服务名)= 启动失败(
index.ts:673-679, 707-740)——第 02 课的循环依赖 PENDING 就是死在这里。
7.5 host / client 双编译面
Web 侧还有一个编译期事实:host 与 client 两侧都对 Context 做同名声明合并但类型不同,一个编译程序不能同时看见两侧(tsconfig.host.json:1-4)。体现为两套检查聚合(tsconfig.host.json / tsconfig.client.json);组合上的桥是 dsh-web-app 补丁同时声明 host 行与 dsh.client 浏览器名册。第 12 课讲浏览器世界时会再用到。
动手环节
- 看来源:
pnpm dsh --dump-config,找到任意一行,确认输出标明了它来自哪个补丁层(provenance)。 - 做一个自定义 profile:复制
$DSH_HOME/profiles/web为web-tutorial,在其cordis.patch.yml里挑一个工具条目改成disabled: true,然后pnpm dsh --profile web-tutorial --dump-config验证生效。 - 体验大声失败:在同一份补丁里故意把某个 patch 的
id打错(指向不存在的条目),再 dump 一次——应看到entry not found警告;再把补丁文件改成非法 YAML,确认启动/dump 直接抛错而不是降级。 - 推演题:两个 bundle 的补丁都要改
tools条目的config,谁赢?如果把其中一个 bundle 在bundles列表里后移一位,结果怎么变?这和「行顺序」有什么区别?
自检清单
- Bundle 的三部分是什么?Profile 目录里哪两个文件承载组合?
- 为什么说「行顺序不携带加载语义」?那加载顺序由什么决定?
- 循环依赖插件(第 02 课 PENDING)会在启动流程的哪一步被暴露?
- 你的 profile 补丁想覆盖
dsh-base的某行配置,靠什么字段命中?没命中会怎样?
常见误解
- 「根目录有个总
cordis.yml控制一切」——没有。根cordis.yml是启动时写的空占位,组合全在补丁层。 - 「补丁是深合并/JSON merge-patch」——按 id 命中行后整段覆盖 config,last-write-wins。
- 「
disabled行为工具消失是运行时判断」——补丁层在组合期决定条目去留;!!js表达式仅允许出现在config与disabled字段(postmortem 0002 的教训,见第 11 课)。
延伸阅读
- 维基:《profile-bundle-composition》
- 仓库:
docs/architecture.md、apps/cli/README.md(profiles 一节)、apps/cli/reference/README.md(层优先级的权威) - 下一课:第 08 课 · 能力接缝:三角色与两种形态