Dagger 引擎中 Dang SDK 的多大版本支持:版本路由、冻结快照与维护策略
【免费下载链接】daggerAutomation engine to build, test and ship any codebase. Runs locally, in CI, or directly in the cloud项目地址: https://gitcode.com/GitHub_Trending/da/dagger
Dagger 引擎对 Dang 语言模块的运行时支持采用"每个受支持的大版本各内嵌一份运行时 + 按模块engineVersion路由"的架构,使得旧模块永远保持其编写时的语言语义。本文基于引擎源码core/sdk/dang目录下的维护说明与实现,完整讲解这套版本分发机制的目录布局、版本门控常量、冻结(frozen)与活跃(living)实现的双轨维护策略、新增一个 Dang 大版本的标准流程,以及若干关键边界条件,帮助深入理解 Dagger 引擎如何在不破坏存量模块的前提下演进其解释型 SDK。
核心思想:一份引擎内嵌多份 Dang 运行时
Dang 是 Dagger 的一种解释型 SDK:模块以.dang源码直接由引擎内置解释器求值,不经过容器化执行。由于解释型 SDK 直接绑定语言语义,一旦语言本身发生不兼容变更(例如运算符含义改变),旧模块就会"跑偏"。为此,Dagger 的设计是:
- 引擎为每个受支持的 Dang 大版本内嵌一份独立的运行时包;
- 每个模块的
engineVersion(声明于其模块配置中)决定它被路由到哪一份运行时; - 已冻结的大版本不再获得任何新功能,仅接受编译器强制的修复与关键 bug 回移。
这一设计的直接好处是:新功能总是要求新的engineVersion,而新的engineVersion必然路由到最新的大版本——因此冻结的大版本永远不需要再做功能工作(见 core/sdk/dang/README.md)。
目录布局:四个组件的职责划分
core/sdk/dang目录及其外围由四个部分组成,各自职责明确:
| 组件 | Go 包 | 职责 |
|---|---|---|
| core/sdk/dang_sdk.go | sdk | 调度器(dispatcher)。dangSDK类型承载所有与版本无关的core.SDK行为,每次调用通过dangImplFor选择一个dangImpl实现 |
| core/sdk/dang/shared/ | dangshared | 版本无关的基础设施(nested-client 代理服务器、客户端元数据、错误转换)。严禁导入任何大版本的github.com/vito/dang |
| core/sdk/dang/v2/ | dangv2 | living(活跃)实现,导入github.com/vito/dang/v2。所有新功能都落在这里 |
| core/sdk/dang/v1/ | dangv1 | frozen(冻结)快照,服务于engineVersion < v0.21.5的模块(Dang v1 语义:.{ }表示"选择")。在快照时点与 living 包逐字节一致,仅包名、文档注释与 dang 导入路径不同 |
shared包禁止导入任何大版本的 Dang 库,这一约束看似严格,实际是整套复制策略成立的前提:正因为所有版本相关的东西都留在vN/包内部,冻结一份大版本只需"复制 living 包 + 机械改写导入路径",而shared中的公共代码永远只有一份。core/sdk/dang/shared/shared.go 的包注释明确写明了这条红线。
版本路由:dangImplFor与版本门控常量
调度逻辑集中在 core/sdk/dang_sdk.go 中。每个支持的大版本实现同一个dangImpl接口:
// dangImpl is implemented by each supported Dang major version // (core/sdk/dang/v1, v2, ...). Unlike core.ModuleTypes, ModuleTypes takes // already-scoped values: the scoping helpers are unexported in this package // and the version packages can't import it (cycle), so the dispatcher scopes // before delegating. type dangImpl interface { ModuleTypes(ctx context.Context, deps *core.SchemaBuilder, scopedSrc dagql.ObjectResult[*core.ModuleSource], scopedMod dagql.ObjectResult[*core.Module]) (dagql.ObjectResult[*core.Module], error) Runtime(ctx context.Context, deps *core.SchemaBuilder, source dagql.ObjectResult[*core.ModuleSource]) (core.ModuleRuntime, error) }路由函数是一个"最新优先"的阶梯,当前只有两级:
func dangImplFor(src *core.ModuleSource) dangImpl { if engine.CheckVersionCompatibility( engine.BaseVersion(engine.NormalizeVersion(src.EngineVersion)), engine.MinimumDangV2ModuleVersion, ) { return dangv2.Impl{} } return dangv1.Impl{} }门控常量定义在 engine/version.go:
// MinimumDangV2ModuleVersion is the minimum module engine version that gets // Dang v2 semantics (`.{ }` is dot-block application, `.{{ }}` is // selection); older modules keep Dang v1 semantics (`.{ }` is selection). MinimumDangV2ModuleVersion = "v0.21.5"即:模块的engineVersion≥ v0.21.5 获得 Dang v2 语义(.{ }是 dot-block 应用、.{{ }}是选择);更老的模块保持 Dang v1 语义(.{ }是 GraphQL 选择)。注意阶梯的注释——"newest-first ladder; adding a future major is one case"——新增一个未来大版本时只需在最前面加一个if分支。
dangSDK本身还负责所有版本无关的 SDK 能力开关。例如AsRuntime/AsModuleTypes/AsCodeGenerator/AsClientGenerator都返回自身,而AsModule返回false(因为 Dang SDK 是打包进引擎的,不像其他 SDK 那样作为模块加载,所以不暴露生成器函数);AlwaysEnablesSelfCalls返回true,因为解释器在运行期按名字对 schema 解析自己的类型,模块自身类型必须出现在它查询的 schema 中。每个As*入口最终都通过dangImplFor(source.Self())把具体工作委托给对应大版本。
living 与 frozen 实现:逐行同构,只有语义差异
v2/sdk.go(living)与 v1/sdk.go(frozen)结构几乎一一对应:两者都提供Impl.ModuleTypes(声明期,产出模块的 TypeDef)与Impl.Runtime(运行期,返回一个原生、不使用容器的runtime,其AsContainer显式返回false)。两处包注释把各自的身份交代得很清楚:
- v1 的包注释:
This is a FROZEN snapshot of the living implementation, taken when Dang v2 shipped: it gets no feature work, only compiler-forced updates when engine internals change and critical bugfix backports. - v2 的包注释:
This is the LIVING implementation: new Dang SDK features land here only. Older majors are frozen snapshots of this package.
两处也存在真实的、值得注意的差异。在ModuleTypes声明阶段选择"完整求值"还是"仅声明"运行器时:
- v1 检查原始实验特性标志:
src.Self().SDK.ExperimentalFeatureEnabled(core.ModuleSourceExperimentalFeatureSelfCalls); - v2 检查
src.Self().SelfCallsEnabled(),其源码注释解释:因为 Dang SDK 总是启用 self-calls(AlwaysEnablesSelfCalls),且 self-call 字段把返回值标注为运行时 schema 中的Dagger.<T>形态,若对自引用函数体做完整推断会去解析尚不含本模块 API 的 deps schema,所以声明期必须使用"仅声明"运行器runDangDirForModuleTypes(对应 v2/helpers.go 中调用dang.DeclareDir的实现)。
两版的Runtime().Call都遵循同样的收尾模式:defer中把任何错误经dangshared.ConvertError转换为携带 GraphQL 错误扩展的*core.Error,然后fnCall.ReturnValue(ctx, core.JSON(outputBytes))将 Dang 求值结果序列化为 JSON 返回给引擎。
shared 包:版本无关的三块基础设施
core/sdk/dang/shared/shared.go 提供三份被所有大版本共用的管线:
WithNestedClientServer—— 在127.0.0.1的随机端口上起一个临时 HTTP 服务器,把 Dagger API 暴露给嵌套客户端,构造一个指向http://<addr>/query的 GraphQL 客户端交给回调函数执行,并在回调返回后关闭服务器(10 秒超时)。Dang 模块对Dagger命名空间的一切 GraphQL 调用都经由这个本地代理转发回引擎查询。请求处理中会注入 OpenTelemetry 传播头,并透传moduleContext与fnCall,支撑 host service 代理等语义。NewNestedClientMetadata—— 从调用方上下文取出客户端元数据,并生成一份全新的嵌套客户端元数据(新的ClientID、ClientSecretToken、ClientStableID,继承SessionID与AllowedLLMModules),让 Dang 代码在隔离的客户端身份下求值。ConvertError—— 将 Dang 求值产生的错误转换为*core.Error;若错误是*gqlerror.Error,则按排序后的键把Extensions逐个序列化为JSON值挂回错误上,保留 GraphQL 错误扩展信息。
v2/helpers.go 中还有两个体现引擎侧深度的细节,两版各自持有同构拷贝:evalDangSource把模块的ContextDirectory挂载到临时路径后执行dang.RunDir,并把程序 stdout/stderr 显式重定向到用户可见的 trace span(dagql.UserFacingSpanContext)——因为引擎内运行时不像容器化 SDK 那样由执行器自动注入正确的 traceparent;ensureModuleSelfTypes则在声明期为模块自身声明的类型合成最小化的Dagger.<T>形态类型条目,使 self-call 返回值在 deps-only schema 阶段也能解析。
维护策略:四类变更各自的落地路径
core/sdk/dang/README.md 定义的维护策略按变更类型划分了四条路径:
- 新功能:只进 living 包(当前即
v2/)。 - 引擎侧胶合层(类型转换、调用分发)的 bug 修复:先修 living 包;仅当 bug 影响旧模块时才回移到冻结包。
- Dang 语言本身的 bug 修复(面向旧模块):在上游
vito/dang的对应维护分支(如release/v1)落地、打补丁标签(如v1.0.x)、再在引擎侧 bumpgo.mod。 - 引擎内部重构(
core、dagql、engine的 API 变动):会在编译期打破冻结包——这是设计上的快速失败信号;对策是对所有拷贝施加同一套机械修复。
从源码结构看,这套策略是自洽的:冻结包与 living 包逐字节同构(除包名/导入路径/注释),因此"机械修复"是可枚举、可批量执行的操作;而编译期破坏保证了重构不会在某一份拷贝里静默漂移。
新增一个大版本(vN+1)的标准流程
README 给出六步流程,结合当前仓库代码可以逐条对应到具体动作:
- 上游:在带新大版本后缀的模块路径上打
vN+1.0.0标签,并为刚冻结的前一个大版本保留维护分支。在该维护分支上,把 tree-sitter 语法导出的 C 符号加_vN后缀——因为 C 符号共享全局命名空间,两个大版本若导出同名符号将无法链接进同一个二进制;规范名tree_sitter_dang永远跟随 living 大版本,保证编辑器集成持续可用。 - 复制:
cp -r v2 v3,然后在v3/中把 dang 导入路径改写为新大版本、包名改为dangv3;v2/就此冻结——按 v1/sdk.go 的措辞更新其包文档为 FROZEN。 - 门控常量:在 engine/version.go 添加
MinimumDangV<N+1>ModuleVersion = "<next unreleased dagger version>"。 - 路由阶梯:在 dang_sdk.go 的
dangImplFor最前面加一个新 case。 - 依赖:
go get github.com/vito/dang/v<N+1>@v<N+1>.0.0。 - 测试:把
core/integration/testdata/modules/dang/下的 dang 测试模块的engineVersion门控 bump 到新版本,让主测试套件覆盖 living 大版本;并为任何语义发生变化的语法添加一个固定版本(pinned)的回归模块。
第 6 步在仓库中已有实例:core/integration/testdata/modules/dang/legacy-selection/ 正是为 v1/v2 边界添加的回归模块。其配置固定"engineVersion": "v0.20.6"(早于 v0.21.5 门控),源码注释明确要求"Do not bump this module's engineVersion",函数体使用 v1 语义的.{contents, size}选择语法,验证"钉在 v0.21.5 之前的模块必须保持.{ }作为 GraphQL 选择"。同目录下还有dot-block、self-calls、test-interface、test-directives等大量按特性命名的回归模块,构成整条大版本语义边界的测试护栏。
边界条件与注意事项
README 列出两条必须知晓的 caveat,它们直接影响仓库内模块的版本行为:
- 仓库内 Dang 模块钉在"已发布"引擎版本上。
.dagger/modules/*、cmd/codegen、modules/markdownlint等仓库内模块固定于上一个已发布版本的engineVersion(已发布 CLI 会拒绝比自己更新的配置),因此在下一个 dagger 版本发布前,它们会被路由到前一个大版本。实践要求是:跨越大版本边界时保持这些模块语法中立(syntax-neutral),或在发布后立即 bump。 engineVersion缺省值的归一化。ModuleSource中空/缺失的engineVersion归一化为当前引擎版本,从而路由到最新大版本;而早于 semver 的配置归一化为v0.11.9,从而路由到最老的大版本。engine/version.go 中presemverModuleVersion = "v0.11.9"常量即对应后者。
小结
Dagger 对 Dang SDK 的多大版本支持是"复制 + 机械改写 + 编译期校验"三者的组合:dang_sdk.go的dangImplFor用MinimumDangV2ModuleVersion = "v0.21.5"一个常量把语义边界钉死在engineVersion上;v1/与v2/同构的双包保证了冻结与活跃实现的漂移只能发生在编译期被发现的位置;shared/的导入禁令让复制策略成本恒定;上游 C 符号命名空间冲突的规避(_vN后缀)解决了两个大版本共存于单二进制的链接问题;legacy-selection这类 pinned 回归模块则为每条语义边界提供了可执行的验证。阅读 core/sdk/dang/README.md 与上文引用的 dang_sdk.go、engine/version.go、v2/helpers.go 源码,可以完整复现从版本路由到模块求值的全链路。
【免费下载链接】daggerAutomation engine to build, test and ship any codebase. Runs locally, in CI, or directly in the cloud项目地址: https://gitcode.com/GitHub_Trending/da/dagger
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考