flowable-devops/backend/docs/07-插件开发指南.md
dengqichen ab88ff72f2 提交
2025-10-13 23:01:56 +08:00

15 KiB
Raw Blame History

插件开发指南

版本: v1.0
日期: 2025-01-13
目标: 指导开发者快速上手节点插件开发


一、快速开始5分钟创建你的第一个插件

1.1 前提条件

  • JDK 17+
  • Maven 3.6+
  • 基本的Java和Spring知识
  • 已有Flowable DevOps项目运行环境

1.2 创建插件类

src/main/java/com/flowable/devops/workflow/node/ 目录下创建新类:

package com.flowable.devops.workflow.node;

import com.flowable.devops.entity.NodeType;
import com.flowable.devops.workflow.model.*;
import com.flowable.devops.workflow.plugin.NodePlugin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 示例插件:文本处理节点
 * 
 * 功能:
 * - 转大写
 * - 转小写
 * - 反转字符串
 */
@Slf4j
@NodePlugin(
    id = "text_processor",
    name = "textProcessor",
    displayName = "Text Processor",
    category = NodeType.NodeCategory.TRANSFORM,
    version = "1.0.0",
    author = "Your Name",
    description = "文本处理工具,支持大小写转换和字符串反转",
    icon = "font-size",
    enabled = true
)
@Component
public class TextProcessorNode implements WorkflowNode {
    
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public NodeType getMetadata() {
        NodeType nodeType = new NodeType();
        nodeType.setId("text_processor");
        nodeType.setName("textProcessor");
        nodeType.setDisplayName("Text Processor");
        nodeType.setCategory(NodeType.NodeCategory.TRANSFORM);
        nodeType.setIcon("font-size");
        nodeType.setDescription("文本处理工具");
        nodeType.setImplementationClass(this.getClass().getName());
        nodeType.setEnabled(true);
        
        // 字段定义
        nodeType.setFields(createFieldsJson());
        
        // 输出结构
        nodeType.setOutputSchema(createOutputSchemaJson());
        
        return nodeType;
    }
    
    /**
     * 创建字段定义
     */
    private com.fasterxml.jackson.databind.JsonNode createFieldsJson() {
        ArrayNode fields = objectMapper.createArrayNode();
        
        // 输入文本
        ObjectNode inputField = objectMapper.createObjectNode();
        inputField.put("name", "text");
        inputField.put("label", "Input Text");
        inputField.put("type", "text");
        inputField.put("required", true);
        inputField.put("supportsExpression", true);
        inputField.put("placeholder", "Enter text to process...");
        fields.add(inputField);
        
        // 操作类型
        ObjectNode operationField = objectMapper.createObjectNode();
        operationField.put("name", "operation");
        operationField.put("label", "Operation");
        operationField.put("type", "select");
        operationField.put("required", true);
        operationField.put("defaultValue", "uppercase");
        ArrayNode operations = objectMapper.createArrayNode();
        operations.add("uppercase");
        operations.add("lowercase");
        operations.add("reverse");
        operationField.set("options", operations);
        fields.add(operationField);
        
        return fields;
    }
    
    /**
     * 创建输出结构
     */
    private com.fasterxml.jackson.databind.JsonNode createOutputSchemaJson() {
        ObjectNode schema = objectMapper.createObjectNode();
        schema.put("type", "object");
        
        ObjectNode properties = objectMapper.createObjectNode();
        
        ObjectNode result = objectMapper.createObjectNode();
        result.put("type", "string");
        result.put("description", "处理后的文本");
        properties.set("result", result);
        
        ObjectNode operation = objectMapper.createObjectNode();
        operation.put("type", "string");
        operation.put("description", "执行的操作");
        properties.set("operation", operation);
        
        schema.set("properties", properties);
        return schema;
    }
    
    @Override
    public NodeExecutionResult execute(NodeInput input, NodeExecutionContext context) {
        LocalDateTime startTime = LocalDateTime.now();
        
        try {
            // 1. 获取输入参数
            String text = input.getStringRequired("text");
            String operation = input.getString("operation", "uppercase");
            
            log.info("执行文本处理: operation={}, text={}", operation, text);
            
            // 2. 执行操作
            String result;
            switch (operation) {
                case "uppercase":
                    result = text.toUpperCase();
                    break;
                case "lowercase":
                    result = text.toLowerCase();
                    break;
                case "reverse":
                    result = new StringBuilder(text).reverse().toString();
                    break;
                default:
                    throw new IllegalArgumentException("Unknown operation: " + operation);
            }
            
            // 3. 构建输出
            Map<String, Object> output = new HashMap<>();
            output.put("result", result);
            output.put("operation", operation);
            
            LocalDateTime endTime = LocalDateTime.now();
            log.info("文本处理完成: {} -> {}", text, result);
            
            return NodeExecutionResult.success(output, startTime, endTime);
            
        } catch (Exception e) {
            LocalDateTime endTime = LocalDateTime.now();
            log.error("文本处理失败: {}", e.getMessage(), e);
            return NodeExecutionResult.failed(e.getMessage(), e.getClass().getSimpleName(), startTime, endTime);
        }
    }
    
