Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 20 章 —— Spring 与生产环境接线

承诺: 读完本章后,你能够通过 Spring Boot 启动一张 BLOGE graph, 不手写 engine bean;能够读懂 health 结果;也能只用一次 GraphEngineCustomizer 覆盖完成必要调整,而不接管整棵自动配置图。


学习目标

  1. 添加 bloge-spring 依赖,让 BlogeAutoConfiguration 自动接线 GraphEngineOperatorRegistryGraphLoader 和编译后的 List<Graph>,无需手动定义任何 bean。
  2. 使用 @BlogeOperator 标注实现 OperatorSuspendableOperatorStreamingOperator 的运行时对象,让 Spring 发现并注册它们。
  3. GraphEngine 和编译后的 graph 列表注入到一个 service 类中,执行 graph 并通过 HTTP 端点返回结果。
  4. UP + graphCount=1 读成接线观察,而不是业务证明。
  5. 使用 GraphEngineCustomizer 调整轻量引擎,并判断何时替换整个 bean 会制造不必要的责任。

前置条件

  • 第 7 章 —— 设计良好的 Operator —— 在将 operator 接入 Spring 之前,你需要掌握 operator 的设计模型。
  • 第 13 章 —— 持久执行 —— 运行时存储、检查点和挂起/恢复,因为 starter 会有条件地接线所有这些组件。
  • 熟悉 Spring Boot 自动配置、@ConfigurationProperties 和 Spring Boot Actuator 基础。

源示例

文件展示内容
BlogeAutoConfiguration.java导入 core slice 的入口自动配置
BlogeProperties.java所有 spring.bloge.* 配置属性及嵌套分组
BlogeOperator.java@Component 组合而成的 operator 扫描注解
BlogeObservabilityAutoConfiguration.java指标、链路追踪、MDC、健康指示器接线
BlogeEndpointAutoConfiguration.java/actuator/bloge/actuator/blogeStats 端点
BlogeAuditAutoConfiguration.java审计日志监听器激活
SessionExecutorAutoConfiguration.javaSessionExecutor 接线,包含访问守卫和监听器
spring-ticket-triage.blogestarter 示例加载的最小 DSL
SpringBootTicketTriageApplication.java端到端的 Spring Boot 应用,使用 starter
SpringTicketTriageService.java注入 GraphEngineList<Graph> 的 Service 层

为什么这很重要

在前面的章节中,你通过手动方式构建引擎:

var engine = GraphEngine.builder()
.registry(registry)
.executionCheckpointStore(checkpointStore)
.listeners(listeners)
.build();

这在测试和学习时没问题。但在生产环境中,需要接线的列表会迅速膨胀:拦截器、监听器、上下文载体、持久化存储、检查点编解码器、恢复配置、分片解析器、定时器服务、事件去重 —— 而且每个 bean 都有自己的条件和回退逻辑。

bloge-spring 模块处理这部分组装工作。它是一个 Spring Boot Starter,会自动配置 classpath 与属性条件已经满足的组件。把 operator 声明为 Spring bean,并将 .bloge 文件放到 classpath 后,starter 可以为这个应用切片组装一套可运行引擎。加入 bloge-durablebloge-metrics-otel 后,starter 会检测并接线其中满足条件的 bean,不需要重复声明 @Bean。这只证明应用接线已经建立,不证明持久化 store、外部 effect 或生产容量已经就绪。

核心洞察: starter 并没有隐藏引擎 —— 它只是组装引擎。多数可替换的 singleton 默认值使用 @ConditionalOnMissingBean;扩展集合和 endpoint slice 有自己的条件,因此覆盖前仍要确认目标 bean 的具体合同。


最短接线路径

不要从所有可选生产能力开始。先打通一个纵向切片:一个依赖、一个带注解的 operator、一份 classpath DSL、一个注入的 executor,以及一次 health 观察。

图:最短 Spring 接线路径

