Dagger 模块开发实战指南:用 Dang 语言编写模块的完整机制解析
【免费下载链接】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-dagger-modules技能文档,系统讲解如何用 Dang 语言编写 Dagger 模块:从dagger.json配置与engineVersion的版本路由,到主对象、构造器与 API 表面,再到自调用(self-calls)的返回类型注解规则、依赖、枚举/接口/标量、指令(directives)、Workspace 参数与缓存陷阱。读完本文,你可以直接动手编写、评审一个可运行的 Dang 模块,并理解引擎在core/sdk/dang下的版本分发与类型安装机制。
心智模型:Dang 模块是什么
面向用 Dang 编写 Dagger 模块的开发者,需要先把四个核心事实钉死:
- 一个 Dang 模块 = 一个目录,包含若干
.dang文件 + 一份把dang指定为 SDK 的配置。源目录中所有顶层.dang文件共同组成一个模块(子目录与其他文件被忽略),且声明在各文件之间顺序无关。 - 没有代码生成,没有容器——引擎原生解释执行 Dang。不存在生成的客户端:Dagger API 会自动导入到
Dagger命名空间下,裸名字同样可用(container.from(...)等价于Dagger.container.from(...))。 - 每一个公开的顶层声明都会成为模块 API:
type→ 对象(外加构造器),enum→ 枚举,interface→ 接口;let保持绑定为私有。自定义scalar在 API 边界上被暴露为字符串。 - **主对象(main object)**是名字与模块名匹配的那个
type;次要类型会以模块前缀进入 schema——例如模块test中的type Widget在 schema 里叫TestWidget。 - 自调用完全受支持(对 Dang SDK 始终开启,无需实验性开关):模块可以通过自身的根绑定调用自己的函数(
test.foo、tuiQa)。返回类型注解规则是第一大坑,下文专门展开。
Dang 语言本身的语法(可空性、copy-on-write 变更、控制流、GraphQL 互操作)不属于本文范围;本文只覆盖 Dang 作为Dagger 模块 SDK的部分。
在源码层面可以印证"无代码生成、无容器"这一点:dang_sdk.go 中dangSDK.AsModule()明确返回"无模块"(Dang SDK 打包进引擎而非作为 Dagger 模块加载),Codegen()是一个 no-op 直接返回源目录,AsModuleInitializer/AsClientInitializer均返回 false。而 v2/sdk.go 中的runtime注释写明"a native Dang runtime that doesn't use containers",其AsContainer()恒返回 false。
此外,引擎侧按engineVersion把模块路由到对应的 Dang 大版本。dang_sdk.go 中的dangImplFor按"最新优先"的阶梯,比较模块的engineVersion与engine/version.go里的MinimumDangV*ModuleVersion常量后选择实现;该机制的维护策略(v1 冻结快照、v2 为 living 实现)完整记录在 core/sdk/dang/README.md。
模块配置:dagger.json
最小配置如下:
{ "name": "counter", "engineVersion": "v0.21.5", "sdk": { "source": "dang" } }配置项逐条说明:
"sdk": "dang"(纯字符串形式)同样可用。engineVersion决定 Dang 大版本:< v0.21.5路由到冻结的 Dang v1(其中.{ }是 GraphQL 选择/selection);>= v0.21.5是 Dang v2(.{ }变为点块应用 dot-block application,.{{ }}才是 selection)。这个阈值在源码中是硬编码常量:engine/version.go 定义MinimumDangV2ModuleVersion = "v0.21.5",注释明确"更老的模块保留 Dang v1 语义"。不要在未核对模块选择语法的情况下提升老模块的engineVersion——v1 里写.{ }做 selection 的模块升到 v2 语义下会直接解释错。- 依赖:
"dependencies": [{"name": "gochild", "source": "gochild"}]——依赖可以来自任意 SDK(Go、Python、TypeScript、Dang),可并存。 "disableDefaultFunctionCaching": true:模块级关闭默认函数缓存(比逐函数@cache更粗糙的全局开关)。- 遗留的
"sdk": {"experimental": {"SELF_CALLS": true}}已过时:自调用已毕业为运行时能力检查,对 Dang 始终开启。源码依据是 dang_sdk.go 的AlwaysEnablesSelfCalls()恒返回 true,注释解释"解释器在运行时按名字对照 schema 解析自己的类型,因此模块自身类型必须存在于它查询的 schema 中"。
主类型、构造器与 API 表面
type Greeter { let secret: String! = "hidden" # 私有状态,永不暴露 name: String! # 公开数据字段(同时也是构造器参数) new(name: String! = "world") { # 显式构造器 self.name = name.capitalize self # 必须以 self 结尾 } greet: String! { "Hey, " + name } # 计算字段 / 零参函数 }围绕这段示例的规则:
- 没有
new时,未初始化的公开字段按声明顺序组成隐式构造器(位置式构造可用)。 - 构造器参数会成为顶层 CLI flag:
dagger call --name alice greet。参数名不必与字段名相同,且构造器函数体真的会执行。 - 公开是默认值:裸的类型声明即被暴露;
let即私有——包括let函数,这是内部辅助函数的惯用写法。pub关键字是遗留物:仍可解析(作为 no-op)且格式化器会剥离它——新代码不要写。 - 带函数体的字段是函数/计算字段;裸的类型化字段是数据。docstring(声明或参数前的
"""...""")会变成 API 描述。 - 私有(
let)字段跨调用持久化:它们会序列化进对象状态并在下次调用时重建(rehydrate)。以 copy-on-write 风格链接状态变更:with(x): Self! { self.x = x; self }。 - 非空参数(
T!)是必填的;可空参数可选——可选输入的惯用写法是arg: File = null。 Map[...]无法通过 API 暴露;把 map 留在let字段里(私有序列化没问题)。匿名记录类型(ad-hoc record)同样不能暴露——请声明一个具名type。源码印证:v2/helpers.go 的dangTypeToTypeDef遇到dang.MapType直接报错 "cannot be exposed via GraphQL; store maps in private (let) fields instead"。Void返回值标记纯副作用函数;函数体以null(或一个Void类型调用)结尾,并用.sync强制容器执行。
引擎如何把上面的.dang声明翻译为 schema?v2/helpers.go 中的initDangModule遍历env.Bindings(dang.PublicVisibility):*dang.ConstructorFunction生成对象 typedef + 构造器并withObject,*dang.Object则按 kind 分发为withEnum/withInterface(scalar 被跳过,按字符串处理)——这正是"只有公开顶层声明成为 API"的实现路径;createObjectTypeDef也只收录Visibility >= PublicVisibility的槽位。
自调用(self-calls)
自调用通过模块的根绑定(模块名的 camelCase 形式)经由引擎调用模块自己的 API:
type Test { containerEcho(msg: String!): Container! { container.from("alpine").withExec(["echo", msg]) } print(msg: String!): String! { test.containerEcho(msg: msg).stdout # 自调用 } fresh: Dagger.Test! { test } # 自调用构造器 }关键规则:
- 返回类型注解规则(第一大坑):返回自调用结果的函数,必须把返回类型声明为
Dagger.<SchemaTypeName>!——例如Dagger.Test!,次要类型Widget则是Dagger.TestWidget!——而不能写裸的本地类型。原因是自调用产生的是运行时 schema 中已安装的那个类型(带命名空间前缀、携带 GraphQL id);裸的本地类型是另一个类型。用裸类型注解会让运行时收到一个原始 ID 字符串,而它期望的是一个对象。 - 本地构造(
Widget(x))产生的是裸的本地Widget!;只有真正的 API 调用才产生Dagger.TestWidget!。 - 自调用结果是真实对象:字段可以正常读回(
test.fresh.getMessage),次要类型上同样成立。 - 当模块作为依赖被使用时,自调用(包括传递性地)同样可用。
- 裸
test(零参构造器的自动调用)是方法内部重置回全新实例的方式。
引擎侧的实现证据在 v2/helpers.go 的ensureModuleSelfTypes:在模块类型声明阶段(ModuleTypes),传给解释器的 schema 还只是依赖 schema(不含模块自身类型),因此该函数解析模块的.dang源文件,把每个公开顶层类型按core.NamespaceObject命名空间化后以最小形状注入 introspection schema(仅含一个id: ID!字段),使Dagger.<T>注解在声明期可解析;"只有顶层类型声明成为模块类型、体内嵌套类型不会被提升"正是该函数的注释所述。v2/sdk.go 的ModuleTypes也解释了为何声明阶段必须用"declaration-only"runner:完整推断会尝试把自引用函数体解析到尚未携带模块自身 API 的依赖 schema 上。
依赖
- 名为
foo的依赖可以作为根绑定foo调用:foo.curve(...)、dangchild.value。带构造器参数的依赖像函数一样调用:engineDev(ws: source).test。 - 依赖的类型以模块命名空间出现:依赖
foo的enum EcCurve是FooEcCurve,依赖dep的interface Greeter是DepGreeter。
枚举、接口、标量
enum Status { PENDING RUNNING DONE }——用==比较;CLI 按原样传成员(--status DONE)。集成测试 module_dang_test.go 的TestEnums用例即验证了dagger call get-status --status COMPLETED这类调用。interface Local { greet(name: String!): String! }加上type Hey implements Local——实现者不应声明 Dagger 为每个接口合成的id: ID!字段。- 结构一致性(structural conformance)可跨模块边界:形状匹配依赖接口的对象可以直接作为该接口传入,无需
implements。 - 接口方法若触碰核心类型,需用限定名注解:
apply(container: Dagger.Container!): Dagger.Container!。 scalar Timestamp在 API 边界上暴露为 String,值以字符串形式到达。
Dagger 消费的指令(directives)
- 函数级:
@check(标记一个 check,通常用于Void返回)、@generate(用于返回 Changeset 的 generator)、@up(用于Service!)、@agent(见下)、@cache。 - 参数级:
@defaultPath(path: ...)用于Directory!参数——相对路径相对于模块解析,"/"相对于上下文根;@ignorePatterns(patterns: [...])用 gitignore 风格模式过滤("!keep"白名单可用)。位置参数与命名参数两种写法都能解析。 - 放置位置:后缀(
screen: String! @cache(...))或声明前一行作前缀。 - Agent 惯用法:
agent(base: LLM!): LLM! @agent { base.withTools(currentNode).withSystemPrompt(systemPrompt) }实现上,v2/helpers.go 的functionDirectiveSelectors把函数级指令一一翻译为 dagql 选择器(withCheck/withGenerator/withUp/withAgent/withCachePolicy);applyArgDirectives处理参数级@defaultPath与@ignorePatterns,且指令参数会经由evalDirectiveArg在属主类型捕获的闭包中求值——注释特别提到这让@cache(policy: FunctionCachePolicy.Never)这类引用导入符号的指令参数能以与类型自身源码一致的方式解析。集成测试TestDirectives(module_dang_test.go)覆盖了位置/命名defaultPath、位置/命名ignorePatterns、混合语法,以及带枚举参数的@cache用例。
Workspace 参数
- 一个
Workspace!类型参数(裸写或Dagger.Workspace!)会由调用方的 workspace 自动填充——dagger call无需传 flag;对 agent 则从绑定的 workspace 填充,并对模型隐藏。 let ws: Workspace!作为未初始化字段是持有它的标准模式。读取用ws.file(...)、ws.directory(path, exclude: [...])。- 挂载进来的 workspace 是一个没有
.git的普通快照——git diff不工作;应使用Workspace.git.uncommitted(一个 Changeset)配合.diffStats/.asPatch。
缓存陷阱
- 引擎在同一会话内按(对象 id、字段、参数)记忆化函数结果。有副作用或读取实时数据的函数必须退出缓存:
@cache(policy: FunctionCachePolicy.Never)(会把每次调用的 nonce 混入调用 id),或用@cache(ttl: 300)设置存活时间。源码印证:cacheDirectiveSelector把policy/ttl参数翻译成withCachePolicy选择器,policy缺省为FunctionCachePolicyDefault。 - 即使设为
Never,完全相同的容器 exec 仍会命中 exec 缓存——用 nonce 打破:.withEnvVariable("NONCE", Random.string)。
遮蔽核心类型
- 模块可以声明遮蔽核心名的类型(
type Container);此后裸名字指本地类型,Dagger.Container!/Dagger.container用于消歧指回核心。 - 引擎侧的实现细节在 v2/helpers.go 的
markDangLocalType:本地类型在命名空间化之前会先被临时改名为DangSDKLocalType<name>(保留OriginalName),目的是防止本地引用如Container在命名空间化运行前被误认为daggercore.Container;collectDangLocalTypes依据绑定来源(BindingOriginLocal)收集本地类型集合,只有本地类型才会被标记。
易错点清单(Pitfalls Checklist)
| 陷阱 | 后果 |
|---|---|
自调用返回用裸本地类型而非Dagger.<T>!注解 | 运行时收到原始 ID 字符串(自调用是可用的——不要从旧注释得出相反的结论) |
有状态/实时工具函数漏写@cache(policy: FunctionCachePolicy.Never) | 第二次调用重放第一次的结果 |
暴露Map[...]或匿名记录类型 | 硬错误 |
实现依赖接口时声明了id字段 | 报错;应省略 |
在>= v0.21.5的模块里使用 v1 的.{ }selection | 那是点块应用了;selection 要用.{{ }} |
| 以为体内嵌套的类型声明会成为模块类型 | 只有顶层类型声明成为模块类型,体内定义不会提升进 schema |
附:仓库中的相关源码位置
- 版本无关的 SDK 调度与能力声明:core/sdk/dang_sdk.go
- 版本路由常量(
MinimumDangV2ModuleVersion = "v0.21.5"):engine/version.go - v2 实现(类型安装、指令翻译、函数调用):core/sdk/dang/v2/sdk.go、core/sdk/dang/v2/helpers.go
- 大版本维护策略与"新增一个大版本"的步骤:core/sdk/dang/README.md
- 技能文档原文(引擎通过 core/skills/skills.go 将其 embed 并经 LLM Skills API 提供给模型):core/skills/dang-dagger-modules/SKILL.md
- Dang 模块集成测试(指令、枚举等行为的回归验证):core/integration/module_dang_test.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),仅供参考