BLOGE
0.9.8-RC1在线版 · 事实校验 2026-09-15 · English
第 7 章 —— 设计良好的 Operator
承诺: 读完本章后,你会知道如何判断哪些逻辑该放进 operator、如何声明它的 schema 和行为契约,以及如何避免最常见的设计错误——把每一段逻辑都做成节点。
学习目标
- 运用单一能力原则来判断一段逻辑是该做成 operator,还是应该留在编排层。
- 把 operator 归入三层复用金字塔: 基础设施 → 能力 → 领域。
- 声明 输入/输出 schema,让编译器、工具链和下游节点能更安全地校验数据。
- 设置 幂等性 和 副作用 契约,帮助框架做出更安全的重试与调度决策。
- 识别——并消除——那些本应是 transform 或 input binding 表达式的琐碎数据整形 operator。
前置条件
- 第 4 章 —— 流动的数据(特别是 transform 和数据转换分层模型)
- 第 6 章 —— 韧性设计(了解节点契约如何影响重试和超时策略)
源示例
| 文件 | 展示内容 |
|---|---|
Operator.java | 核心 @FunctionalInterface —— execute、idempotency、sideEffectType |
OperatorContext.java | 引擎传给每个 operator 的只读 record |
SchemaAware.java | 可选接口,用于显式声明输入/输出 schema |
Idempotency.java | IDEMPOTENT、NOT_IDEMPOTENT、UNKNOWN |
SideEffectType.java | READ_ONLY、WRITE、EXTERNAL_CALL、MIXED |
OperatorMeta.java | 用于标注 layer、tags、version、owner 的注解 |
OperatorLayer.java | INFRASTRUCTURE、CAPABILITY、DOMAIN |
Operator Design Specification | 完整的规范文档(八个章节) |
为什么这很重要
前几章教会了你如何构建 graph 和传递数据。但一个 BLOGE 系统的质量,最终取决于它的 operator 质量——它们是每个 graph 的基本构件。
如果 operator 的边界划错了,你会看到:
- 膨胀的 graph:一半节点只是单行字段重命名,本来用 transform 就能搞定。
- 巨石 operator:把三个互不相关的 I/O 调用捆在一起,既无法单独重试,也无法单独观测。
- 不透明的契约:在读源码之前,谁也不知道一个 operator 的输出长什么样。
把边界划对了,你就能解锁:
- 复用 —— 一个边界清晰的 operator 可以服务于数十个 graph。
- 可观测性 —— 每个 operator 都有自己的延迟、错误率和重试指标。
- 安全演进 —— 类型化 schema 让破坏性变更在编译时就能被发现,而不是凌晨两点。
心智模型
单一能力原则
一个 operator 应该封装一项可独立度量的业务能力。问自己五个问题:
数据转换的四个层次
并非每段逻辑都应该放进 operator。BLOGE 提供了四个层次,各有不同的成本:
| 层次 | 工具 | 成本 | 何时使用 |
|---|---|---|---|
| 路由 | input { userId = fetchUser.output.id } | 零 | 传递或重命名字段 |
| 轻量 transform | input { name = concat(a.output.first, " ", a.output.last) } | 零 | 拼接、类型转换、空值填充 |
| 结构适配 | transform orderSummary { … } | 零(虚拟节点) | 多字段重组,且被多个下游节点复用 |
| 业务逻辑 | node … : Operator { … } | 完整成本(有调度、计时、可重试) | 领域规则、I/O、副作用 |
经验法则: 如果移除该逻辑只会破坏 数据格式兼容性——用 transform。如果移除它会改变 业务含义——它必须是 operator。
三层复用金字塔
每一层通过
@OperatorMeta(layer = …)
使用
OperatorLayer
枚举(INFRASTRUCTURE、CAPABILITY、DOMAIN)来标注。用它在代码和文档中明确 operator 的复用范围。
第一个可运行示例
下面设计一个从外部服务获取用户的 operator。类型化 I/O、schema 和行为契约会明确它与框架之间的边界。
步骤 1 — 定义类型化的输入和输出 record
public record FetchUserInput(String userId) {
public FetchUserInput {
if (userId == null || userId.isBlank())
throw new IllegalArgumentException("userId must not be blank");
}
}
public record FetchUserOutput(String id, String name, String email, int vipLevel) {}
Record 为你提供了不可变性、紧凑构造函数中的校验,以及通过
SchemaIntrospector
实现的自动 schema 内省。
步骤 2 — 实现 operator
这 43 行实现保留完整,让 metadata、外部调用、幂等性、effect 分类和两端 schema 能够作为一份 Operator 合同一起审阅。
@OperatorMeta(
layer = OperatorLayer.INFRASTRUCTURE,
tags = {"user", "query"},
description = "Fetch a user by ID from the user service",
owner = "user-platform",
since = "1.0.0"
)
public class FetchUserOperator
implements Operator<FetchUserInput, FetchUserOutput>, SchemaAware {
private final UserServiceClient client;
public FetchUserOperator(UserServiceClient client) {
this.client = client;
}
@Override
public FetchUserOutput execute(FetchUserInput input, OperatorContext ctx)
throws Exception {
// 业务数据通过 `input` 传入,而不是通过 `ctx`。
return client.fetchUser(input.userId());
}
@Override
public Idempotency idempotency() {
return Idempotency.IDEMPOTENT; // 可安全重试
}
@Override
public SideEffectType sideEffectType() {
return SideEffectType.EXTERNAL_CALL; // 跨越服务边界
}
@Override
public SchemaDescriptor inputSchema() {
return SchemaIntrospector.introspect(FetchUserInput.class);
}
@Override
public SchemaDescriptor outputSchema() {
return SchemaIntrospector.introspect(FetchUserOutput.class);
}
}
注意四点:
-
@FunctionalInterface—— 核心契约只有一个方法:O execute(I input, OperatorContext ctx)。不需要继承抽象基类,也不需要先学习生命周期钩子。 -
类型化泛型 ——
Operator<FetchUserInput, FetchUserOutput>为编译器和引擎提供了一个具体的类型对。即使你没有实现SchemaAware,框架也会自动内省泛型参数来派生StructuredSchema。 -
行为默认值 ——
idempotency()返回IDEMPOTENT,因此框架可以认为重试是安全的。sideEffectType()返回EXTERNAL_CALL,表明该 operator 跨越了服务边界,应该在 graph 中配合显式的韧性设置。 -
OperatorContext是只读的元数据 —— 它携带nodeId、graphName、retryAttempt、executionId和timeSource。所有 业务 数据都必须通过input传入。
用四刀重构一个坏 Operator
识别好 Operator 最快的方法,是修一个坏的。先看 PlaceOrderOperator:它校验
字段、计算折扣、调用支付、写入订单、发送邮件,还负责翻译所有异常。这个
名字描述的是一段工作流,而不是一项能力。
第 1、2 刀 —— 让纯决策回到普通代码
把校验与计价提取成输入输出显式的函数或领域对象。它们不需要
OperatorContext、retry,也不需要 node。此时可以直接用表格驱动的单元
测试覆盖折扣边界,不必启动 graph。
第 3、4 刀 —— 暴露 effect,再把 adapter 做薄
引入 PaymentPort、OrderRepository 之类的窄接口。最终 Operator 只组装
类型化输入、调用一项业务能力,并返回可检查结果:
| 层 | 负责什么 | 测试接缝 |
|---|---|---|
| 纯领域逻辑 | 校验与计价决策 | 输入 → 输出 |
| effect port | 支付或持久化协议 | fake/受控 port 加调用证据 |
| Operator adapter | graph 输入输出与能力调用 | GraphTestRunner node 结果 |
| graph | 顺序、分支、retry、timeout | Scenario 与状态 断言 |
完成标准不是“类变小了”,而是失败边界变清楚:计价错误由纯测试发现;支付
协议错误由 port contract 发现;依赖错误由 graph Scenario 发现。
SchemaAware、元数据和 context API 用来描述这项能力,不能挽救一个仍然
混合多种责任的能力。
拆开来看
Operator 接口
来自
Operator.java:
@FunctionalInterface
public interface Operator<I, O> {
O execute(I input, OperatorContext ctx) throws Exception;
default Idempotency idempotency() { return Idempotency.UNKNOWN; }
default SideEffectType sideEffectType() { return SideEffectType.MIXED; }
}
| 成员 | 用途 |
|---|---|
execute(I, OperatorContext) | 你必须实现的唯一方法——接收组装好的输入并返回输出 |
idempotency() | 告知引擎重试是否安全(IDEMPOTENT)、是否禁止(NOT_IDEMPOTENT)、还是未指定(UNKNOWN) |
sideEffectType() | 告知框架该 operator 有何种副作用,以便调度和韧性决策保持显式 |
OperatorContext —— 引擎告诉你的信息
public record OperatorContext(
String nodeId,
String graphName,
GraphContext graphContext, // 请求作用域;每个节点的写入相互隔离
int retryAttempt, // 0 = 首次执行
String executionId,
TimeSource timeSource // 永不为 null;默认 SystemTimeSource.INSTANCE
) {}
graphContext 用于请求级元数据(trace ID、tenant ID)。它不是存放业务数据的地方——业务数据必须通过类型化的 I 输入传递。
timeSource —— operator 中的可测试时间
timeSource 字段让每个 operator 都可以使用引擎的时钟,而不需要硬编码
System.currentTimeMillis() 或 Instant.now()。
为什么重要: 如果 operator 直接读取墙钟,测试就无法控制它看到的时间。依赖真实时间的重试延迟断言既慢又脆弱。有了 timeSource,测试夹具注入 ManualTimeSource,operator 就能免费获得确定性时间。
经验法则: 只要 operator 需要当前时间,就调用 ctx.timeSource().now() 而不是 Instant.now()。
@Override
public AuditOutput execute(AuditInput input, OperatorContext ctx) throws Exception {
Instant timestamp = ctx.timeSource().now(); // ✅ 可测试
// Instant timestamp = Instant.now(); // ❌ 不可测试
return auditService.record(input.action(), timestamp);
}
在生产环境中,引擎提供 SystemTimeSource.INSTANCE,它委托给
Instant.now() 和 Thread.sleep()。在测试中你用 ManualTimeSource 替换:
var manualTime = new ManualTimeSource(Instant.parse("2025-01-15T10:00:00Z"));
var ctx = OperatorContext.builder()
.nodeId("audit")
.graphName("compliance")
.graphContext(new GraphContext())
.timeSource(manualTime) // 注入测试时钟
.build();
var result = new AuditOperator(auditService).execute(input, ctx);
// 断言 operator 使用了注入的时间,而不是墙钟
assertEquals(Instant.parse("2025-01-15T10:00:00Z"), result.recordedAt());
这与第 18 章 中 TestGraphEngine 使用的
ManualTimeSource 相同。区别在于:这里你在 operator 层做单元测试,
而第 18 章在 graph 层做集成测试。
SchemaAware —— 显式 schema 声明
来自
SchemaAware.java:
public interface SchemaAware {
default SchemaDescriptor inputSchema() { return OpaqueSchema.INSTANCE; }
default SchemaDescriptor outputSchema() { return OpaqueSchema.INSTANCE; }
}
实现 SchemaAware 是可选的。如果你不实现,框架会回退到对泛型类型参数 I 和 O 的自动内省。但推荐显式声明,因为:
- 当泛型被擦除为
Map<String, Object>时,显式声明仍能保留 schema 信息。 - 你可以给单个字段附加描述和约束注解。
- 它能让下游 input binding 获得编译期路径校验。
在 Java operator 层,DefaultOperatorRegistry 会先检查 SchemaAware;如果你没有实现该接口,它会回退到通过 SchemaIntrospector 对 operator 泛型类型参数的内省。
@OperatorMeta —— 目录元数据
public @interface OperatorMeta {
OperatorLayer layer() default OperatorLayer.DOMAIN;
String[] tags() default {};
String version() default "";
String description() default "";
String owner() default "";
String since() default "";
// LLM 工具元数据 —— 由 bloge-agent-ext(第 17 章)消费
String promptHint() default "";
String usageExample() default "";
String constraintsDescription() default "";
}
每个 operator 都应该 至少声明 layer、description 和 owner。即使运行时行为取决于 operator 契约本身,这些元数据也能让意图和归属对读者和工具链保持可见。
最后三个字段是 LLM 工具描述。当一个 operator 作为工具暴露给 agent
节点(第 17 章)时,bloge-agent-ext 读取它们来构造发给模型的 function
spec:
promptHint—— LLM 在工具列表里看到的一行说明。usageExample—— 给模型锚定行为的具体调用示例。constraintsDescription—— 前置条件、副作用、速率限制。
它们也会进入 operator-metadata.json(第 9 章),Studio 的 operator
面板会展示同样的提示。
常见陷阱
❌ 为纯数据整形创建 operator
// 不要这样做——这个 operator 的工作不需要独立调度,可以由 transform 完成
public class FormatOrderSummaryOperator
implements Operator<Map<String, Object>, Map<String, Object>> {
@Override
public Map<String, Object> execute(Map<String, Object> input,
OperatorContext ctx) {
return Map.of(
"customerName", input.get("name"),
"total", input.get("price")
);
}
}
这个 operator "勉强"可以独立测试,但它没有有意义的指标、没有副作用、没有领域知识、也没有复用价值。它在单一能力原则的五项检查中挂了四项。
改用 transform:
transform orderSummary {
customerName = fetchUser.output.name
total = calcPrice.output.total
}
Transform 不增加 Operator 调度成本——不会触发 operator 调用、没有超时追踪、没有重试开销——但表达式求值本身仍有计算成本,而且整形逻辑直接可见于 graph 本身。
如何在现有 graph 中发现这个陷阱:
- Operator 的
execute方法 里没有if、没有 I/O、没有外部调用。 - Operator 类没有构造函数依赖(没有 service client、没有 repository)。
- 把 operator 替换成
return input;的透传,也不会改变业务结果。
如果以上任何一条成立,删掉这个 operator,改用 transform 或 input binding 表达式。
❌ 把决策表编码为普通算子
有时候,一个 operator 看起来像业务逻辑,但实现其实只是一张“条件映射到输出值”的表格——没有 I/O、没有重试、没有状态。这不是一项可独立度量的能力,而是一张藏在 Java 代码里的策略表。
以下情况请选择 decision_table:
- 逻辑是输入的纯函数(无副作用,无外部调用)。
- 条件是可枚举的——你可以列出所有情况,编译器或 lint 可以在列表不完备时发出警告。
- 审计线索很重要——你希望运行时有结构化错误码,并受
decision-table/missing-otherwiselint 规则保护。
以下情况继续使用 Operator:
- 节点需要执行 I/O(HTTP 调用、数据库查询、消息发布)或存在副作用。
- 你需要框架管理的重试、超时或降级策略。
- 逻辑涉及可变状态或无法用规则谓词表达的协调逻辑。
完整语法参考 和所有错误码,请见附录 F —— 决策表。
引导式重写
下面这个 graph 有两个设计问题——一个 operator 太琐碎,另一个太臃肿:
graph invoiceProcess {
node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}
// 问题 1:琐碎的数据整形——应该用 transform
node formatAddress : FormatAddressOperator {
input {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
}
}
// 问题 2:巨石——把支付和通知捆在一个 operator 里
node processAndNotify : ProcessAndNotifyOperator {
depends_on = [fetchOrder, formatAddress]
input {
order = fetchOrder.output
address = formatAddress.output
}
timeout = 10s
}
}
重写后:
graph invoiceProcess {
node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}
// 修复 1:用 transform 替换琐碎的 operator
transform formattedAddress {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
full = concat(fetchOrder.output.address.street, ", ",
fetchOrder.output.address.city, " ",
fetchOrder.output.address.zip)
}
// 修复 2:把巨石拆成两个独立的 operator
node processPayment : ProcessPaymentOperator {
depends_on = [fetchOrder]
input {
order = fetchOrder.output
address = formattedAddress.full
}
timeout = 5s
retry = { attempts: 2, backoff: 500ms, strategy: exponential }
}
node sendNotification : SendNotificationOperator {
depends_on = [fetchOrder]
input {
email = fetchOrder.output.customerEmail
orderId = fetchOrder.output.id
}
timeout = 3s
fallback = { sent: false, reason: "notification service unavailable" }
}
}
改进之处:
| 之前 | 之后 | 为什么重要 |
|---|---|---|
FormatAddressOperator(完整节点) | transform formattedAddress | 零调度成本;纯数据整形不需要 timeout/retry |
ProcessAndNotifyOperator(一个臃肿节点) | processPayment + sendNotification | 独立的 SLA、独立的重试策略,而且它们可以并行执行 |
脑力检查
-
说出单一能力原则中五项检查中的三项。 (可独立测试、可独立观测、可独立替换、可独立复用、有清晰的领域语义。)
-
一个 operator 的
execute方法通过ctx.graphContext().get("items")获取 业务数据。这有什么问题? (业务数据必须通过类型化输入I传入,而不是通过 graph context。Graph context 用于请求级元数据,如 trace ID。) -
你有一个只做字符串拼接的 operator。它该存在吗? (不该——字符串拼接是轻量 transform。在 input binding 或 transform 中使用
concat(a.output.first, " ", a.output.last)即可。) -
SideEffectType.EXTERNAL_CALL告诉框架什么? (该 operator 跨越了外部服务边界,因此读者和集成方应将其视为有副作用的调用,并在 graph 中配合显式的 timeout/retry 策略。) -
如果一个 operator 没有实现
SchemaAware,框架如何推导它的 schema? (通过SchemaIntrospector对泛型类型参数I和O进行内省。)
练习
-
审计一个现有 graph。 打开
order-process.bloge。 对每个节点应用五项检查。有没有节点应该改成 transform?写下你的推理过程。 -
从零设计一个新 operator。 假设你要实现一个
CreditCheckOperator,它调用外部信用评分 API。- 定义
CreditCheckInput和CreditCheckOutput为 Java record。 - 实现
Operator<CreditCheckInput, CreditCheckOutput>。 - 实现
SchemaAware并提供显式 schema。 - 覆写
idempotency()→IDEMPOTENT(只读查询)。 - 覆写
sideEffectType()→EXTERNAL_CALL。 - 用
@OperatorMeta(layer = CAPABILITY, ...)标注。
- 定义
-
Schema 校验检查。 写好 record 后,在单元测试中使用
SchemaIntrospector.introspect(CreditCheckOutput.class),验证返回的StructuredSchema包含你期望的字段。 -
重构一个不好的 operator。 写一个
FormatCurrencyOperator,接收Number返回String。然后删掉它,用 transform 或绑定表达式替代。哪种方式更简洁?
实验验收卡
- 预期与观察: Operator 拆成纯逻辑、port、adapter 和 graph policy 后,失败接缝独立。
- 失败与恢复: 让 adapter 返回传输错误;只替换 adapter 或 fallback。
- 证明边界: 只证明责任边界可测试,不证明外部系统可靠。
- 练习合同: 坏 service;每次只切一层;交付责任图和失败测试;失败归到唯一 owner 即停止。
回顾
- 一个 operator 应该封装一项可独立度量的业务能力——单一能力原则。
- 数据转换有四个层次:路由(input binding)、轻量 transform(表达式)、结构适配(transform 块)和业务逻辑(operator 节点)。使用成本最低的那个。
- 三层复用金字塔(基础设施 → 能力 → 领域)按作用范围对 operator 进行分类;用
@OperatorMeta(layer = …)标注它们。 - 类型化 I/O record +
SchemaAware带来编译期校验、schema 感知的工具链和更安全的 schema 演进。 - 行为契约 ——
idempotency()和sideEffectType()—— 为框架和你的团队在重试、调度和韧性决策上提供更明确的指引。 OperatorContext用于元数据,而非业务数据。 所有领域值通过类型化输入传递。使用ctx.timeSource().now()替代Instant.now()以获得可测试的时间。- 最常见的错误是为纯数据整形创建 operator。如果没有 I/O、没有副作用、也没有领域规则——用 transform。
下一步
在第 8 章——把个人 DSL 草稿变成团队资产中,你将把这些想法带回 .bloge 编写层——将 graph 级 schema、文档注释、transform、分支和 operator 契约组合成既易读又易演进的文件。