你负责Starter 负责可观察检查
实现运行时 operator 合同的 @BlogeOperator beanOperatorRegistryDSL 加载时能解析 operator 名
classpath:/bloge/*.blogeGraphLoader 与编译后的 List<Graph>目标 graph 存在
service 输入与输出轻量 GraphEngine / GraphExecutor一次执行返回预期节点事实
部署依赖Actuator 存在时的 BlogeHealthIndicatorUPgraphCount=1

这里的 UP 只表示应用至少加载了一张 graph。它不表示订单 Scenario 通过、 外部支付系统健康,也不表示该版本具备发布资格。


心智模型

bloge-spring 想象成一条 分层装配线。每一层只有在其 classpath 和属性前置条件都满足时才会激活:

Diagram: 20-spring-and-production-wiring figure 1

第 2–6 层是叠加且独立的。你不需要一次全部启用,移除某个 classpath 依赖就能干净地移除对应层。


第一个可运行示例

这是来自 bloge-examples/src/main/java/com/leanowtech/bloge/examples/integration/spring/ 的工单分流示例。

步骤 1 — 添加依赖

<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-spring</artifactId>
<version>${bloge.version}</version>
</dependency>

步骤 2 — 把 operator 编写为 Spring bean

下面的示例对 SpringTicketClassifierOperator.java 做了简化:

@BlogeOperator(
value = "SpringTicketClassifierOperator",
description = "Classifies a support ticket",
owner = "examples",
tags = {"spring", "starter", "triage"}
)
public class SpringTicketClassifierOperator implements
Operator<Map<String, Object>, ClassifiedTicket> {

public record ClassifiedTicket(String queue, int priorityScore, boolean vip) {}

@Override
public ClassifiedTicket execute(Map<String, Object> input, OperatorContext ctx) {
boolean vip = "vip".equalsIgnoreCase(String.valueOf(input.get("customerTier")));
String queue = vip ? "vip-escalation" : "general-support";
int priorityScore = vip ? 70 : 30;
return new ClassifiedTicket(queue, priorityScore, vip);
}
}

@BlogeOperator@Component 组合而成,因此 Spring 会发现这个 bean; registry 只会接收其中实现 OperatorSuspendableOperatorStreamingOperator 的对象。value 设置 DSL 中的 operator 名。仓库里的完整 示例刻意把 domain bean 保持为普通对象,再通过显式 OperatorRegistry 适配; 上面的精简版选择的是零手写 registry 路径。

步骤 3 — 把 DSL 放到 classpath 上

来自 spring-ticket-triage.bloge

graph springTicketTriage {
node classifyTicket : SpringTicketClassifierOperator {
input {
ticketId = ctx.ticketId
message = ctx.message
customerTier = ctx.customerTier
}
timeout = 2s
}

node draftReply : SpringReplyDraftOperator {
depends_on = [classifyTicket]
input {
ticketId = ctx.ticketId
queue = classifyTicket.output.queue
priorityScore = classifyTicket.output.priorityScore
vip = classifyTicket.output.vip
}
timeout = 2s
}
}

starter 的默认 DSL 位置是 classpath:bloge/。将此文件放在 src/main/resources/bloge/spring-ticket-triage.bloge,它就会被自动加载。

步骤 4 — 注入并执行

来自 SpringTicketTriageService.java

@Service
public class SpringTicketTriageService {

private final GraphEngine engine;
private final Map<String, Graph> graphsByName;

public SpringTicketTriageService(GraphEngine engine, List<Graph> graphs) {
this.engine = engine;
this.graphsByName = graphs.stream()
.collect(Collectors.toUnmodifiableMap(Graph::name, Function.identity()));
}

public TicketTriageResponse triage(String ticketId, String message, String customerTier) {
Graph graph = graphsByName.get("springTicketTriage");
var result = engine.execute(graph, new GraphContext(Map.of(
"ticketId", ticketId,
"message", message,
"customerTier", customerTier
)));
return /* 将 result 映射为 response */;
}
}

没有 GraphEngine.builder()。没有 DefaultOperatorRegistry。没有 GraphLoader 调用。starter 已经把这一切全部组装好了。