    @Override
    public void validate(NodeInput input) {
        // 验证必需参数
        input.getStringRequired("text");
        
        String operation = input.getString("operation");
        if (operation != null && 
            !operation.equals("uppercase") && 
            !operation.equals("lowercase") && 
            !operation.equals("reverse")) {
            throw new IllegalArgumentException("Invalid operation: " + operation);
        }
    }
}

1.3 测试插件

重启应用

mvn spring-boot:run

查看日志

========================================
开始初始化插件系统...
========================================
✓ 加载内置插件: 2 个
  ✓ http_request: HTTP Request (1.0.0)
  ✓ text_processor: Text Processor (1.0.0)
========================================
插件清单:
========================================
【API接口】(1 个)
  ✓ 📦 HTTP Request v1.0.0 - Flowable Team (INTERNAL)
【数据转换】(1 个)
  ✓ 📦 Text Processor v1.0.0 - Your Name (INTERNAL)
========================================

调用API测试

# 获取所有节点类型
curl http://localhost:8080/api/node-types

# 应该能看到新的text_processor节点

二、核心概念

2.1 插件注解 (@NodePlugin)

@NodePlugin(
    id = "unique_id",              // 必需全局唯一ID
    name = "nodeName",             // 必需:代码引用名
    displayName = "Display Name",  // 必需UI显示名
    category = NodeCategory.API,   // 必需:分类
    version = "1.0.0",            // 版本号
    author = "Your Name",         // 作者
    description = "描述",          // 功能描述
    icon = "api",                 // 图标
    enabled = true                // 默认启用
)

参数说明

参数 类型 必需 说明 示例
id String 节点唯一ID "http_request"
name String 代码引用名 "httpRequest"
displayName String UI显示名 "HTTP Request"
category NodeCategory 分类 API, DATABASE, LOGIC, NOTIFICATION, TRANSFORM, OTHER
version String 版本号 "1.0.0"
author String 作者 "Your Name"
description String 描述 "发送HTTP请求"
icon String 图标 "api", "database", "mail"
enabled boolean 默认启用 true

2.2 节点接口 (WorkflowNode)

所有插件必须实现 WorkflowNode 接口:

public interface WorkflowNode {
    
    /**
     * 获取节点元数据
     * 包含字段定义、输出结构等
     */
    NodeType getMetadata();
    
    /**
     * 执行节点
     * @param input 输入参数(表达式已解析)
     * @param context 执行上下文
     * @return 执行结果
     */
    NodeExecutionResult execute(NodeInput input, NodeExecutionContext context);
    
    /**
     * 验证节点配置(可选)
     * @param input 输入参数
     */
    default void validate(NodeInput input) {
        // 默认不验证
    }
}

2.3 节点元数据 (NodeType)

定义节点的UI表现和数据结构

NodeType nodeType = new NodeType();
nodeType.setId("my_node");
nodeType.setDisplayName("My Node");
nodeType.setCategory(NodeCategory.API);
nodeType.setFields(fieldsJson);        // 字段定义
nodeType.setOutputSchema(schemaJson);  // 输出结构

字段定义示例

[
  {
    "name": "url",
    "label": "URL",
    "type": "text",
    "required": true,
    "supportsExpression": true,
    "placeholder": "https://api.example.com"
  },
  {
    "name": "method",
    "label": "Method",
    "type": "select",
    "options": ["GET", "POST", "PUT", "DELETE"],
    "defaultValue": "GET"
  }
]

输出结构示例 (JSON Schema):

{
  "type": "object",
  "properties": {
    "statusCode": {
      "type": "number",
      "description": "HTTP状态码"
    },
    "body": {
      "type": "object",
      "description": "响应体"
    }
  }
}

2.4 执行结果 (NodeExecutionResult)

返回节点执行结果:

// 成功
return NodeExecutionResult.success(output, startTime, endTime);

// 失败
return NodeExecutionResult.failed(errorMessage, errorType, startTime, endTime);

// 跳过
return NodeExecutionResult.skipped(reason, startTime, endTime);

三、最佳实践

3.1 节点设计原则

无状态设计

// ✅ 推荐:所有状态通过参数传递
public NodeExecutionResult execute(NodeInput input, NodeExecutionContext context) {
    String url = input.getString("url");
    // ...
}

