编写插件
这一页从最简插件开始,逐步加上依赖、提供、配置校验、可选依赖和失败处理。
最简形态
import { definePlugin } from "dougong"
const plugin = definePlugin({
name: "app.hello",
setup() {
console.log("started")
},
})name 是必填的稳定标识。它用于诊断和 Installation ID(app.hello:1),不参与依赖解析——依赖解析看的是 Contract。
definePlugin 保留声明的 Plugin 形状用于类型推导,同时在边界把它规范化为仅含 name、config、requires、provides、setup 的不可变普通 record。声明不能使用 Symbol、隐藏属性、类实例或未知字段;requires 与 provides 也只能是由可枚举字符串 own key 构成的普通 record。错误因此留在定义处,而不是拖到 Host 启动时。
声明依赖
const DATABASE = service<Database>("app/database")
const ROUTES = extensionPoint<Route>("http/routes")
definePlugin({
name: "app.users",
requires: {
db: DATABASE, // Service → ctx.db 是 Database
routes: ROUTES, // ExtensionPoint → ctx.routes 是 ContributionView<Route>
},
setup(ctx) {
ctx.db.query("select 1")
ctx.routes.get()
},
})requires 的 key 是你自己起的别名,Contract ID 不必和它一致。这让同一个插件可以要求两个同类型的不同 Service:
requires: {
primary: PRIMARY_DB,
replica: REPLICA_DB,
}一个 Plugin 中,一个 Contract ID 只能出现一次。若两个 alias 指向同一个 Contract,或同一个 Service 同时出现在 requires 和 provides,definePlugin() 会立即拒绝;请删除重复 alias,或为语义上不同的能力声明不同的 Contract ID。
保留字
ctx 上有一组内置成员,别名不能和它们重名:signal、meta、log、cleanup、lifetime、spawn、on、emit、contribute。用了会在 definePlugin 时立刻报错。
可选依赖
import { optional } from "dougong"
definePlugin({
name: "app.telemetry",
requires: { tracer: optional(TRACER) },
setup(ctx) {
ctx.tracer?.startSpan("boot") // 类型是 Tracer | undefined
},
})没有提供者时 ctx.tracer 是 undefined,Installation 仍可正常激活。提供者后来出现或消失时,它的 Instance 会被重建——所以 ctx.tracer 在一次 Instance 生命周期内不会变。
optional() 只接受 Service。ExtensionPoint 不需要它:空 Map 本身就是合法值。
提供能力
const USERS = service<UserService>("app/users")
definePlugin({
name: "app.users",
requires: { db: DATABASE },
provides: { users: USERS },
setup(ctx) {
return {
users: createUserService(ctx.db), // key 必须和 provides 一致
}
},
})provides 的每个 key 都必须出现在返回值里,少一个就是 SERVICE_NOT_RETURNED。这条是编译期就能发现的:
provides: { users: USERS },
setup() {}, // ❌ Type '() => void' is not assignable to
// '(context, config) => Awaitable<ProvidedServices<...>>'同一个 Contract 被两个 Plugin 的 provides 声明占用,会在构图时抛 SERVICE_CONFLICT——在任何 Instance 启动之前。
贡献到 ExtensionPoint
definePlugin({
name: "app.user-routes",
setup(ctx) {
ctx.contribute(ROUTES, "users.list", { path: "/users", run: listUsers })
ctx.contribute(ROUTES, "users.show", { path: "/users/:id", run: showUser })
},
})第二个参数是局部 key,只需要在当前 Instance 内唯一。Core 会组合成真实 key:
<转义后的 Installation ID>/<转义后的局部 key>其中 % 和 / 分别转义为 %25 和 %2F。所以不同 Installation 可复用相同局部 key,而且两组不同的「Installation ID + 局部 key」不可能产生同一个真实 key。
返回的 Contribution 可以更新和提前撤回:
const c = ctx.contribute(ROUTES, "users.list", route)
c.update(nextRoute) // 原地更新,通知订阅者
c.dispose() // 提前撤回不调用 dispose() 也没关系——Instance 停止时全部贡献自动撤回。
配置与校验
config 接受任何 Standard Schema 实现(Zod、Valibot、ArkType 等):
import { z } from "zod"
const schema = z.object({
hostname: z.string(),
port: z.number().default(5432),
})
const db = definePlugin({
name: "app.db",
config: schema,
provides: { db: DATABASE },
setup(ctx, config) {
// ^ 类型是 schema 的 **输出** 类型,port 一定存在
return { db: connect(config.hostname, config.port) }
},
})
host.install(db, { hostname: "localhost" }) // 输入类型,port 可省略注意输入和输出是两个类型:host.install() 接受输入(port 可选),setup 收到输出(port 已填默认值)。
校验失败抛 ConfigValidationError(code CONFIG_INVALID),它带一个 issues 数组:
try {
await installation.ready()
} catch (e) {
if (e instanceof ConfigValidationError) {
e.issues.forEach((i) => console.error(i.path, i.message))
}
}所有受影响 Installation 的 Plugin 配置会在停止任何活动 Instance 之前全部校验完毕。 一个配置错误不会让你的应用停在半路。
异步 setup
definePlugin({
name: "app.db",
provides: { db: DATABASE },
async setup(ctx) {
const client = await connect()
ctx.cleanup(() => client.close())
return { db: client }
},
})同一拓扑层的 Installation 并发执行 setup。启动期间 ctx.signal 会在同层任何 setup 失败时 abort,可以用它取消慢操作:
async setup(ctx) {
const client = await connect({ signal: ctx.signal })
...
}失败会发生什么
setup 抛异常时:
- 该 Instance 已经获取的资源全部尝试释放(cleanup 逆序执行)
- 它暂存的监听、贡献、Contract kind 一个都不发布
- 同一层其他 Instance 的
ctx.signal被 abort - 整笔变更回滚到变更前的执行图
installation.ready()reject,installation.status变成"failed"host.status回到变更前的状态,不会停在中间态
const installation = host.install(brokenPlugin)
await expect(installation.ready()).rejects.toThrow("setup failed")
expect(host.status).toBe("active") // 其他 Installation 不受影响抛出非 Error 的值(throw "boom")会被分类为 INSTALLATION_UNAVAILABLE 的 DougongError,原值保留在 cause 里——undefined 永远不会同时表示「失败值」和「没有失败」。
更新与移除
const installation = host.install(plugin, { hostname: "a" })
await installation.update({ config: { hostname: "b" } }) // 换配置
await installation.update({ plugin: nextVersion }) // 换声明,保持 Installation 身份
await installation.remove()更新保持 Installation 身份:ID、诊断位置和 Group 归属不变。活动 Instance 会被替换,且只有受影响的依赖闭包会重启;无关 Installation 不动。
installation.id // "app.db:1"
installation.status // "pending" | "active" | "stopping" | "failed" | "removed"
installation.groupId // 所属 Group 的 ID
await installation.ready() // 等待这次安装就绪;失败则 reject一个完整的例子
import { createHost, definePlugin, extensionPoint, optional, service } from "dougong"
import { z } from "zod"
const DATABASE = service<Database>("app/database")
const METRICS = service<Metrics>("app/metrics")
const ROUTES = extensionPoint<Route>("http/routes")
const database = definePlugin({
name: "app.database",
config: z.object({ url: z.string(), poolSize: z.number().default(10) }),
provides: { db: DATABASE },
async setup(ctx, config) {
const client = await createPool(config.url, config.poolSize)
ctx.cleanup(() => client.end())
ctx.log.info("database connected")
return { db: client }
},
})
const users = definePlugin({
name: "app.users",
requires: { db: DATABASE, metrics: optional(METRICS) },
setup(ctx) {
ctx.contribute(ROUTES, "list", {
path: "/users",
run: async () => {
ctx.metrics?.count("users.list")
return ctx.db.query("select * from users")
},
})
},
})
const host = createHost({ name: "api" })
host.install(users)
host.install(database, { url: process.env.DATABASE_URL! })
await host.start()接下来
- 生命周期与资源 ——
cleanup/spawn/lifetime的完整规则 - 事务与变更 —— 一次原子地改多个插件
- Core API 规范 —— 每个 API 的边界情形