步骤 5 — 通过 HTTP 暴露

来自 SpringTicketTriageController.java

@RestController
@RequestMapping("/api/bloge/tickets")
public class SpringTicketTriageController {

private final SpringTicketTriageService triageService;

public SpringTicketTriageController(SpringTicketTriageService triageService) {
this.triageService = triageService;
}

@GetMapping("/triage")
public TicketTriageResponse triage(
@RequestParam("ticketId") String ticketId,
@RequestParam("message") String message,
@RequestParam(name = "customerTier", defaultValue = "standard") String customerTier) {
return triageService.triage(ticketId, message, customerTier);
}
}

一次 override,只增加一个明确 owner

假设默认轻量引擎都合适,只有 scheduler 集成需要调整。保留 starter 管理的 其它 bean,只使用 builder 接缝:

@Bean
GraphEngineCustomizer schedulerOverride(MyTimerSupport timerSupport) {
return builder -> builder.schedulerTimerSupport(timerSupport);
}

图:不替换 engine 的单点 Spring override

Customizer 在轻量 engine-mode preset 之后、核心 GraphEngine 构建之前运行; 它配置 durable runtime 路径。如果替换整个 GraphEngine bean,你也会 同时接管 listener、interceptor、context carrier 和未来 starter 默认值。

本章余下部分作为生产参考。首次阅读只需完成最短切片、health 边界和这一次 override,就已经形成完整闭环。


生产参考

上面的最短路径已经足以理解所有权。需要查询完整 bean 清单、属性分组、持久化 store 接线、健康检查或 HTTP 错误契约时,请转到附录 I —— Spring 生产参考

常见陷阱

❌ 在 @BlogeOperator 已存在时仍手动注册 operator

// 如果你同时也用 @BlogeOperator 标注了这个类,就不要这么做:
@Bean
public OperatorRegistry operatorRegistry() {
var registry = new DefaultOperatorRegistry();
registry.register("FetchUserOperator", new FetchUserOperator(userService));
return registry;
}

因为 BlogeAutoConfiguration 提供了一个会扫描 @BlogeOperator 标注 bean 的 OperatorRegistry bean,而且 它的 bean 带有 @ConditionalOnMissingBean,声明你自己的 OperatorRegistry bean 会完全替换掉自动配置的那个。你的自定义 registry 不会扫描 @BlogeOperator bean —— 因此所有标注的 operator 会悄然消失。

修复: 要么完全依赖 @BlogeOperator 扫描(标准路径),要么提供一个包含所有需要内容的完整 OperatorRegistry bean。不要混用两种方式。


常见故障

Operator 悄然消失

如果一个类已经标注 @BlogeOperator("MyOperator"),同时又声明了空的自定义 OperatorRegistry bean,@ConditionalOnMissingBean 会让自动扫描 registry 退出。DSL 随后会以 unregistered operator 'MyOperator' 停止加载。

修复边界只有两个:删除自定义 registry,完全交给自动扫描;或者让自定义 registry 显式注册应用需要的全部 operator。不能把两种 owner 混在一起。


每次只增加一个生产扩展

只有明确的运维需求出现时,才增加可观测性、审计、恢复或 interceptor。附录 I 提供配置地图与部署检查;本章只保留一条设计规则:每个 override 都必须只有一个理由和一个 owner。

脑力检查

  1. 自动注册一个 operator 需要同时满足什么? (答案:bean 带有 @BlogeOperator,并实现 OperatorSuspendableOperatorStreamingOperator;单有注解只保证 Spring 发现。)

  2. starter 默认在哪里查找 .bloge 文件?哪个属性可以覆盖它? (答案:classpath:bloge/,可通过 spring.bloge.dsl-locations 配置。)

  3. 如果你声明了自己的 OperatorRegistry bean,自动配置的那个会怎样? (答案:它会被跳过 —— @ConditionalOnMissingBean 意味着你的 bean 优先,而自动 @BlogeOperator 扫描将不再执行。)

  4. 列举两个仅通过添加 classpath 依赖就能激活、无需修改任何属性的生产特性。 (答案:当 spring-boot-actuator 存在时,Actuator 端点自动激活;当 bloge-metrics-otel 存在时,可观测性指标和链路追踪自动激活。)

  5. 当没有找到任何 .bloge 文件时,BlogeHealthIndicator 报告什么? (答案:DOWN,附带详情 "reason": "No graphs loaded"。)