// ❌ 禁止:在节点中保存状态
private String lastUrl;  // 不要这样!

幂等性

// 多次执行相同输入应得到相同结果
// 对于非幂等操作(如发送邮件),应该有幂等性保护机制

错误处理

try {
    // 业务逻辑
    return NodeExecutionResult.success(output, startTime, endTime);
} catch (Exception e) {
    log.error("执行失败: {}", e.getMessage(), e);
    return NodeExecutionResult.failed(e.getMessage(), e.getClass().getSimpleName(), startTime, endTime);
}

日志记录

log.info("开始执行HTTP请求: {}", url);
log.debug("请求参数: {}", params);
log.error("请求失败: {}", e.getMessage(), e);

3.2 字段定义最佳实践

支持表达式

{
  "name": "url",
  "supportsExpression": true,  // 允许使用 ${nodes.xxx.output.yyy}
  "supportsFieldMapping": true  // 允许使用字段映射选择器
}

提供默认值

{
  "name": "timeout",
  "type": "number",
  "defaultValue": 30000
}

完整的字段类型

  • text: 单行文本
  • textarea: 多行文本
  • number: 数字
  • boolean: 布尔值
  • select: 下拉选择
  • code: 代码编辑器
  • key_value: 键值对列表

3.3 输出结构设计

清晰的层次结构

{
  "type": "object",
  "properties": {
    "data": {
      "type": "object",
      "description": "主数据"
    },
    "metadata": {
      "type": "object",
      "description": "元数据"
    }
  }
}

提供详细的字段说明

{
  "statusCode": {
    "type": "number",
    "description": "HTTP状态码 (200-599)"
  }
}

四、高级功能

4.1 访问上游节点数据

@Override
public NodeExecutionResult execute(NodeInput input, NodeExecutionContext context) {
    // 获取上游节点的输出
    Map<String, Object> upstreamData = context.getNodeOutput("upstream_node_id");
    
    if (upstreamData != null) {
        Object someValue = upstreamData.get("output");
        // 使用上游数据...
    }
    
    // ...
}

4.2 访问环境变量

String apiKey = context.getEnv("API_KEY");

4.3 访问工作流变量

Map<String, Object> workflowVars = context.getVariables();
Object inputData = workflowVars.get("input");

4.4 异步执行(高级)

// 注意:第一期不支持,使用同步执行
// 第二期可以使用CompletableFuture实现异步

五、测试

5.1 单元测试

@SpringBootTest
public class TextProcessorNodeTest {
    
    @Autowired
    private TextProcessorNode node;
    
    @Test
    public void testUppercase() {
        // 准备输入
        Map<String, Object> config = Map.of(
            "text", "hello",
            "operation", "uppercase"
        );
        NodeInput input = new NodeInput(config);
        
        // 准备上下文
        NodeExecutionContext context = NodeExecutionContext.builder()
            .nodeId("test_node")
            .build();
        
        // 执行
        NodeExecutionResult result = node.execute(input, context);
        
        // 验证
        assertEquals(NodeExecutionResult.ExecutionStatus.SUCCESS, result.getStatus());
        assertEquals("HELLO", result.getOutput().get("result"));
    }
}

5.2 集成测试

创建完整的工作流进行测试:

{
  "name": "测试文本处理",
  "nodes": [
    {
      "id": "node1",
      "type": "text_processor",
      "config": {
        "text": "Hello World",
        "operation": "uppercase"
      }
    }
  ],
  "edges": []
}

六、常见问题

Q1: 如何添加新的字段类型?

A: 修改前端的字段渲染组件,后端只需要在 fields JSON中定义即可。

Q2: 节点可以调用外部服务吗?

A: 可以,但建议使用 WebClient异步而不是 RestTemplate同步

Q3: 如何处理大文件?

A: 避免在节点输出中保存大文件使用文件引用URL或路径

Q4: 节点可以访问数据库吗?

A: 可以通过 Spring 注入 Repository但不推荐直接访问建议通过 Service 层。

Q5: 如何debug插件

A:

  1. 增加日志输出
  2. 使用IDE断点调试
  3. 查看节点执行日志表

七、示例插件库

7.1 数据库查询节点

参考:DatabaseQueryNode.java(待实现)

7.2 发送邮件节点

参考:SendEmailNode.java(待实现)

7.3 Slack通知节点

参考:SlackNotificationNode.java(待实现)


八、发布插件

8.1 内置插件

直接提交代码到主仓库即可。

8.2 外部插件(第二期)

  1. 打包为JAR
  2. 上传到插件市场
  3. 用户下载安装

下一步:查看 06-插件化架构设计.md 了解系统架构。