Skip to content

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.CredentialsProvider

pom.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/targetrelease 会自动设置系统模块位置,确保生成的字节码可在 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
isTriggerfalse触发器通常搭配 hasInput=false
isLayoutNodefalse分支容器、循环容器等布局节点设为 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_NAMEWebhook 请求 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:硬编码常量(只有一种凭证类型时使用)

表单配置中字段 nametypeOptions.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
          }
        }
      ]
    }
  ]
}
字段必填说明
bundleNameBundle 唯一名称,惯例 组织/名称
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": "..." }
  ]
}

字段通用属性

属性类型必填说明
nameString字段唯一标识(camelCase)
labelString表单项标签
uiComponentString渲染组件类型
placeholderString输入占位提示
hintString字段下方小字说明
requiredBoolean是否必填,默认 false
defaultany字段默认值
expressionBoolean是否支持表达式模式
optionsArray静态选项列表或子字段定义
typeOptionsObject组件专有配置
displayOptionsObject条件显示/隐藏规则
rulesArray校验规则
droppableBoolean是否支持从消息树拖入变量
draggableBoolean是否支持行拖拽排序
emptyTextString列表为空时的提示
addTextString添加按钮文字

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说明
JsonExpressionInputJSON 表达式编辑器;支持 droppable
ExpressionInput模板表达式输入框;支持 droppable
BooleanExpressionInput逻辑条件表达式(SPEL)
JsEditorJavaScript 代码编辑器
SqlEditorSQL 查询编辑器
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依赖字段列表,全部有值才触发
credentialsNameprovider=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.MyTriggerNode

META-INF/services/cn.buildify.bundle.api.credentials.CredentialsProvider

com.example.bundle.credentials.ApiKeyCredentialsProvider

META-INF/services/cn.buildify.bundle.api.MethodExecutor

com.example.bundle.methods.GetListMethodExecutor

META-INF/services/cn.buildify.bundle.api.BundleActivator

com.example.bundle.MyBundleActivator

TIP

没有该类型实现时不需要创建对应文件。


异步执行模式

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_TOKENtoken 为空或空白
FEISHU_API_<code>业务接口返回 code != 0feishuCode, feishuMsg
HTTP_IO_ERRORIOException(网络不通、连接超时)url
HTTP_INTERRUPTEDInterruptedException

标准用法:

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.ExampleApiNode

META-INF/services/cn.buildify.bundle.api.credentials.CredentialsProvider

com.example.bundle.credentials.ApiKeyCredentialsProvider

bundle.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.namebundle.json groups[].nodes[].name 完全一致
  • [ ] Properties 文件位于 resources/properties/,字段使用 uiComponent不是 type
  • [ ] 参数读取使用 parameters.path()不是 parameters.get()
  • [ ] 所有异步回调都调用了 context.complete(message)
  • [ ] 含表达式的参数:initialize() 存入 dynamicParametersonMsg()resolveExpressions 解析
  • [ ] 触发器节点在 initialize() 中判断了 context.isTest()
  • [ ] 触发器节点在 destroy() 中取消了定时任务 / 注销了 HTTP 路由
  • [ ] SandboxExecutordestroy() 中调用了 close()
  • [ ] 每个 CredentialsProvider 都标注了 @CredentialsDescription
  • [ ] 凭证表单文件位于 resources/credentials/
  • [ ] 所有实现类都已注册到 META-INF/services/ 对应 SPI 文件中
  • [ ] buildify-bundle-api 依赖 scope 为 provided
  • [ ] bundle.json 中无 credentials 数组(凭证通过 SPI 自动发现)