09|组合发生在启动时:命名配置、分发补丁如何拼出一个产品实例
先别为不同的产品形态各写一份运行时
打开这个项目的启动入口,看见命令行工具和 Web 应用两种形态,再看见"以 Web 模式启动"和"以无服务器模式跑一次任务"两条命令,最省事的脑补是:两套应用,共用一些库。
这是第 01 章警告过的那条错路。这个仓库的第一条设计原则把它掐死:一个正在运行的实例,是启动时按层顺序叠出来的一棵插件树。
启动器本身自己承认:按运行模式动态加载对应逻辑,好让无关模式滚出这条分发路径。这里说的"启动器"专指命令行入口那个分发文件——它只做一件事:把解析好的名字、补丁文件、剩余参数丢给真正的启动流程,自己不到一百行。循环不在这里。Web 服务器也不在这里。启动器甚至不解析具体的应用级参数——那些是内层应用插件自己的事,不是启动器的职责。真正干活的组装逻辑(层叠补丁、遥测开关、失败回滚、进程关闭)住在入口紧挨着的另一个文件里,那个文件不薄,只是它仍然不认识循环、不认识某个具体工具——薄的是"分发决策",不是"组装机制"。
所以不同的产品形态不是两份代码树。它们是同一份空根上,叠了不同的后几层。
1. 三样东西,不要叫成"配置文件"
先给判断:命名配置、分发补丁、覆盖补丁是三个职责,不是三种配置口味。
命名配置(profile) 是运行环境目录下一个有名字的目录。它包含:树外插件依赖、一份声明"我要叠哪些分发补丁"的清单、以及用户自己写的覆盖补丁文件。有两个内置模板是随安装自带的,第一次使用会自动初始化;其它名字必须显式创建,不存在就明确报错,不会静默地凑合跑起来。
分发补丁(bundle) 是一种分发格式:一个包在自己的元数据里声明"我的补丁文件在哪"。分发补丁的本体就是它的补丁清单;有些补丁顺便还带上这份补丁要挂载的实际插件代码。这类基础分发包本身可能没有任何运行时 API——组装逻辑只读它声明的补丁字段,不读它的程序导出。
覆盖补丁(patch) 是作用在空的配置行列表上的一次手术:按编号点名某一行,替换它的整份配置,或者插入新的行。这里有一条容易被忽略但很关键的规则:编号补丁不做深度合并,你要保留的字段必须重写一遍。这不是疏忽。深度合并会让"这一行现在到底是什么"变成好几层配置文件叠加后的心灵感应式猜测。整行替换让"打印出当前生效配置"这个动作打出来的每一行,都是某一层的完整主张,不掺半层的旧值和半层的新值。
基础分发层是每个命名配置的第一层:模型适配器、工具、持久化、沙箱和审批策略、设置、凭据、遥测。Web 应用分发层在这份基础层上加浏览器应用;无服务器分发层在同一份基础层上加一个跑一次就退出的执行器,不挂宿主端、不挂 HTTP 服务、不挂浏览器相关插件。这两个表面层是兄妹关系,不是父子关系。
如果把这三样揉成"一份大配置文件"会怎样?Web 要改一行工具超时,无服务器版的文件里没有这行,于是复制一份。三周后两份底座分叉,沙箱策略只在一边生效。分层设计把底座写成一份补丁,表面写成盖在上面的补丁。改底座,两个产品一起变;改表面,另一个产品无事发生。
2. 层序就是产品,空根必须保持为空
判断:产品形态是一张有顺序的清单,不是一棵对象树。顺序错了,你叠出的就不是你以为的那个实例。
官方的层序是这样:
- 命名配置清单里列出的每一个分发补丁,按声明顺序
- 这个命名配置自己的覆盖补丁文件
- 运行环境级别的覆盖补丁文件(因此会覆盖每个命名配置自己写的那份)
- 命令行传入的覆盖参数,按传入顺序
- 某些硬性关闭开关(比如遥测)
所有这些层压在什么上面?一个空列表。每次准备一个命名配置的运行环境,都会把这份空的根配置重新写回磁盘。原因不太美观但很正确:底层的配置加载器如果把当前已经叠好的树重新写回它的配置入口,下次启动会把每个分发补丁的插入动作再叠一遍,产生重复。这份空根文件存在于磁盘上,只是因为配置加载器需要一个真实的文件把"这是哪个命名配置的根目录"钉死下来。真正的构图内容不在这个文件里。
从空列表开始逐层套用补丁,这个组装过程在"真正启动"和"只是打印当前配置看看"两条路径上复用同一套补丁应用算法(同一个规则集,保证两条路径算出来的结果逐行相等)——你在终端里看到的树,就是即将挂载的树,不是另外算出来的一份近似值。
如果某个补丁点名了一个在清单里、却没有声明补丁字段的包,加载会明确报错——把它当成普通依赖装进来不是"没有补丁",是配置配错了。第 00 章说的"误配置在加载时就该响",在这里兑现。
有两条调试命令是这套设计的直接产物:打印当前用户实际生效的配置,或者跳过用户自定义层、只打印默认配置。任何一行打得出来,你都可以写一份自己的补丁去替换它。
如果层序是"谁先加载谁赢"会怎样?插件加载顺序变成隐性依赖,一次热重载就可能换成另一张产品树。空根加显式清单,让"这个实例到底是什么"变成一份可以打印、可以比较差异、可以提交进版本控制的文本。
3. 换产品形态是叠一层,换执行器是补一行
判断:第 06 章说"最终配置只选一个执行器实现",落到产品层面,就是分发补丁行和用户覆盖补丁行的事,没有第三条"产品级条件分支"。
补丁自身的写法也守着一条纪律:一次插入铺在空根上;后面的分发补丁和用户补丁按编号点名同一行;最后一次写入生效。会随运行模式变化的值不该住在基础层里——否则同一行会被多个不同模式的分发层和用户补丁分别写一次,合并语义立刻爆炸。基础层只放共享的身份信息和中性默认值;具体模式的分发层重写它需要的那整份完整配置。
平台层面的分叉也是补丁,不是散落在代码里的条件判断:不同 shell 执行器根据运行平台声明各自的启用/禁用条件,一份补丁文件里,每种主机环境恰好激活一条 shell 执行链路。环境要选用哪些插件,通过覆盖层去表达,不要在配置文件里写命令式逻辑。
两种执行器都想登记同一个能力键?配置没写完整——只关掉了一种、忘了打开另一种,或者反过来——加载时会出现重复登记,明确报错。这是第 06 章"一个上下文一个实现"这条纪律在组合层的回声。
Web 表面在基础层上插入宿主端、Web 服务器、浏览器插件花名册;无服务器表面在同一份基础层上禁用热更新、插入一次性执行器:加载器稳定之后,通过创建能力开一个持久化的 Agent,把任务当成普通用户消息提交,等它空闲,把最后一段非空的助手回复打印到标准输出,然后退出进程。这个一次性执行器走的是第 03 章那张表——依赖接口,不依赖循环内部对象。
用户要换执行世界、关遥测、加一个树外插件,写一份覆盖补丁就够了,不必复制整个基础分发层,也不必新建一个专门的"沙箱版基础层"包。第 08 章的预设机制是另一根轴:那是一个会话加入一份构图。这一章是一个进程叠出一份部署。两根轴都是组合,一层不该模仿另一层的接口形状。
如果换形态靠复制代码会怎样?循环内部会出现"如果是无服务器模式就……"这样的条件分支,宿主端代码被条件编译进循环,持续集成矩阵按产品线裂开。把产品线收成一份分发补丁清单,循环继续保持穷。穷,是因为组合发生在启动时。
4. 用户层是活的;坏补丁不准拆掉正在跑的树
判断:组合不是"启动时读一次,然后冻住"。用户那一层保持可热更新,失败必须落到"上一份好树还在跑"。
每次一个命名配置启动,都会有一个监听器盯着这份覆盖补丁文件的变化——即便这份文件当时还不存在,也照样盯着这条路径。突发的多次写入会被串行化处理。每次刷新时,按调用方声明的层序重新组装成最终的用户补丁(分发层在下,覆盖层在上)。如果读取失败、解析失败、或者配置加载器判定这份候选配置不合法,会保留上一份好树,并广播一个"配置更新失败"的通知。一次性的产品表面(比如无服务器模式)会在自己关闭时把这个监听器一起拆掉。
空文件或者只有注释的文件会被判定为解析失败——它解析出来的是"什么都没有",不是"一份空清单"。要真正停用这一层,需要显式写一个空列表。点名一棵树里不存在的行编号,只会打一条警告,不会导致启动失败:补丁没打上,底座还在正常运行。
这和第 02 章的可逆注册是同一种品味。热更新不是"重新启动整个进程"。它是卸掉用户层那些贡献,换上一组新的贡献。分发层通常不会被触碰——模型适配器、会话存储、工具管道都还在原地。你改动的只是盖在上面的那几行。
还有一个实际的小机制:任意命名配置里写的裸插件名,要能靠普通的模块解析规则,一路找到实际安装的那个包。这是通过在运行环境根目录维护一份扁平的符号链接结构来实现的。组合发生在配置文件里,但模块解析仍然走普通的解析规则,不发明第三套模块系统。
如果用户补丁一写错就拆掉整棵树会怎样?改一行超时,整个 Web 会话直接死掉。这里的选择是:坏的那一层回滚,正在跑的 turn 继续从同一份会话日志投影历史。第 05 章的那份真相,不会因为一次配置文件的打字错误而消失。
5. 结语:带走一句话
一个运行实例是空列表上按顺序叠起来的补丁——换产品形态是再盖一层,不是去复制循环代码;你在"打印当前配置"里看不见的那个"产品",其实并不存在。