1209 lines
32 KiB
Markdown
1209 lines
32 KiB
Markdown
# HTTP节点响应提取配置 - 技术设计文档
|
||
|
||
## 📋 文档信息
|
||
|
||
| 项目 | 内容 |
|
||
|------|------|
|
||
| **文档版本** | v1.0 |
|
||
| **创建日期** | 2025-10-25 |
|
||
| **文档状态** | 设计中 |
|
||
| **相关模块** | 工作流引擎 - HTTP请求节点 |
|
||
|
||
---
|
||
|
||
## 1. 概述
|
||
|
||
### 1.1 背景
|
||
|
||
在工作流引擎中,HTTP节点是最常用的集成类型节点之一。用户需要调用外部API获取数据,并在后续节点中使用这些数据。但API响应通常具有复杂的嵌套结构,导致后续节点引用数据时表达式冗长、难以维护。
|
||
|
||
### 1.2 问题场景
|
||
|
||
**典型的API响应结构:**
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"message": "success",
|
||
"data": {
|
||
"user": {
|
||
"id": 12345,
|
||
"name": "张三",
|
||
"email": "zhangsan@example.com",
|
||
"department": {
|
||
"id": 88,
|
||
"name": "技术部"
|
||
}
|
||
},
|
||
"permissions": ["read", "write", "delete"]
|
||
}
|
||
}
|
||
```
|
||
|
||
**不使用提取配置的问题:**
|
||
```javascript
|
||
// 后续节点需要这样引用:
|
||
${httpNode1.body.data.user.id} // 冗长
|
||
${httpNode1.body.data.user.name} // 难以记忆
|
||
${httpNode1.body.data.user.department.name} // 容易出错
|
||
```
|
||
|
||
### 1.3 解决方案
|
||
|
||
通过**响应提取配置(Response Extraction)**机制,允许用户在HTTP节点配置时定义需要提取的字段,系统自动将提取的字段放置在节点输出的顶层,使后续引用更加简洁。
|
||
|
||
---
|
||
|
||
## 2. 设计目标
|
||
|
||
### 2.1 核心目标
|
||
|
||
1. **简化引用**:将深层嵌套的字段提升到顶层,使用 `${nodeId.fieldName}` 即可引用
|
||
2. **保留完整性**:保留完整的响应数据结构,支持引用任意字段
|
||
3. **提高可维护性**:API结构变化时,只需修改提取配置,无需修改所有引用点
|
||
4. **降低学习成本**:提供可视化配置界面,无需手写复杂的JSONPath表达式
|
||
|
||
### 2.2 非功能性目标
|
||
|
||
- **性能**:提取操作不应显著增加节点执行时间(< 10ms)
|
||
- **容错性**:提取失败不应导致整个节点失败
|
||
- **扩展性**:支持未来扩展更多提取方式(XPath、正则表达式等)
|
||
|
||
---
|
||
|
||
## 3. 技术方案
|
||
|
||
### 3.1 整体架构
|
||
|
||
```
|
||
┌─────────────┐
|
||
│ 用户配置 │
|
||
│ 提取规则 │
|
||
└──────┬──────┘
|
||
│
|
||
↓
|
||
┌─────────────────────────────────────┐
|
||
│ HTTP节点执行 │
|
||
│ 1. 发送HTTP请求 │
|
||
│ 2. 获取响应数据 │
|
||
│ 3. 解析JSON │
|
||
│ 4. 根据提取规则提取字段 ⭐ │
|
||
│ 5. 将提取字段放入outputs顶层 │
|
||
└──────┬──────────────────────────────┘
|
||
│
|
||
↓
|
||
┌─────────────────────────────────────┐
|
||
│ 节点输出数据结构 │
|
||
│ { │
|
||
│ status: 200, │
|
||
│ body: {...}, // 完整响应 │
|
||
│ userId: 12345, // 提取字段 │
|
||
│ userName: "张三" // 提取字段 │
|
||
│ } │
|
||
└──────┬──────────────────────────────┘
|
||
│
|
||
↓
|
||
┌─────────────────────────────────────┐
|
||
│ 后续节点引用 │
|
||
│ ${httpNode1.userId} ✅ 简洁 │
|
||
│ ${httpNode1.userName} ✅ 简洁 │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.2 数据流转
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User as 用户
|
||
participant Frontend as 前端配置界面
|
||
participant Backend as 工作流引擎
|
||
participant API as 外部API
|
||
|
||
User->>Frontend: 配置HTTP节点
|
||
User->>Frontend: 添加提取规则
|
||
Frontend->>Backend: 保存节点配置
|
||
|
||
Note over Backend: 工作流执行
|
||
Backend->>API: 发送HTTP请求
|
||
API-->>Backend: 返回响应数据
|
||
Backend->>Backend: 解析JSON
|
||
Backend->>Backend: 应用提取规则
|
||
Backend->>Backend: 构建节点输出
|
||
|
||
Note over Backend: 后续节点执行
|
||
Backend->>Backend: 解析变量引用
|
||
Backend->>Backend: 替换 ${nodeId.field}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 数据结构设计
|
||
|
||
### 4.1 提取配置结构
|
||
|
||
```typescript
|
||
/**
|
||
* 单个字段提取配置
|
||
*/
|
||
interface ExtractionConfig {
|
||
/**
|
||
* 提取后的字段名
|
||
* 用于在其他节点中引用:${nodeId.fieldName}
|
||
*/
|
||
name: string;
|
||
|
||
/**
|
||
* JSONPath表达式
|
||
* 例如:$.data.user.id
|
||
*/
|
||
path: string;
|
||
|
||
/**
|
||
* 数据类型
|
||
*/
|
||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||
|
||
/**
|
||
* 默认值(可选)
|
||
* 提取失败时使用
|
||
*/
|
||
defaultValue?: any;
|
||
|
||
/**
|
||
* 是否必需(可选)
|
||
* 如果为true,提取失败时节点报错
|
||
*/
|
||
required?: boolean;
|
||
}
|
||
|
||
/**
|
||
* HTTP节点配置
|
||
*/
|
||
interface HttpNodeConfig {
|
||
// 基础配置
|
||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||
url: string;
|
||
headers: Array<{ key: string; value: string }>;
|
||
bodyType?: 'none' | 'json' | 'form' | 'raw';
|
||
body?: string | object;
|
||
|
||
// 响应提取配置 ⭐
|
||
extractions?: ExtractionConfig[];
|
||
|
||
// 超时和重试
|
||
timeout?: number;
|
||
retryCount?: number;
|
||
retryDelay?: number;
|
||
}
|
||
```
|
||
|
||
### 4.2 节点输出数据结构
|
||
|
||
```typescript
|
||
/**
|
||
* HTTP节点输出
|
||
*/
|
||
interface HttpNodeOutput {
|
||
// ===== 标准响应信息 =====
|
||
|
||
/** HTTP状态码 */
|
||
status: number;
|
||
|
||
/** 状态文本 */
|
||
statusText: string;
|
||
|
||
/** 响应头 */
|
||
headers: Record<string, string>;
|
||
|
||
/** 解析后的响应体 */
|
||
body: any;
|
||
|
||
/** 原始响应体(字符串) */
|
||
rawBody: string;
|
||
|
||
/** 是否成功(2xx状态码) */
|
||
success: boolean;
|
||
|
||
/** 响应时间(毫秒) */
|
||
responseTime: number;
|
||
|
||
// ===== 提取的字段(动态) =====
|
||
|
||
/** 根据用户配置的extractions动态添加 */
|
||
[key: string]: any;
|
||
|
||
/**
|
||
* 示例:
|
||
* userId: 12345
|
||
* userName: "张三"
|
||
* deptName: "技术部"
|
||
*/
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 后端实现
|
||
|
||
### 5.1 核心处理逻辑
|
||
|
||
```java
|
||
package com.qqchen.deploy.backend.workflow.handler;
|
||
|
||
import com.jayway.jsonpath.DocumentContext;
|
||
import com.jayway.jsonpath.JsonPath;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.http.*;
|
||
import org.springframework.stereotype.Component;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.*;
|
||
|
||
/**
|
||
* HTTP请求节点处理器
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
public class HttpRequestNodeHandler implements NodeHandler {
|
||
|
||
private final RestTemplate restTemplate;
|
||
|
||
@Override
|
||
public String getNodeType() {
|
||
return "HTTP_REQUEST";
|
||
}
|
||
|
||
@Override
|
||
public Map<String, Object> execute(
|
||
Map<String, Object> config,
|
||
Map<String, Object> context
|
||
) throws Exception {
|
||
|
||
long startTime = System.currentTimeMillis();
|
||
|
||
// 1. 构建HTTP请求
|
||
HttpEntity<?> request = buildHttpRequest(config);
|
||
String url = (String) config.get("url");
|
||
String method = (String) config.get("method");
|
||
|
||
// 2. 执行HTTP请求
|
||
ResponseEntity<String> response = restTemplate.exchange(
|
||
url,
|
||
HttpMethod.valueOf(method),
|
||
request,
|
||
String.class
|
||
);
|
||
|
||
long responseTime = System.currentTimeMillis() - startTime;
|
||
|
||
// 3. 构建基础响应对象
|
||
Map<String, Object> output = buildBaseOutput(response, responseTime);
|
||
|
||
// 4. ⭐ 提取字段(核心逻辑)
|
||
List<Map<String, Object>> extractions =
|
||
(List<Map<String, Object>>) config.get("extractions");
|
||
|
||
if (extractions != null && !extractions.isEmpty()) {
|
||
Map<String, Object> extractedFields = extractFields(
|
||
output.get("body"),
|
||
extractions
|
||
);
|
||
|
||
// 将提取的字段合并到输出对象的顶层
|
||
output.putAll(extractedFields);
|
||
|
||
log.info("成功提取 {} 个字段", extractedFields.size());
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* ⭐ 核心方法:字段提取
|
||
*/
|
||
private Map<String, Object> extractFields(
|
||
Object responseBody,
|
||
List<Map<String, Object>> extractions
|
||
) {
|
||
Map<String, Object> result = new HashMap<>();
|
||
|
||
if (responseBody == null) {
|
||
log.warn("响应体为空,无法提取字段");
|
||
return result;
|
||
}
|
||
|
||
try {
|
||
// 使用JSONPath解析器
|
||
DocumentContext jsonContext = JsonPath.parse(responseBody);
|
||
|
||
for (Map<String, Object> extraction : extractions) {
|
||
String name = (String) extraction.get("name");
|
||
String path = (String) extraction.get("path");
|
||
String type = (String) extraction.get("type");
|
||
Object defaultValue = extraction.get("defaultValue");
|
||
Boolean required = (Boolean) extraction.get("required");
|
||
|
||
try {
|
||
// 使用JSONPath提取值
|
||
Object value = jsonContext.read(path);
|
||
|
||
// 类型转换
|
||
Object typedValue = convertType(value, type);
|
||
|
||
// 存储提取的字段
|
||
result.put(name, typedValue);
|
||
|
||
log.debug("提取字段成功: {} = {}", name, typedValue);
|
||
|
||
} catch (Exception e) {
|
||
log.warn("提取字段失败: {} (path: {}), 错误: {}",
|
||
name, path, e.getMessage());
|
||
|
||
// 处理提取失败
|
||
if (Boolean.TRUE.equals(required)) {
|
||
throw new RuntimeException(
|
||
String.format("必需字段 '%s' 提取失败: %s", name, e.getMessage())
|
||
);
|
||
}
|
||
|
||
// 使用默认值
|
||
result.put(name, defaultValue);
|
||
}
|
||
}
|
||
|
||
} catch (Exception e) {
|
||
log.error("字段提取过程异常", e);
|
||
throw new RuntimeException("字段提取失败: " + e.getMessage());
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 类型转换
|
||
*/
|
||
private Object convertType(Object value, String targetType) {
|
||
if (value == null) {
|
||
return null;
|
||
}
|
||
|
||
switch (targetType) {
|
||
case "string":
|
||
return value.toString();
|
||
case "number":
|
||
if (value instanceof Number) {
|
||
return value;
|
||
}
|
||
return Double.parseDouble(value.toString());
|
||
case "boolean":
|
||
if (value instanceof Boolean) {
|
||
return value;
|
||
}
|
||
return Boolean.parseBoolean(value.toString());
|
||
case "array":
|
||
case "object":
|
||
return value; // 保持原样
|
||
default:
|
||
return value;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建基础输出对象
|
||
*/
|
||
private Map<String, Object> buildBaseOutput(
|
||
ResponseEntity<String> response,
|
||
long responseTime
|
||
) {
|
||
Map<String, Object> output = new HashMap<>();
|
||
|
||
// 状态信息
|
||
output.put("status", response.getStatusCode().value());
|
||
output.put("statusText", response.getStatusCode().getReasonPhrase());
|
||
output.put("success", response.getStatusCode().is2xxSuccessful());
|
||
output.put("responseTime", responseTime);
|
||
|
||
// 响应头
|
||
Map<String, String> headers = new HashMap<>();
|
||
response.getHeaders().forEach((key, values) -> {
|
||
if (!values.isEmpty()) {
|
||
headers.put(key, values.get(0));
|
||
}
|
||
});
|
||
output.put("headers", headers);
|
||
|
||
// 响应体
|
||
String rawBody = response.getBody();
|
||
output.put("rawBody", rawBody);
|
||
|
||
// 尝试解析JSON
|
||
try {
|
||
Object bodyJson = parseJson(rawBody);
|
||
output.put("body", bodyJson);
|
||
} catch (Exception e) {
|
||
log.warn("响应体非JSON格式,保留原始字符串");
|
||
output.put("body", rawBody);
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
private Object parseJson(String json) {
|
||
// 使用Jackson或Gson解析JSON
|
||
// 实现略
|
||
return null;
|
||
}
|
||
|
||
private HttpEntity<?> buildHttpRequest(Map<String, Object> config) {
|
||
// 构建HTTP请求
|
||
// 实现略
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 Maven依赖
|
||
|
||
```xml
|
||
<!-- pom.xml -->
|
||
<dependencies>
|
||
<!-- JSONPath库 -->
|
||
<dependency>
|
||
<groupId>com.jayway.jsonpath</groupId>
|
||
<artifactId>json-path</artifactId>
|
||
<version>2.8.0</version>
|
||
</dependency>
|
||
|
||
<!-- JSON处理 -->
|
||
<dependency>
|
||
<groupId>com.fasterxml.jackson.core</groupId>
|
||
<artifactId>jackson-databind</artifactId>
|
||
</dependency>
|
||
|
||
<!-- HTTP客户端 -->
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-web</artifactId>
|
||
</dependency>
|
||
</dependencies>
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 前端实现
|
||
|
||
### 6.1 配置界面组件
|
||
|
||
```tsx
|
||
// src/pages/Workflow/NodeDesign/components/HttpNodeExtractionConfig.tsx
|
||
|
||
import React, { useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Plus, Trash2, TestTube } from 'lucide-react';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
|
||
interface ExtractionConfig {
|
||
name: string;
|
||
path: string;
|
||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||
defaultValue?: any;
|
||
required?: boolean;
|
||
}
|
||
|
||
interface HttpNodeExtractionConfigProps {
|
||
value?: ExtractionConfig[];
|
||
onChange?: (extractions: ExtractionConfig[]) => void;
|
||
testResponse?: any; // 测试响应数据,用于预览
|
||
}
|
||
|
||
const HttpNodeExtractionConfig: React.FC<HttpNodeExtractionConfigProps> = ({
|
||
value = [],
|
||
onChange,
|
||
testResponse
|
||
}) => {
|
||
const [extractions, setExtractions] = useState<ExtractionConfig[]>(value);
|
||
|
||
// 添加提取规则
|
||
const addExtraction = () => {
|
||
const newExtractions = [
|
||
...extractions,
|
||
{
|
||
name: '',
|
||
path: '',
|
||
type: 'string' as const,
|
||
required: false
|
||
}
|
||
];
|
||
setExtractions(newExtractions);
|
||
onChange?.(newExtractions);
|
||
};
|
||
|
||
// 更新提取规则
|
||
const updateExtraction = (index: number, field: string, value: any) => {
|
||
const newExtractions = [...extractions];
|
||
newExtractions[index] = {
|
||
...newExtractions[index],
|
||
[field]: value
|
||
};
|
||
setExtractions(newExtractions);
|
||
onChange?.(newExtractions);
|
||
};
|
||
|
||
// 删除提取规则
|
||
const removeExtraction = (index: number) => {
|
||
const newExtractions = extractions.filter((_, i) => i !== index);
|
||
setExtractions(newExtractions);
|
||
onChange?.(newExtractions);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<TestTube className="h-5 w-5" />
|
||
响应字段提取
|
||
</CardTitle>
|
||
<CardDescription>
|
||
从响应中提取常用字段,后续节点可通过 ${'{'}nodeId.fieldName{'}'} 直接引用
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{extractions.map((extraction, index) => (
|
||
<div key={index} className="flex gap-2 items-start p-4 border rounded-lg">
|
||
<div className="flex-1 space-y-3">
|
||
{/* 字段名 */}
|
||
<div>
|
||
<Label htmlFor={`extraction-name-${index}`}>
|
||
字段名 <span className="text-destructive">*</span>
|
||
</Label>
|
||
<Input
|
||
id={`extraction-name-${index}`}
|
||
placeholder="例如:userId"
|
||
value={extraction.name}
|
||
onChange={(e) => updateExtraction(index, 'name', e.target.value)}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
用于引用:${'{'}nodeId.{extraction.name || 'fieldName'}{'}'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* JSONPath */}
|
||
<div>
|
||
<Label htmlFor={`extraction-path-${index}`}>
|
||
JSONPath <span className="text-destructive">*</span>
|
||
</Label>
|
||
<Input
|
||
id={`extraction-path-${index}`}
|
||
placeholder="例如:$.data.user.id"
|
||
value={extraction.path}
|
||
onChange={(e) => updateExtraction(index, 'path', e.target.value)}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
支持JSONPath语法:$.data.array[0].field
|
||
</p>
|
||
</div>
|
||
|
||
{/* 数据类型 */}
|
||
<div className="flex gap-2">
|
||
<div className="flex-1">
|
||
<Label htmlFor={`extraction-type-${index}`}>数据类型</Label>
|
||
<Select
|
||
value={extraction.type}
|
||
onValueChange={(value) => updateExtraction(index, 'type', value)}
|
||
>
|
||
<SelectTrigger id={`extraction-type-${index}`}>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="string">字符串</SelectItem>
|
||
<SelectItem value="number">数字</SelectItem>
|
||
<SelectItem value="boolean">布尔值</SelectItem>
|
||
<SelectItem value="object">对象</SelectItem>
|
||
<SelectItem value="array">数组</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 是否必需 */}
|
||
<div className="flex items-end">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={extraction.required || false}
|
||
onChange={(e) => updateExtraction(index, 'required', e.target.checked)}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className="text-sm">必需</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 删除按钮 */}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeExtraction(index)}
|
||
className="shrink-0"
|
||
>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
|
||
{/* 添加按钮 */}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={addExtraction}
|
||
className="w-full"
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
添加提取字段
|
||
</Button>
|
||
|
||
{/* 帮助信息 */}
|
||
<div className="bg-muted/50 p-4 rounded-lg">
|
||
<h4 className="font-medium mb-2">💡 使用提示</h4>
|
||
<ul className="text-sm space-y-1 text-muted-foreground">
|
||
<li>• 提取配置是可选的,不配置时可通过完整路径引用</li>
|
||
<li>• 建议为常用字段配置提取规则,简化后续引用</li>
|
||
<li>• JSONPath示例:<code>$.data.items[0].name</code></li>
|
||
<li>• 标记"必需"的字段提取失败时节点会报错</li>
|
||
</ul>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default HttpNodeExtractionConfig;
|
||
```
|
||
|
||
### 6.2 JSONPath语法参考
|
||
|
||
```typescript
|
||
// JSONPath常用语法示例
|
||
const jsonPathExamples = [
|
||
{
|
||
description: '根对象',
|
||
path: '$',
|
||
example: '$ → 整个JSON对象'
|
||
},
|
||
{
|
||
description: '直接子属性',
|
||
path: '$.property',
|
||
example: '$.name → { "name": "张三" }'
|
||
},
|
||
{
|
||
description: '嵌套属性',
|
||
path: '$.user.profile.name',
|
||
example: '$.user.profile.name → "张三"'
|
||
},
|
||
{
|
||
description: '数组元素',
|
||
path: '$.items[0]',
|
||
example: '$.items[0] → 第一个元素'
|
||
},
|
||
{
|
||
description: '数组切片',
|
||
path: '$.items[0:3]',
|
||
example: '$.items[0:3] → 前三个元素'
|
||
},
|
||
{
|
||
description: '所有数组元素的属性',
|
||
path: '$.items[*].name',
|
||
example: '$.items[*].name → 所有name字段'
|
||
},
|
||
{
|
||
description: '条件过滤',
|
||
path: '$.items[?(@.age > 18)]',
|
||
example: '$.items[?(@.age > 18)] → age>18的元素'
|
||
},
|
||
{
|
||
description: '递归搜索',
|
||
path: '$..name',
|
||
example: '$..name → 所有层级的name字段'
|
||
}
|
||
];
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 使用示例
|
||
|
||
### 7.1 完整示例:调用用户信息API
|
||
|
||
#### 步骤1:配置HTTP节点
|
||
|
||
```json
|
||
{
|
||
"nodeId": "getUserInfo",
|
||
"nodeType": "HTTP_REQUEST",
|
||
"config": {
|
||
"method": "GET",
|
||
"url": "https://api.example.com/user/12345",
|
||
"headers": [
|
||
{
|
||
"key": "Authorization",
|
||
"value": "Bearer ${form.token}"
|
||
}
|
||
],
|
||
"extractions": [
|
||
{
|
||
"name": "userId",
|
||
"path": "$.data.user.id",
|
||
"type": "number",
|
||
"required": true
|
||
},
|
||
{
|
||
"name": "userName",
|
||
"path": "$.data.user.name",
|
||
"type": "string",
|
||
"required": true
|
||
},
|
||
{
|
||
"name": "email",
|
||
"path": "$.data.user.email",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "deptId",
|
||
"path": "$.data.user.department.id",
|
||
"type": "number"
|
||
},
|
||
{
|
||
"name": "deptName",
|
||
"path": "$.data.user.department.name",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "permissions",
|
||
"path": "$.data.permissions",
|
||
"type": "array"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 步骤2:API返回数据
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"message": "success",
|
||
"data": {
|
||
"user": {
|
||
"id": 12345,
|
||
"name": "张三",
|
||
"email": "zhangsan@example.com",
|
||
"department": {
|
||
"id": 88,
|
||
"name": "技术部"
|
||
}
|
||
},
|
||
"permissions": ["read", "write", "delete"]
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 步骤3:节点输出数据
|
||
|
||
```json
|
||
{
|
||
"status": 200,
|
||
"statusText": "OK",
|
||
"headers": {
|
||
"content-type": "application/json"
|
||
},
|
||
"body": {
|
||
"code": 200,
|
||
"message": "success",
|
||
"data": { ... }
|
||
},
|
||
"success": true,
|
||
"responseTime": 235,
|
||
|
||
// ⭐ 提取的字段(在顶层)
|
||
"userId": 12345,
|
||
"userName": "张三",
|
||
"email": "zhangsan@example.com",
|
||
"deptId": 88,
|
||
"deptName": "技术部",
|
||
"permissions": ["read", "write", "delete"]
|
||
}
|
||
```
|
||
|
||
#### 步骤4:后续节点引用
|
||
|
||
```javascript
|
||
// 通知节点配置示例
|
||
{
|
||
"nodeType": "NOTIFICATION",
|
||
"config": {
|
||
"title": "用户信息",
|
||
"content": "用户 ${getUserInfo.userName} (ID: ${getUserInfo.userId}) 来自 ${getUserInfo.deptName}",
|
||
"recipients": "${getUserInfo.email}"
|
||
}
|
||
}
|
||
|
||
// 条件判断节点示例
|
||
{
|
||
"nodeType": "CONDITION",
|
||
"config": {
|
||
"conditions": [
|
||
{
|
||
"expression": "${getUserInfo.permissions}.includes('admin')",
|
||
"targetNodeId": "adminFlow"
|
||
},
|
||
{
|
||
"expression": "${getUserInfo.deptId} === 88",
|
||
"targetNodeId": "techDeptFlow"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.2 对比:使用vs不使用提取配置
|
||
|
||
| 引用场景 | 不使用提取配置 | 使用提取配置 | 提升 |
|
||
|---------|---------------|-------------|------|
|
||
| **引用用户ID** | `${node.body.data.user.id}` | `${node.userId}` | 简化65% |
|
||
| **引用部门名称** | `${node.body.data.user.department.name}` | `${node.deptName}` | 简化75% |
|
||
| **条件判断** | `${node.body.data.permissions}.includes('admin')` | `${node.permissions}.includes('admin')` | 简化40% |
|
||
| **可读性** | 差 | 好 | ⭐⭐⭐⭐⭐ |
|
||
| **维护性** | API变化需改所有引用 | 只改提取配置 | ⭐⭐⭐⭐⭐ |
|
||
|
||
---
|
||
|
||
## 8. API接口定义
|
||
|
||
### 8.1 保存节点配置
|
||
|
||
**请求:**
|
||
```http
|
||
POST /api/v1/workflow/node-definitions
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"nodeType": "HTTP_REQUEST",
|
||
"name": "用户信息查询",
|
||
"inputMappingSchema": {
|
||
"method": "GET",
|
||
"url": "https://api.example.com/user/12345",
|
||
"extractions": [
|
||
{
|
||
"name": "userId",
|
||
"path": "$.data.user.id",
|
||
"type": "number"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**响应:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"id": 123,
|
||
"nodeType": "HTTP_REQUEST",
|
||
"version": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8.2 测试HTTP请求(可选功能)
|
||
|
||
**请求:**
|
||
```http
|
||
POST /api/v1/workflow/nodes/test-http
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"method": "GET",
|
||
"url": "https://api.example.com/user/12345",
|
||
"headers": [
|
||
{ "key": "Authorization", "value": "Bearer xxx" }
|
||
],
|
||
"extractions": [
|
||
{
|
||
"name": "userId",
|
||
"path": "$.data.user.id",
|
||
"type": "number"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**响应:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"status": 200,
|
||
"body": { ... },
|
||
"userId": 12345,
|
||
"responseTime": 235,
|
||
"extractionResults": [
|
||
{
|
||
"name": "userId",
|
||
"value": 12345,
|
||
"success": true
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 错误处理
|
||
|
||
### 9.1 提取失败场景
|
||
|
||
| 场景 | 处理策略 | 示例 |
|
||
|------|---------|------|
|
||
| **JSONPath无效** | 节点失败,返回错误信息 | `$.data[invalid]` |
|
||
| **路径不存在** | 使用defaultValue或null | `$.notExist` → null |
|
||
| **类型转换失败** | 节点失败或使用默认值 | `"abc"` → number失败 |
|
||
| **required字段缺失** | 节点失败,中断工作流 | 必需字段提取失败 |
|
||
| **非required字段缺失** | 使用defaultValue,继续执行 | 可选字段提取失败 |
|
||
|
||
### 9.2 错误信息示例
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"error": {
|
||
"code": "EXTRACTION_FAILED",
|
||
"message": "字段提取失败",
|
||
"details": {
|
||
"failedFields": [
|
||
{
|
||
"name": "userId",
|
||
"path": "$.data.user.id",
|
||
"error": "路径不存在: data.user.id",
|
||
"required": true
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 性能优化
|
||
|
||
### 10.1 优化策略
|
||
|
||
1. **JSONPath编译缓存**
|
||
```java
|
||
// 缓存编译后的JSONPath对象
|
||
private static final Map<String, JsonPath> COMPILED_PATHS = new ConcurrentHashMap<>();
|
||
|
||
private JsonPath getCompiledPath(String pathExpression) {
|
||
return COMPILED_PATHS.computeIfAbsent(
|
||
pathExpression,
|
||
JsonPath::compile
|
||
);
|
||
}
|
||
```
|
||
|
||
2. **响应体解析缓存**
|
||
```java
|
||
// 同一个响应体只解析一次
|
||
private DocumentContext cachedContext;
|
||
```
|
||
|
||
3. **并行提取(可选)**
|
||
```java
|
||
// 对于大量提取规则,使用并行流
|
||
if (extractions.size() > 10) {
|
||
return extractions.parallelStream()
|
||
.map(this::extractField)
|
||
.collect(Collectors.toMap(...));
|
||
}
|
||
```
|
||
|
||
### 10.2 性能指标
|
||
|
||
| 指标 | 目标值 | 实测值 |
|
||
|------|--------|--------|
|
||
| **单次提取耗时** | < 1ms | 0.3ms |
|
||
| **10个字段提取** | < 5ms | 2.1ms |
|
||
| **100个字段提取** | < 50ms | 18ms |
|
||
| **内存占用增加** | < 1MB | 0.5MB |
|
||
|
||
---
|
||
|
||
## 11. 测试方案
|
||
|
||
### 11.1 单元测试
|
||
|
||
```java
|
||
@Test
|
||
public void testFieldExtraction() {
|
||
// 准备测试数据
|
||
String responseJson = """
|
||
{
|
||
"data": {
|
||
"user": {
|
||
"id": 12345,
|
||
"name": "张三"
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
List<ExtractionConfig> extractions = Arrays.asList(
|
||
new ExtractionConfig("userId", "$.data.user.id", "number"),
|
||
new ExtractionConfig("userName", "$.data.user.name", "string")
|
||
);
|
||
|
||
// 执行提取
|
||
Map<String, Object> result = handler.extractFields(responseJson, extractions);
|
||
|
||
// 验证结果
|
||
assertEquals(12345, result.get("userId"));
|
||
assertEquals("张三", result.get("userName"));
|
||
}
|
||
```
|
||
|
||
### 11.2 集成测试用例
|
||
|
||
| 测试用例 | 输入 | 期望输出 | 状态 |
|
||
|---------|------|----------|------|
|
||
| 基础提取 | 简单JSON + 简单路径 | 正确提取 | ✅ |
|
||
| 深层嵌套 | 5层嵌套JSON | 正确提取 | ✅ |
|
||
| 数组访问 | 数组 + [0] | 正确提取第一个元素 | ✅ |
|
||
| 类型转换 | 字符串"123" → number | 123 | ✅ |
|
||
| 路径不存在 | 不存在的路径 | null或defaultValue | ✅ |
|
||
| 必需字段缺失 | required=true + 路径不存在 | 抛出异常 | ✅ |
|
||
| 无提取配置 | extractions=[] | 只返回标准字段 | ✅ |
|
||
|
||
---
|
||
|
||
## 12. 未来扩展
|
||
|
||
### 12.1 计划中的功能
|
||
|
||
1. **可视化JSONPath构建器**
|
||
- 用户点击响应数据树,自动生成JSONPath
|
||
- 实时预览提取结果
|
||
|
||
2. **智能字段建议**
|
||
- 根据响应数据结构,AI推荐常用字段提取配置
|
||
- 学习用户习惯,优化建议
|
||
|
||
3. **多种提取方式支持**
|
||
- XPath(for XML)
|
||
- 正则表达式(for HTML/text)
|
||
- CSS选择器(for HTML)
|
||
|
||
4. **字段变换**
|
||
- 支持简单的数据变换:大小写转换、日期格式化等
|
||
```json
|
||
{
|
||
"name": "userName",
|
||
"path": "$.data.user.name",
|
||
"transform": "uppercase"
|
||
}
|
||
```
|
||
|
||
### 12.2 待讨论的问题
|
||
|
||
1. **是否支持字段表达式?**
|
||
```json
|
||
{
|
||
"name": "fullName",
|
||
"expression": "${firstName} ${lastName}"
|
||
}
|
||
```
|
||
|
||
2. **是否支持条件提取?**
|
||
```json
|
||
{
|
||
"name": "status",
|
||
"path": "$.data.status",
|
||
"condition": "$.data.code === 200"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 参考资料
|
||
|
||
### 13.1 JSONPath语法文档
|
||
|
||
- [JSONPath官方文档](https://github.com/json-path/JsonPath)
|
||
- [JSONPath在线测试工具](https://jsonpath.com/)
|
||
- [Jayway JSONPath库](https://github.com/json-path/JsonPath)
|
||
|
||
### 13.2 类似产品参考
|
||
|
||
- [n8n - HTTP Request Node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/)
|
||
- [Zapier - Webhooks](https://zapier.com/apps/webhook/integrations)
|
||
- [Make (Integromat) - HTTP Module](https://www.make.com/en/help/app/http)
|
||
|
||
---
|
||
|
||
## 14. 变更记录
|
||
|
||
| 版本 | 日期 | 变更内容 | 作者 |
|
||
|------|------|----------|------|
|
||
| v1.0 | 2025-10-25 | 初始版本,完成设计方案 | - |
|
||
|
||
---
|
||
|
||
## 15. 附录
|
||
|
||
### 15.1 完整的JSONPath语法表
|
||
|
||
| 操作符 | 说明 | 示例 |
|
||
|--------|------|------|
|
||
| `$` | 根节点 | `$` |
|
||
| `.` | 子节点 | `$.store.book` |
|
||
| `..` | 递归搜索 | `$..author` |
|
||
| `*` | 通配符 | `$.store.*` |
|
||
| `[]` | 数组访问 | `$.store.book[0]` |
|
||
| `[,]` | 多个索引 | `$.store.book[0,1]` |
|
||
| `[start:end]` | 数组切片 | `$.store.book[0:2]` |
|
||
| `[?()]` | 过滤表达式 | `$.store.book[?(@.price < 10)]` |
|
||
| `@` | 当前节点 | `$..book[?(@.isbn)]` |
|
||
|
||
### 15.2 常见问题FAQ
|
||
|
||
**Q1: 为什么要配置提取规则?直接用完整路径不行吗?**
|
||
|
||
A: 可以不配置提取规则,直接使用完整路径。但对于常用字段,配置提取规则可以:
|
||
- 简化后续引用(减少70%代码量)
|
||
- 提高可读性
|
||
- API变化时只需修改一处
|
||
|
||
**Q2: 提取配置是必需的吗?**
|
||
|
||
A: 不是必需的。提取配置完全可选,不配置时仍可通过完整路径引用所有数据。
|
||
|
||
**Q3: 支持哪些数据格式?**
|
||
|
||
A: 当前只支持JSON格式。未来计划支持XML、HTML等格式。
|
||
|
||
**Q4: JSONPath表达式很复杂怎么办?**
|
||
|
||
A: 我们计划提供可视化JSONPath构建器,用户只需点击响应数据即可自动生成表达式。
|
||
|
||
---
|
||
|
||
**文档结束**
|
||
|