SDK API 参考
Bundle 是 Buildify 的节点软件包(fat JAR),由独立 BundleClassLoader 加载,按版本发布。本文覆盖项目结构、FlowNode API、凭证、表单与发布检查清单,与当前 buildify-bundle-api 实现一致。
推荐:让 Agent 按规范开发
在支持 Skill 的 Agent 中启用 buildify-bundle-dev skill,Agent 会按本文规范生成节点、表单、SPI 与校验清单,打包并发布版本。安装与完整流程见 Agent 开发 Bundle。只编排内置节点时,从快速开始入门即可。表单控件细节见 节点表单配置。
核心概念
| 概念 | 说明 |
|---|---|
| Bundle | 可独立部署的节点包(JAR),包含一组 FlowNode、MethodExecutor、CredentialsProvider |
| FlowNode | 工作流中的处理节点,接收消息并向下游传递 |
| MethodExecutor | 节点暴露给前端的动态方法,用于加载下拉列表等联动操作 |
| CredentialsProvider | 访问凭证提供者,管理 API Key、Token 等敏感信息 |
| Context | 节点运行时上下文,提供消息传递、凭证获取、调度等所有能力 |
| Message | 在节点间流转的数据载体,包含 payload 和 headers |
Bundle 项目结构
my-bundle/
├── pom.xml
└── src/
└── main/
├── java/
│ └── com/example/bundle/
│ ├── nodes/
│ │ ├── MyNode.java
│ │ └── MyTriggerNode.java
│ ├── methods/
│ │ └── GetListMethodExecutor.java
│ └── credentials/
│ └── ApiKeyCredentialsProvider.java
└── resources/
├── bundle.json
├── properties/
│ ├── MyNode.json
│ └── MyTriggerNode.json
├── credentials/
│ └── apiKey.json
└── META-INF/services/
├── cn.buildify.bundle.api.FlowNode
└── cn.buildify.bundle.api.credentials.CredentialsProviderpom.xml <properties> 版本声明(Java 必须为 21):
xml
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<buildify.version>1.0-SNAPSHOT</buildify.version>
</properties>WARNING
必须使用 maven.compiler.release 而非 source/target:release 会自动设置系统模块位置,确保生成的字节码可在 JDK 21 上正确运行;使用 source/target 会触发编译警告且可能引发运行时兼容问题。
Maven 依赖(scope 必须为 provided):
xml
<!-- Buildify Bundle API,运行时由框架提供 -->
<dependency>
<groupId>cn.buildify</groupId>
<artifactId>buildify-bundle-api</artifactId>
<version>${buildify.version}</version>
<scope>provided</scope>
</dependency>
<!-- SLF4J,需打入 JAR(compile scope) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
<!-- Lombok,编译期注解处理器 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>构建插件(maven-compiler-plugin + maven-shade-plugin):
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>${java.version}</release>
<encoding>UTF-8</encoding>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>FlowNode 节点开发
接口定义
java
public interface FlowNode {
// 节点启动时调用一次:读参数、初始化连接(可选)
default void initialize(Context context, JsonValue parameters) {}
// 每条消息到达时调用(必须实现)
void onMsg(Context context, Message<?> msg);
// 节点停止/重启时调用:释放资源(可选,必须幂等)
default void destroy(Context context) {}
// 参数变更是否需要重启,默认任何变化都重启
default boolean isRestartRequired(JsonValue newParameters, JsonValue oldParameters) {
return !Objects.equals(newParameters, oldParameters);
}
// isRestartRequired()=false 时调用,就地刷新轻量配置
default void onParametersUpdated(Context context, JsonValue newParameters, JsonValue oldParameters) {}
// 凭证变更;返回 true 表示已就地生效,false 时框架回退为重启节点
default boolean onCredentialsUpdated(Context context, String type, String name) { return false; }
// 输出关系,默认 [Success, Failure]
default List<Relation> getRelations() { return Relation.DEFAULT; }
// 消息级过滤:返回 false 该消息被跳过,不触发 onMsg
default boolean support(Message<?> message) { return true; }
// 辅助方法:获取 payload 并转型
default <T> T getPayload(Message<?> message, Class<T> type) { ... }
}TIP
support() 返回 false 的消息不会进入 onMsg(),也就没有配对的 complete(),该方法内不要做副作用。
节点生命周期
[加载] → initialize() → [RUNNING]
↓
onMsg() × N
↓
参数/凭证变更 → isRestartRequired() = true
↓
destroy() → initialize()
↓
destroy() → [STOPPED]规范 1:读取参数用 path() 不用 get()
path() 在字段不存在时返回 MissingNode(安全),get() 返回 null(NPE 风险)。
java
// ✅ 正确
String url = parameters.path("url").asText(null);
int timeout = parameters.path("timeout").asInt(30);
boolean flag = parameters.path("enabled").asBoolean(false);
// ❌ 错误
String url = parameters.get("url").asText();规范 2:路由后必须 context.complete(入站 message)
在 tellSuccess / tellFailure / tellNext 完成路由后,同步和异步路径都必须对 onMsg 的入参 message 调用 complete(),否则框架无法释放消息与异步句柄。
java
// ✅ 同步路径
context.tellSuccess(outMsg);
context.complete(message);
// ✅ executeBlocking:用 onComplete 统一收尾
context.<String>executeBlocking(() -> doWork())
.onSuccess(result -> context.tellSuccess(buildMsg(result, message)))
.onFailure(e -> context.tellFailure(message, e))
.onComplete(() -> context.complete(message));
// ✅ 原生异步:finally 保证必然 complete
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.whenComplete((response, error) -> {
try {
if (error != null) context.tellFailure(message, error);
else context.tellSuccess(buildMsg(response, message));
} finally {
context.complete(message);
}
});规范 3:dynamicParameters + resolveExpressions 模式
支持表达式(如 ={{msg.body.url}})的参数,在 initialize() 中存入 dynamicParameters,在 onMsg() 中按当前消息动态解析。
java
private JsonValue dynamicParameters;
@Override
public void initialize(Context context, JsonValue parameters) {
this.timeout = parameters.path("timeout").asInt(30); // 固定参数直接读取
// 含表达式的参数存入 dynamicParameters
this.dynamicParameters = JsonValueFactory.objectNode()
.put("url", parameters.path("url"))
.put("method", parameters.path("method"));
}
@Override
public void onMsg(Context context, Message<?> msg) {
JsonValue payload = getPayload(msg, JsonValue.class);
JsonValue parameters = context.resolveExpressions(dynamicParameters, payload);
String url = parameters.path("url").asText();
String method = parameters.path("method").asText("GET");
}规范 4:普通节点输出放 output,触发器放根级
普通节点把业务结果写入 payload 的 output,下游用 {{ msg.output.xxx }} 引用。触发器是流程入口,不要再包一层 output,业务字段直接放根级。
触发器节点额外规范
initialize()中用context.isTest()区分:测试模式立即触发一次;生产模式注册定时任务或监听器。Webhook 等被动触发器两种模式都要注册路由。- 每条消息必须用
context.createTriggerMessage(payload)创建;透传外部追踪 ID 用createTriggerMessage(payload, externalTraceId)。 destroy()中取消定时任务、注销 HTTP 路由。注解必须同时设isTrigger=true, hasInput=false。
java
@Override
public void initialize(Context context, JsonValue parameters) {
if (context.isTest()) {
triggerOnce(context); // 测试模式:立即触发一次
} else {
this.scheduledFuture = context.schedule(() -> triggerOnce(context), cronExpr, zoneId);
}
}
private void triggerOnce(Context context) {
JsonValue payload = JsonValueFactory.objectNode()
.put("cron", cronExpr)
.put("timestamp", System.currentTimeMillis());
context.tellSuccess(context.createTriggerMessage(payload));
}
@Override
public void destroy(Context context) {
if (scheduledFuture != null) {
scheduledFuture.cancel(true);
}
}条件分支节点(自定义 Relation)
java
@Slf4j
@FlowNodeDescription(name = "IfNode", propertiesFile = "properties/IfNode.json")
public class IfNode implements FlowNode {
public static final List<Relation> RELATIONSHIPS = List.of(
Relation.builder().name("True").label("是").description("条件成立").build(),
Relation.builder().name("False").label("否").description("条件不成立").build(),
Relation.RELATIONSHIP_FAILURE
);
private String expression;
@Override
public void initialize(Context context, JsonValue parameters) {
this.expression = parameters.path("expression").asText(null);
if (this.expression == null || this.expression.isBlank()) {
throw new FlowNodeException("INVALID_PARAM", "expression cannot be null");
}
}
@Override
public List<Relation> getRelations() { return RELATIONSHIPS; }
@Override
public void onMsg(Context context, Message<?> message) {
JsonValue payload = getPayload(message, JsonValue.class);
context.executeBlocking(() -> {
JsonValue result = context.evaluateExpression(this.expression, payload);
if (!result.isBoolean()) {
throw new RuntimeException("expression must return boolean, got: " + result);
}
return result.asBoolean() ? "True" : "False";
}).onSuccess(label -> {
context.tellNext(message, label);
context.complete(message);
}).onFailure(e -> {
log.error("[IfNode] expression failed", e);
context.tellFailure(message, e);
context.complete(message);
});
}
}@FlowNodeDescription 注解
每个 FlowNode 实现类必须标注此注解。
java
@FlowNodeDescription(
name = "MyNode",
propertiesFile = "properties/MyNode.json",
isTrigger = false,
hasInput = true,
hasOutput = true,
isLayoutNode = false,
isShowConfigOnAdd = true,
documentUrl = "https://docs.example.com/node"
)
public class MyNode implements FlowNode { ... }| 属性 | 默认值 | 说明 |
|---|---|---|
name | — | 必填,与 bundle.json groups[].nodes[].name 完全一致 |
propertiesFile | "" | 惯例路径:properties/NodeName.json |
isTrigger | false | 触发器通常搭配 hasInput=false |
isLayoutNode | false | 分支容器、循环容器等布局节点设为 true |
Context 上下文 API
消息传递
java
context.tellSuccess(newMsg);
context.tellNext(message, "True");
context.tellNext(message, Set.of("A", "B"));
context.tellFailure(originalMsg, new RuntimeException("失败原因"));
context.complete(message); // 同步与异步路径都必须调用凭证获取
getCredentials 的入参是节点表单中 CredentialSelect 字段的 name(camelCase),不是凭证类型标识。typeOptions.credentialsType 决定「选哪一类凭证」,字段 name 决定从哪个框读取用户已选中的实例。
java
// 表单:{ "name": "credentialsId", "uiComponent": "CredentialSelect",
// "typeOptions": { "credentialsType": "ApiKeyCredential" } }
Credentials credentials = context.getCredentials("credentialsId"); // ✅ 字段 name
Map<String, Object> data = credentials.getData(); // key 为凭证表单字段 name
String apiKey = (String) data.get("apiKey");触发器消息
java
Message<JsonValue> msg = context.createTriggerMessage(payload);
Message<JsonValue> msg = context.createTriggerMessage(payload, externalTraceId);节点和工作流信息
java
String nodeId = context.getWorkflowNodeId();
String workflowId = context.getWorkflowId();
String tenantId = context.getTenantId();
boolean isTest = context.isTest();表达式解析
java
JsonValue resolved = context.resolveExpressions(dynamicParameters, payload);
JsonValue result = context.evaluateExpression("={{msg.body.name}}", payload);
Map<String, Object> env = context.getEnvironmentVariables();节点状态显示
java
context.setStatus(NodeStatus.builder().color("green").text("已连接").build());
context.setStatus(NodeStatus.builder().color("red").text("连接失败").build());异步执行
java
// 带泛型写法
context.<String>executeBlocking(() -> syncCall())
.onSuccess(result -> {
context.tellSuccess(...);
context.complete(message);
})
.onFailure(e -> {
context.tellFailure(message, e);
context.complete(message);
});
// 带超时
context.<String>executeBlocking(() -> slowOp(), Duration.ofSeconds(5))
.onSuccess(...)
.onFailure(...);定时调度
java
ScheduledFuture<?> future = context.schedule(
() -> triggerLogic(),
"0 */5 * * * ?",
"Asia/Shanghai"
);
// destroy() 中取消
future.cancel(true);沙箱脚本
java
SandboxExecutor executor = context.createSandboxExecutor("js",
"(function myFn(msg, headers, env){ " + userCode + " })");
executor.setTimeout(Duration.ofSeconds(5));
JsonValue result = executor.execute(
payload, message.getHeaders(), context.getEnvironmentVariables());
// destroy() 中关闭
executor.close();HTTP 路由(Webhook)
java
Router router = context.getRouter();
Route route = Route.create(Set.of(HttpMethod.POST), "/webhook/mypath")
.timeout(5000)
.handler(rc -> handleRequest(context, rc));
router.register(route); // initialize() 中注册
router.unregister(route); // destroy() 中注销消息与 MessageBuilder
创建消息
java
// 触发器:必须用 createTriggerMessage
Message<JsonValue> msg = context.createTriggerMessage(payload);
// 普通节点:基于原消息复制 headers(推荐,保留 CORRELATION_ID)
Message<JsonValue> msg = MessageBuilder.withPayload(newPayload)
.copyHeaders(originalMsg.getHeaders())
.build();
// 追加自定义 header
Message<JsonValue> msg = MessageBuilder.withPayload(payload)
.copyHeaders(originalMsg.getHeaders())
.setHeader("myKey", "myValue")
.build();读取消息
java
JsonValue payload = getPayload(message, JsonValue.class);
String corrId = (String) message.getHeaders().get(MessageHeaders.CORRELATION_ID);内置 MessageHeaders 常量
| 常量 | 说明 |
|---|---|
MessageHeaders.ID | 消息唯一 UUID |
MessageHeaders.TIMESTAMP | 消息创建时间戳(ms) |
MessageHeaders.CORRELATION_ID | 批次关联 ID |
MessageHeaders.WEBHOOK_TRIGGER_ID_NAME | Webhook 请求 ID 的 header key |
JsonValue 数据操作
创建
java
JsonValue obj = JsonValueFactory.objectNode();
JsonValue arr = JsonValueFactory.arrayNode();
JsonValue val = JsonValueFactory.fromJson("{\"name\":\"Alice\"}");
JsonValue val = JsonValueFactory.fromObject(myPojo);读取(始终用 path() 不用 get())
java
String name = obj.path("name").asText("default");
int age = obj.path("age").asInt(0);
boolean flag = obj.path("enabled").asBoolean(false);
long ts = obj.path("ts").asLong(0L);
// 链式嵌套访问
String city = obj.path("address").path("city").asText();
// 检查
boolean missing = obj.path("x").isMissingNode();
boolean isNull = obj.path("x").isNull();
boolean hasVal = obj.hasNonNull("x");写入
java
obj.put("name", "Alice")
.put("age", 30)
.put("nested", JsonValueFactory.objectNode().put("k", "v"));
arr.add("item1");
arr.add(JsonValueFactory.objectNode().put("id", 1));
for (JsonValue item : arr.asArray()) { ... }其他
java
JsonValue copy = original.deepCopy();
String json = obj.toJson();
Map<String, Object> map = obj.toMap();Relation 输出关系
java
// 使用内置关系
@Override
public List<Relation> getRelations() {
return List.of(Relation.RELATIONSHIP_SUCCESS, Relation.RELATIONSHIP_FAILURE);
}
// 自定义关系(定义为 static 常量)
public static final List<Relation> RELATIONSHIPS = List.of(
Relation.builder().name("True").label("是").description("条件成立").build(),
Relation.builder().name("False").label("否").description("条件不成立").build(),
Relation.RELATIONSHIP_FAILURE
);
// 消息路由
context.tellNext(message, "True");
context.tellNext(message, "False");MethodExecutor 节点方法
java
public interface MethodExecutor {
CompletableFuture<Object> execute(ExecutorContext context);
String getMethodName();
}ExecutorContext 常用方法:
| 方法 | 说明 |
|---|---|
context.getCredentials() | 返回凭证 Map,key 为凭证名 |
context.getParams() | 返回表单 dependsOn 字段的当前值 |
java
import cn.buildify.bundle.api.ExecutorContext;
import cn.buildify.bundle.api.MethodExecutor;
public class GetDatabasesExecutor implements MethodExecutor {
@Override
public String getMethodName() { return "getDatabases"; }
@Override
public CompletableFuture<Object> execute(ExecutorContext context) {
return CompletableFuture.supplyAsync(() -> {
Map<String, Object> cred = context.getCredentials().get("dbCred");
String host = (String) cred.get("host");
Map<String, Object> params = context.getParams();
String database = (String) params.get("database");
return fetchDatabases(host, database);
});
}
}SPI 文件:META-INF/services/cn.buildify.bundle.api.MethodExecutor
访问凭证
凭证类型标识使用 PascalCase,以下三处必须逐字一致:
| 位置 | 字段 |
|---|---|
@CredentialsDescription(type) | 如 ApiKeyCredential |
表单 typeOptions.credentialsType | 同上 |
credentials/<type>.json 文件名 | credentials/ApiKeyCredential.json |
credentialsName 已废弃,禁止使用。context.getCredentials(...) 传的是表单字段 name,不是类型标识。
@CredentialsDescription 注解
java
@CredentialsDescription(
type = "ApiKeyCredential",
label = "API Key",
propertiesFile = "credentials/ApiKeyCredential.json"
)
public class ApiKeyCredentialsProvider implements CredentialsProvider {
// 无动态方法时,空实现即可
}含动态方法的凭证
java
@CredentialsDescription(
type = "MysqlCredential",
label = "MySQL",
propertiesFile = "credentials/MysqlCredential.json")
public class MysqlCredentialsProvider implements CredentialsProvider {
@Override
public CompletableFuture<Object> execute(CredentialsMethodRequest request) {
return CompletableFuture.supplyAsync(() -> {
if (request.isTestConnection()) {
return Map.of("success", true, "message", "连接成功");
}
if (request.is("getDatabases")) {
return List.of(Map.of("label", "mydb", "value", "mydb"));
}
throw new UnsupportedOperationException(request.getMethod());
});
}
}凭证表单(credentials/ApiKeyCredential.json)
json
{
"properties": [
{
"name": "apiKey",
"label": "API Key",
"uiComponent": "Password",
"required": true,
"placeholder": "请输入 API Key"
}
]
}节点中读取凭证
表单用 CredentialSelect,typeOptions.credentialsType 指向凭证类型;代码用字段 name 取值:
json
{
"name": "credentialsId",
"label": "API 凭证",
"uiComponent": "CredentialSelect",
"required": true,
"typeOptions": {
"credentialsType": "ApiKeyCredential"
}
}java
@Override
public void onMsg(Context context, Message<?> message) {
Credentials cred = context.getCredentials("credentialsId");
String apiKey = (String) cred.getData().get("apiKey");
}同一节点有多个凭证框时,各自有独立 name,分别调用 getCredentials。
WARNING
不要把凭证字段写入日志或 payload。getCredentials("ApiKeyCredential") 是错误用法——那是类型标识,不是表单字段名。
BundleActivator 生命周期
java
public class MyBundleActivator implements BundleActivator {
@Override
public void start(BundleContext context) throws Exception {
// 初始化共享资源
}
@Override
public void stop(BundleContext context) throws Exception {
// 释放共享资源
}
}SPI 文件:META-INF/services/cn.buildify.bundle.api.BundleActivator
bundle.json 描述文件
必须放于 resources 根目录。
json
{
"bundleName": "my-company/my-bundle",
"groups": [
{
"label": "HTTP",
"nodes": [
{
"icon": "http.svg",
"name": "HttpRequestNode",
"label": "HTTP 请求",
"summary": "发起 HTTP 请求",
"parameters": {
"method": "GET",
"timeout": 30,
"authType": "none"
}
},
{
"icon": "webhook.svg",
"name": "WebhookTriggerNode",
"label": "Webhook 触发器",
"summary": "接收 HTTP 请求触发工作流",
"parameters": {
"timeout": 5000,
"responseMode": "Immediate"
}
}
]
},
{
"label": "调度",
"nodes": [
{
"icon": "scheduler.svg",
"name": "SchedulerNode",
"label": "定时触发器",
"summary": "按 Cron 表达式定时触发",
"parameters": {
"zoneId": "Asia/Shanghai",
"triggerType": "intervalMinutes",
"intervalMinutesValue": 1
}
}
]
}
]
}| 字段 | 必填 | 说明 |
|---|---|---|
bundleName | 是 | Bundle 唯一名称,惯例 组织/名称 |
groups[].label | 是 | 分组 UI 名称 |
groups[].nodes[].name | 是 | 必须与 @FlowNodeDescription(name) 完全一致 |
groups[].nodes[].label | 是 | 节点 UI 显示名称 |
groups[].nodes[].summary | 否 | 节点功能简述,建议小于 10 字 |
groups[].nodes[].icon | 否 | 图标文件名 |
groups[].nodes[].parameters | 否 | 节点默认参数值 |
WARNING
bundle.json 不需要 credentials 数组,凭证通过 SPI 自动发现。
Properties 表单配置文件
路径由 @FlowNodeDescription(propertiesFile) 指定,惯例为 properties/NodeName.json。
文件根结构
json
{
"defaultValue": {
"method": "GET",
"timeout": 30
},
"properties": [
{ "name": "...", "uiComponent": "..." }
]
}字段通用属性
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
name | String | ✅ | 字段唯一标识(camelCase) |
label | String | 表单项标签 | |
uiComponent | String | ✅ | 渲染组件类型 |
placeholder | String | 输入占位提示 | |
hint | String | 字段下方小字说明 | |
required | Boolean | 是否必填,默认 false | |
default | any | 字段默认值 | |
expression | Boolean | 是否支持表达式模式 | |
options | Array | 静态选项列表或子字段定义 | |
typeOptions | Object | 组件专有配置 | |
displayOptions | Object | 条件显示/隐藏规则 | |
rules | Array | 校验规则 | |
droppable | Boolean | 是否支持从消息树拖入变量 | |
draggable | Boolean | 是否支持行拖拽排序 | |
emptyText | String | 列表为空时的提示 | |
addText | String | 添加按钮文字 |
uiComponent 组件速查表
基础输入
| uiComponent | 说明 | 典型 typeOptions |
|---|---|---|
Input | 单行文本 | maxlength, type:"textarea", rows |
Password | 密码输入(脱敏) | — |
InputNumber | 数字输入 | min, max, step, style |
Switch | 开关,值为 boolean | — |
DatePicker | 日期选择 | — |
Space | 空白间隔(仅布局用) | height |
选择类
| uiComponent | 说明 |
|---|---|
Select | 下拉单选;静态 options 或动态 loadOptions |
RadioButtonGroup | 单选按钮组(按钮样式,≤5 选项推荐) |
RadioGroup | 单选组(Radio 样式) |
CheckboxGroup | 复选框组,值为数组 |
CredentialSelect | 凭证选择器,必须配置 typeOptions.credentialsType(PascalCase) |
编辑器类
| uiComponent | 说明 |
|---|---|
JsonExpressionInput | JSON 表达式编辑器;支持 droppable |
ExpressionInput | 模板表达式输入框;支持 droppable |
BooleanExpressionInput | 逻辑条件表达式(SPEL) |
JsEditor | JavaScript 代码编辑器 |
SqlEditor | SQL 查询编辑器 |
CodeEditor | 多语言代码编辑器,typeOptions.lang 指定语言 |
集合类
| uiComponent | 说明 |
|---|---|
Fixed | 固定字段组,值为 Object,子字段不可增删 |
Collection | 可选字段集合,值为 Object,用户按需添加 |
FixedCollection | 固定结构多行列表,值为 Array,可增删排序 |
RelationCollection | 关系列表,联动画布连接线 |
条件显示(displayOptions)
json
{
"name": "bearerTokenOptions",
"uiComponent": "Fixed",
"displayOptions": {
"show": { "authType": ["bearerToken"] }
},
"options": [
{ "name": "token", "label": "Token", "uiComponent": "Input", "expression": true, "required": true }
]
}show/hide可同时存在- 同一对象内多字段为与逻辑
- 数组内多值为或逻辑
- 支持 boolean 值:
"sendBody": [true]
动态加载选项(loadOptions)
json
{
"name": "database",
"label": "数据库",
"uiComponent": "Select",
"typeOptions": {
"allow-create": true,
"clearable": true,
"filterable": true,
"loadOptions": {
"method": "getDatabases",
"provider": "credentials",
"trigger": "auto",
"dependsOn": ["host", "port", "username", "password"],
"credentialsType": "MysqlCredential"
}
}
}| 属性 | 说明 |
|---|---|
method | 后端方法名 |
provider | "credentials" 调 CredentialsProvider;"bundle" 调 MethodExecutor |
trigger | "auto" 依赖满足后自动加载;"manual" 手动刷新 |
dependsOn | 依赖字段列表;provider=bundle 无依赖时写 [] |
credentialsType | provider=credentials 时必填,PascalCase 凭证标识 |
credentialsRef | provider=bundle 时必填,同表单内 CredentialSelect 的 name |
provider=bundle 示例:
json
"loadOptions": {
"method": "getBuckets",
"provider": "bundle",
"trigger": "auto",
"credentialsRef": "credentialsId",
"dependsOn": []
}完整示例(HttpRequestNode properties)
json
{
"defaultValue": { "authType": "none", "timeout": 30, "method": "GET" },
"properties": [
{
"name": "method", "label": "请求方法", "uiComponent": "Select", "required": true, "expression": true,
"options": [
{ "label": "GET", "value": "GET" },
{ "label": "POST", "value": "POST" },
{ "label": "PUT", "value": "PUT" },
{ "label": "PATCH", "value": "PATCH" },
{ "label": "DELETE", "value": "DELETE" }
]
},
{
"name": "url", "label": "请求地址", "uiComponent": "Input",
"required": true, "expression": true,
"rules": [{ "format": "url" }], "typeOptions": { "maxlength": 255 }
},
{
"name": "authType", "label": "鉴权类型", "uiComponent": "Select", "required": true,
"options": [
{ "label": "无", "value": "none" },
{ "label": "API Key", "value": "apiKey" },
{ "label": "Bearer Token", "value": "bearerToken" },
{ "label": "Basic Auth", "value": "basicAuth" }
]
},
{
"name": "apiKeyOptions", "uiComponent": "Fixed",
"displayOptions": { "show": { "authType": ["apiKey"] } },
"options": [
{
"name": "type", "label": "添加位置", "uiComponent": "RadioButtonGroup", "required": true,
"options": [{ "label": "Header", "value": "headers" }, { "label": "Query Params", "value": "query" }]
},
{ "name": "name", "label": "参数名", "uiComponent": "Input", "required": true },
{ "name": "value", "label": "参数值", "uiComponent": "Input", "required": true, "expression": true }
]
},
{
"name": "bearerTokenOptions", "uiComponent": "Fixed",
"displayOptions": { "show": { "authType": ["bearerToken"] } },
"options": [{ "name": "token", "label": "Token", "uiComponent": "Input", "required": true, "expression": true }]
},
{
"name": "basicAuthOptions", "uiComponent": "Fixed",
"displayOptions": { "show": { "authType": ["basicAuth"] } },
"options": [
{ "name": "username", "label": "用户名", "uiComponent": "Input", "required": true, "expression": true },
{ "name": "password", "label": "密码", "uiComponent": "Password", "required": true, "expression": true }
]
},
{ "name": "sendBody", "label": "发送请求体", "uiComponent": "Switch" },
{
"name": "sendBodyOptions", "uiComponent": "Fixed",
"displayOptions": { "show": { "sendBody": [true] } },
"options": [
{
"name": "bodyContentType", "label": "内容类型", "uiComponent": "Select",
"options": [
{ "label": "JSON", "value": "json" },
{ "label": "x-www-form-urlencoded", "value": "x-www-form-urlencoded" },
{ "label": "form-data", "value": "form-data" },
{ "label": "binary", "value": "binary" },
{ "label": "自定义", "value": "raw" }
]
},
{
"name": "jsonBody", "uiComponent": "JsonExpressionInput", "droppable": true,
"typeOptions": { "height": "100px" },
"displayOptions": { "show": { "bodyContentType": ["json"] } }
},
{
"name": "formUrlEncodedBody", "uiComponent": "FixedCollection", "addText": "添加参数",
"displayOptions": { "show": { "bodyContentType": ["x-www-form-urlencoded"] } },
"options": [
{ "name": "name", "label": "参数名", "uiComponent": "Input", "required": true },
{ "name": "value", "label": "参数值", "uiComponent": "Input", "required": true, "expression": true }
]
},
{
"name": "rawBody", "label": "请求体内容", "uiComponent": "Input", "expression": true,
"typeOptions": { "type": "textarea", "rows": 10, "maxlength": 20000 },
"displayOptions": { "show": { "bodyContentType": ["raw"] } }
}
]
},
{
"name": "timeout", "label": "超时(秒)", "uiComponent": "InputNumber",
"hint": "请求超时时间,修改后节点将重启",
"typeOptions": { "min": 1, "max": 300, "style": "width:200px" }
}
]
}SPI 服务注册
框架通过 Java ServiceLoader 自动发现实现类,每行一个完整类名,编码 UTF-8。
META-INF/services/cn.buildify.bundle.api.FlowNode
com.example.bundle.nodes.MyNode
com.example.bundle.nodes.MyTriggerNodeMETA-INF/services/cn.buildify.bundle.api.credentials.CredentialsProvider
com.example.bundle.credentials.ApiKeyCredentialsProviderMETA-INF/services/cn.buildify.bundle.api.MethodExecutor
com.example.bundle.methods.GetListMethodExecutorMETA-INF/services/cn.buildify.bundle.api.BundleActivator
com.example.bundle.MyBundleActivatorTIP
没有该类型实现时不需要创建对应文件。
异步执行模式
HTTP 请求——优先 sendAsync
java
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.whenComplete((response, error) -> {
if (error != null) {
context.tellFailure(message, error);
context.complete(message);
return;
}
try {
JsonValue root = payload.deepCopy();
root.put("output", buildResponse(response));
context.tellSuccess(MessageBuilder.withPayload(root)
.copyHeaders(message.getHeaders()).build());
context.complete(message);
} catch (Throwable e) {
context.tellFailure(message, e);
context.complete(message);
}
});同步阻塞 IO——使用 executeBlocking
java
context.<List<String>>executeBlocking(() -> {
return jdbcTemplate.queryForList("SELECT name FROM db", String.class);
}).onSuccess(result -> {
context.tellSuccess(MessageBuilder.withPayload(JsonValueFactory.fromObject(result))
.copyHeaders(message.getHeaders()).build());
context.complete(message);
}).onFailure(e -> {
context.tellFailure(message, e);
context.complete(message);
});定时器取消
java
@Override
public void destroy(Context context) {
if (scheduledFuture != null && !scheduledFuture.isCancelled()) {
scheduledFuture.cancel(true);
}
}SandboxExecutor 沙箱脚本
java
private SandboxExecutor sandboxExecutor;
@Override
public void initialize(Context context, JsonValue parameters) {
String code = parameters.path("code").asText(null);
if (code == null || code.isBlank()) {
throw new FlowNodeException("INVALID_PARAM", "code cannot be null");
}
String wrapped = "(function jsFunction(msg, headers, env){ %s })".formatted(code);
this.sandboxExecutor = context.createSandboxExecutor("js", wrapped);
this.sandboxExecutor.setTimeout(Duration.ofSeconds(5));
}
@Override
public void onMsg(Context context, Message<?> message) {
context.executeBlocking(() -> {
try {
return sandboxExecutor.execute(
message.getPayload(),
message.getHeaders(),
context.getEnvironmentVariables());
} catch (TimeoutException e) {
throw new CompletionException(e);
}
}).onSuccess(result -> {
context.tellSuccess(MessageBuilder.withPayload(result)
.copyHeaders(message.getHeaders()).build());
context.complete(message);
}).onFailure(ex -> {
context.tellFailure(message, ex);
context.complete(message);
});
}
@Override
public void destroy(Context context) {
if (sandboxExecutor != null) {
try { sandboxExecutor.close(); }
catch (Exception e) { log.warn("failed to close sandbox", e); }
}
}JS 函数签名:
javascript
// msg = payload(JsonValue 映射为 JS 对象)
// headers = 消息 headers
// env = 系统环境变量 Map
return msg;异常处理
initialize() 中的参数校验
java
@Override
public void initialize(Context context, JsonValue parameters) {
String url = parameters.path("url").asText(null);
if (url == null || url.isBlank()) {
throw new FlowNodeException("INVALID_PARAM", "url must not be blank");
}
}onMsg() 同步路径
java
@Override
public void onMsg(Context context, Message<?> message) {
try {
JsonValue result = syncProcess(message);
context.tellSuccess(MessageBuilder.withPayload(result)
.copyHeaders(message.getHeaders()).build());
} catch (Exception e) {
context.tellFailure(message, e);
}
context.complete(message);
}FlowNodeException——结构化异常规范
WARNING
当捕获到已知类型错误时,必须使用 FlowNodeException 结构化后抛出,禁止直接 throw new RuntimeException(...)。
java
import cn.buildify.bundle.api.FlowNodeException;错误码约定:
| 错误码 | 触发场景 | 推荐 metadata key |
|---|---|---|
FEISHU_AUTH_FAILED | 获取 tenant_access_token 失败 | feishuCode |
FEISHU_EMPTY_TOKEN | token 为空或空白 | — |
FEISHU_API_<code> | 业务接口返回 code != 0 | feishuCode, feishuMsg |
HTTP_IO_ERROR | IOException(网络不通、连接超时) | url |
HTTP_INTERRUPTED | InterruptedException | — |
标准用法:
java
// ❌ 禁止
throw new RuntimeException("飞书 API 错误: " + msg);
// ✅ 飞书业务 API 返回 code != 0
int code = body.path("code").asInt(-1);
if (code != 0) {
String apiMsg = body.path("msg").asText("unknown");
throw new FlowNodeException("FEISHU_API_" + code,
"飞书 API 错误 [" + code + "]: " + apiMsg)
.with("feishuCode", code).with("feishuMsg", apiMsg);
}
// ✅ token 为空
if (token == null || token.isBlank()) {
throw new FlowNodeException("FEISHU_EMPTY_TOKEN",
"tenant_access_token 为空,请检查 App ID 和 App Secret");
}
// ✅ HTTP IOException
try {
resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new FlowNodeException("HTTP_IO_ERROR",
"HTTP 请求失败 [" + url + "]: " + e.getMessage(), e)
.with("url", url);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FlowNodeException("HTTP_INTERRUPTED", "HTTP 请求被中断", e);
}onFailure 中的日志分级:
java
.onFailure(e -> {
if (e instanceof FlowNodeException fne) {
log.warn("[MyNode] 执行失败 code={} msg={}", fne.getCode(), fne.getMessage());
} else {
log.error("[MyNode] 未知异常", e);
}
context.tellFailure(message, e);
context.complete(message);
});完整开发示例
节点实现(ExampleApiNode.java)
java
package com.example.bundle.nodes;
import cn.buildify.bundle.api.*;
import cn.buildify.bundle.api.json.*;
import cn.buildify.bundle.api.message.Message;
import cn.buildify.bundle.api.message.support.MessageBuilder;
import cn.buildify.bundle.api.status.NodeStatus;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.*;
@Slf4j
@FlowNodeDescription(name = "ExampleApiNode", propertiesFile = "properties/ExampleApiNode.json")
public class ExampleApiNode implements FlowNode {
private HttpClient httpClient;
private int timeout;
private String credentialsId;
private JsonValue dynamicParameters;
@Override
public void initialize(Context context, JsonValue parameters) {
this.timeout = parameters.path("timeout").asInt(30);
this.credentialsId = parameters.path("credentialsId").asText(null);
String method = parameters.path("method").asText(null);
if (method == null || method.isBlank()) {
throw new FlowNodeException("INVALID_PARAM", "method is required");
}
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeout))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
this.dynamicParameters = JsonValueFactory.objectNode()
.put("url", parameters.path("url"))
.put("method", parameters.path("method"));
context.setStatus(NodeStatus.builder().color("green").text("就绪").build());
}
@Override
public List<Relation> getRelations() {
return List.of(Relation.RELATIONSHIP_SUCCESS, Relation.RELATIONSHIP_FAILURE);
}
@Override
public boolean isRestartRequired(JsonValue newParameters, JsonValue oldParameters) {
return newParameters.path("timeout").asInt(30) != oldParameters.path("timeout").asInt(30);
}
@Override
public void onMsg(Context context, Message<?> message) {
JsonValue payload = getPayload(message, JsonValue.class);
JsonValue parameters = context.resolveExpressions(dynamicParameters, payload);
String url = parameters.path("url").asText(null);
String method = parameters.path("method").asText(null);
if (url == null || method == null) {
context.tellFailure(message, new FlowNodeException("INVALID_PARAM", "url/method is required"));
context.complete(message);
return;
}
Map<String, String> authHeaders = buildAuthHeaders(context);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(timeout));
authHeaders.forEach(builder::header);
switch (method.toUpperCase()) {
case "GET" -> builder.GET();
case "DELETE" -> builder.DELETE();
default -> throw new IllegalArgumentException("Unsupported method: " + method);
}
httpClient.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString())
.whenComplete((response, error) -> {
if (error != null) {
log.error("[ExampleApiNode] request failed", error);
context.tellFailure(message, error);
context.complete(message);
return;
}
try {
JsonValue root = payload.deepCopy();
root.put("output", buildResponse(response));
context.tellSuccess(MessageBuilder.withPayload(root)
.copyHeaders(message.getHeaders()).build());
context.complete(message);
} catch (Throwable e) {
log.error("[ExampleApiNode] failed to process response", e);
context.tellFailure(message, e);
context.complete(message);
}
});
}
private Map<String, String> buildAuthHeaders(Context context) {
if (credentialsId == null) return Map.of();
Credentials cred = context.getCredentials(credentialsId);
if (cred == null) return Map.of();
String apiKey = (String) cred.getData().get("apiKey");
return apiKey != null ? Map.of("Authorization", "Bearer " + apiKey) : Map.of();
}
private JsonValue buildResponse(HttpResponse<String> response) {
JsonValue result = JsonValueFactory.objectNode()
.put("statusCode", response.statusCode());
String body = response.body();
if (body != null && !body.isBlank()) {
try {
result.put("body", JsonValueFactory.fromJson(body));
} catch (Exception e) {
result.put("body", body);
}
}
return result;
}
}表单配置(resources/properties/ExampleApiNode.json)
json
{
"defaultValue": { "method": "GET", "timeout": 30 },
"properties": [
{
"name": "url", "label": "请求地址", "uiComponent": "Input",
"required": true, "expression": true, "droppable": true,
"rules": [{ "format": "url" }], "typeOptions": { "maxlength": 255 }
},
{
"name": "method", "label": "请求方法", "uiComponent": "RadioButtonGroup", "required": true,
"options": [{ "label": "GET", "value": "GET" }, { "label": "DELETE", "value": "DELETE" }]
},
{
"name": "credentialsId", "label": "API 凭证", "uiComponent": "CredentialSelect",
"typeOptions": { "credentialsType": "ApiKeyCredential" }
},
{
"name": "timeout", "label": "超时(秒)", "uiComponent": "InputNumber",
"hint": "更改超时后节点将自动重启",
"typeOptions": { "min": 1, "max": 300, "style": "width:200px" }
}
]
}凭证(ApiKeyCredentialsProvider.java)
java
@CredentialsDescription(
type = "ApiKeyCredential",
label = "API Key 凭证",
propertiesFile = "credentials/ApiKeyCredential.json"
)
public class ApiKeyCredentialsProvider implements CredentialsProvider {}SPI 注册
META-INF/services/cn.buildify.bundle.api.FlowNode
com.example.bundle.nodes.ExampleApiNodeMETA-INF/services/cn.buildify.bundle.api.credentials.CredentialsProvider
com.example.bundle.credentials.ApiKeyCredentialsProviderbundle.json
json
{
"bundleName": "example/api-bundle",
"groups": [
{
"label": "API",
"nodes": [
{
"icon": "api.svg",
"name": "ExampleApiNode",
"label": "Example API",
"summary": "调用示例 API",
"parameters": { "method": "GET", "timeout": 30 }
}
]
}
]
}使用文档规范
每个 Bundle 须在 docs/doc.md 提供面向最终用户的使用文档。
文件位置
my-bundle/
└── docs/
└── doc.md必须包含的章节
| 章节 | 说明 |
|---|---|
| 概述 | 节点名称、所属分组、功能一句话描述 |
| 前置准备 | 凭证配置说明(字段表格 + 测试连接提示) |
| 节点参数 | 每个表单字段的必填性与含义 |
| 输出 | 成功后 payload 中追加字段的 JSON 结构及说明 |
| 输出关系 | Success / Failure 各自的触发条件 |
| 错误码 | 所有 FlowNodeException 的 code 值、含义及排查建议 |
| 使用示例 | 至少一个端到端配置示例 |
| 注意事项 | 易错点、格式要求、权限说明等 |
写作规范
- 面向最终用户,不出现 Java 类名、包路径等技术细节
- 参数名使用表单中的 label(中文名),而非字段
name - 错误码须与代码中
FlowNodeException第一个参数保持同步
开发检查清单
- [ ] 每个
FlowNode都标注了@FlowNodeDescription(name = "...") - [ ]
@FlowNodeDescription.name与bundle.json groups[].nodes[].name完全一致 - [ ] Properties 文件位于
resources/properties/,字段使用uiComponent(不是type) - [ ] 参数读取使用
parameters.path()(不是parameters.get()) - [ ] 同步与异步路径在路由后都调用了
context.complete(message) - [ ] 含表达式的参数:
initialize()存入dynamicParameters,onMsg()用resolveExpressions解析 - [ ] 普通节点输出放
output;触发器 payload 放根级 - [ ] 触发器使用
context.createTriggerMessage(payload),并判断context.isTest() - [ ] 触发器注解同时设
isTrigger=true, hasInput=false,destroy()中取消定时任务 / 注销 HTTP 路由 - [ ]
SandboxExecutor在destroy()中调用了close() - [ ]
@CredentialsDescription(type)与表单typeOptions.credentialsType、文件名credentials/<type>.json三处一致 - [ ]
getCredentials传入的是CredentialSelect字段name,未使用已废弃的credentialsName - [ ] 已知错误使用
FlowNodeException(含initialize参数校验) - [ ] 所有实现类都已注册到
META-INF/services/对应 SPI 文件中 - [ ]
buildify-bundle-api依赖 scope 为provided - [ ]
bundle.json中无credentials数组(凭证通过 SPI 自动发现)