实验

从一个全新的 Spring Boot 应用开始:

  1. 添加 bloge-spring 到你的 POM,创建一个用 @BlogeOperator 标注、并实现 Operator 的对象,返回一个问候字符串。

  2. 编写一个单节点 .bloge graph,放在 src/main/resources/bloge/ 下,引用你的 operator。

  3. 创建一个 @Service,注入 GraphEngineList<Graph>,按名称查找你的 graph 并执行它。

  4. 验证健康指示器:启动应用并访问 /actuator/health。确认 bloge 组件显示 UP 以及你的 graph 名称。

  5. 故意破坏它:重命名 .bloge 文件使其无法加载任何 graph。重启并验证健康指示器报告 DOWN

  6. 添加一个自定义 ExecutionListener bean,记录 graph 级别的完成耗时。再次执行你的 graph,确认该监听器被触发,且无需对引擎 bean 做任何修改。


实验验收卡

  • 预期与观察: starter 装配 engine、loader 和 health,一个 customizer 只有一个 owner。
  • 失败与恢复: 移除 bean 或重复 override;恢复最短自动配置。
  • 证明边界: 证明 Spring 接线和 health,不证明生产容量或业务正确性。
  • 练习合同: 最小应用;只改一个 bean/customizer;交付 context 和 health;启动成功且 owner 唯一即停止。

回顾

  • bloge-spring 是 Spring Boot Starter;只有 classpath、property 与 bean 条件满足时,它才会自动配置 GraphEngineOperatorRegistryGraphLoader 和编译后的 graph。
  • @BlogeOperator 是由 @Component 组合而成的注解。标注的 bean 只有实现 BLOGE 运行时 operator 合同后才会自动注册;普通 domain bean 需要 adapter/customizer 路径。其 promptHintusageExampleconstraintsDescription 属性支持 agent/LLM 集成。
  • starter 使用 分层的条件化自动配置:核心 bean 只在 starter 条件生效的 context 中存在;持久化存储在 bloge-durable + DataSource 存在时激活;可观测性在 bloge-metrics-otel 存在时激活;审计和恢复在各自的属性设置为 true 时激活。
  • 自动配置被拆分为聚焦的切片(BlogeCoreAutoConfigurationBlogeGraphEngineAutoConfigurationBlogeDurableAutoConfiguration 等),实现了更清晰的关注点分离。
  • 新增 spring.bloge.engine-mode 属性用于选择引擎预设(AUTOREQUEST_RESPONSE)。
  • 新增 spring.bloge.tenant.* 属性启用自动配置的多租户支持 —— 租户解析、命名空间解析、并发上限和速率限制 —— 无需编写自定义 TenantContextResolver bean。
  • 新增 spring.bloge.version-routing.* 属性在 starter 层面支持 latestpinnedcanary graph 版本路由。
  • GraphEngineCustomizer 让你无需替换整个 bean 即可微调引擎 builder。
  • 多数可替换的 singleton 默认值带有 @ConditionalOnMissingBean;覆盖前应检查具体 slice。
  • 自定义的 OperatorInterceptorExecutionListenerCallerContextCarrier bean 通过 ObjectProvider 自动收集并注入引擎。
  • Actuator 端点(/actuator/bloge/actuator/blogeStats)提供已加载 graph 和执行指标的运行时可见性。
  • BlogeHealthIndicator 开箱即用地提供 /actuator/health 集成。

下一步

第 21 章——单 JVM 里的调度与复杂度中,你会先隔离本地调度机制:ready set、完成事件与 graph 复杂度护栏。分布式所有权和容量留到第 22 章。


参考链接

Coding Agent: Open the versioned task guide.