Bundle 节点开发
本文是 Buildify 自定义节点(Bundle)的完整开发规范,涵盖项目结构、核心 API、表单配置与发布检查清单。文档基于 bundle-core 真实实现整理,也可直接作为 AI 生成节点代码的规范依据。
阅读前提
开发 Bundle 需要 Java 21 与 Maven 环境。如果你只是使用平台内置节点编排流程,请从快速开始入门。
核心概念
| 概念 | 说明 |
|---|---|
| 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);
}
// 定义输出关系,默认 [Success, Failure](可选)
default List<Relation> getRelations() { return Relation.DEFAULT; }
// 辅助方法:获取 payload 并转型
default <T> T getPayload(Message<?> message, Class<T> type) { ... }
}节点生命周期
[加载] → 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)
所有异步操作完成后,无论成功失败,都必须调用 context.complete(message)。
java
// ✅ sendAsync 写法
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.whenComplete((response, error) -> {
if (error != null) {
context.tellFailure(message, error);
context.complete(message); // ← 必须
return;
}
context.tellSuccess(newMsg);
context.complete(message); // ← 必须
});
// ✅ executeBlocking 链式写法
context.executeBlocking(() -> doWork())
.onSuccess(result -> {
context.tellNext(message, "Success");
context.complete(message); // ← 必须
})
.onFailure(e -> {
context.tellFailure(message, e);
context.complete(message); // ← 必须
});TIP
同步场景直接 tellSuccess / tellFailure 后立即返回,不需要 complete()。
规范 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");
}触发器节点必须处理 isTest()
java
@Override
public void initialize(Context context, JsonValue parameters) {
if (context.isTest()) {
// 测试模式:立即触发一次
triggerOnce(context);
} else {
// 生产模式:注册定时任务
this.scheduledFuture = context.schedule(() -> triggerOnce(context), cronExpr, zoneId);
}
}
@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 = Objects.requireNonNull(
parameters.path("expression").asText(null), "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);凭证获取
java
Credentials credentials = context.getCredentials("myCredentialAlias");
Map<String, Object> data = credentials.getData();
String apiKey = (String) data.get("apiKey");节点和工作流信息
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
// 全新消息
Message<JsonValue> msg = MessageBuilder.withPayload(payload).build();
// 基于原消息复制 headers(推荐)
Message<JsonValue> msg = MessageBuilder.withPayload(newPayload)
.copyHeaders(originalMsg.getHeaders())
.build();
// 追加自定义 header
Message<JsonValue> msg = MessageBuilder.withPayload(payload)
.copyHeaders(originalMsg.getHeaders())
.setHeader("myKey", "myValue")
.build();
// 指定完整 headers(Webhook 触发器使用)
MessageHeaders headers = new MessageHeaders(
Map.of(MessageHeaders.WEBHOOK_TRIGGER_ID_NAME, requestId));
Message<?> msg = MessageBuilder.createMessage(root, headers);读取消息
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
访问凭证
@CredentialsDescription 注解
java
@CredentialsDescription(
name = "apiKeyCredential",
label = "API Key",
propertiesFile = "credentials/apiKey.json"
)
public class ApiKeyCredentialsProvider implements CredentialsProvider {
// 无动态方法时,空实现即可
}含动态方法的凭证
java
@CredentialsDescription(name = "mysqlCred", label = "MySQL", propertiesFile = "credentials/mysql.json")
public class MysqlCredentialsProvider implements CredentialsProvider {
@Override
public CompletableFuture<Object> execute(CredentialsMethodRequest request) {
return CompletableFuture.supplyAsync(() -> {
if ("testConnection".equals(request.getMethod())) {
return Map.of("success", true, "message", "连接成功");
}
if ("getDatabases".equals(request.getMethod())) {
return List.of(Map.of("label", "mydb", "value", "mydb"));
}
throw new UnsupportedOperationException(request.getMethod());
});
}
}凭证表单(credentials/apiKey.json)
json
{
"properties": [
{
"name": "apiKey",
"label": "API Key",
"uiComponent": "Password",
"required": true,
"placeholder": "请输入 API Key"
}
]
}两种凭证使用模式
模式 A:动态 CredentialSelect(支持多种凭证类型时使用)
java
private String credentialAlias;
@Override
public void initialize(Context context, JsonValue parameters) {
String alias = parameters.path("credential").asText(null);
if (alias != null) {
Credentials cred = context.getCredentials(alias);
this.apiKey = (String) cred.getData().get("apiKey");
}
}模式 B:硬编码常量(只有一种凭证类型时使用)
表单配置中字段 name、typeOptions.credentialsName、@CredentialsDescription(name) 以及静态常量值四者必须完全一致。
java
private static final String CREDENTIAL_ALIAS = "ossCredential";
@Override
public void onMsg(Context context, Message<?> message) {
Credentials cred = context.getCredentials(CREDENTIAL_ALIAS);
String accessKeyId = (String) cred.getData().get("accessKeyId");
}WARNING
两种模式不可混用。模式 B 的 CredentialSelect 字段 name 必须等于 credentialsName,框架通过此字段绑定凭证实例。
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.credentialsName |
编辑器类
| 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": {
"loadOptions": {
"method": "getDatabases",
"provider": "credentials",
"trigger": "auto",
"dependsOn": ["host", "port", "username", "password"],
"credentialsName": "mysqlCred"
}
}
}| 属性 | 说明 |
|---|---|
method | 后端方法名 |
provider | "credentials" 调 CredentialsProvider;"bundle" 调 MethodExecutor |
trigger | "auto" 依赖满足后自动加载;省略则手动刷新 |
dependsOn | 依赖字段列表,全部有值才触发 |
credentialsName | provider=credentials 时必填 |
完整示例(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 = Objects.requireNonNull(
parameters.path("code").asText(null), "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 = Objects.requireNonNull(
parameters.path("url").asText(null), "url must not be null");
if (url.isBlank()) throw new IllegalArgumentException("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);
}
}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 credentialAlias;
private JsonValue dynamicParameters;
@Override
public void initialize(Context context, JsonValue parameters) {
this.timeout = parameters.path("timeout").asInt(30);
this.credentialAlias = parameters.path("credential").asText(null);
Objects.requireNonNull(parameters.path("method").asText(null), "method is null");
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 = Objects.requireNonNull(parameters.path("url").asText(null), "url is null");
String method = Objects.requireNonNull(parameters.path("method").asText(null), "method is null");
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 (credentialAlias == null) return Map.of();
Credentials cred = context.getCredentials(credentialAlias);
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": "credential", "label": "API 凭证", "uiComponent": "CredentialSelect",
"typeOptions": { "credentialsName": "apiKeyCredential" }
},
{
"name": "timeout", "label": "超时(秒)", "uiComponent": "InputNumber",
"hint": "更改超时后节点将自动重启",
"typeOptions": { "min": 1, "max": 300, "style": "width:200px" }
}
]
}凭证(ApiKeyCredentialsProvider.java)
java
@CredentialsDescription(
name = "apiKeyCredential",
label = "API Key 凭证",
propertiesFile = "credentials/apiKey.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解析 - [ ] 触发器节点在
initialize()中判断了context.isTest() - [ ] 触发器节点在
destroy()中取消了定时任务 / 注销了 HTTP 路由 - [ ]
SandboxExecutor在destroy()中调用了close() - [ ] 每个
CredentialsProvider都标注了@CredentialsDescription - [ ] 凭证表单文件位于
resources/credentials/ - [ ] 所有实现类都已注册到
META-INF/services/对应 SPI 文件中 - [ ]
buildify-bundle-api依赖 scope 为provided - [ ]
bundle.json中无credentials数组(凭证通过 SPI 自动发现)
