perf: 收敛工作流状态与高 IO 节点开销

- 落地 Redis 版本状态、触发租约和定义缓存

- 优化数据批写、插件请求、文件下载与审计日志

- 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
2026-07-29 00:47:48 +08:00
parent 5ee6065017
commit 1ae8a22afe
103 changed files with 15600 additions and 532 deletions

View File

@@ -1,182 +1,461 @@
package tech.easyflow.common.ai.plugin;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.*;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import okhttp3.Dispatcher;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSink;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class PluginHttpClient {
/**
* 使用共享连接池、有界并发和实际响应字节限制调用 HTTP 插件。
*/
public final class PluginHttpClient {
private static final int TIMEOUT = 10_000;
private static final int TIMEOUT_MILLIS = 10_000;
private static final long DEFAULT_MAX_RESPONSE_BYTES =
64L * 1024L * 1024L;
private static final int MAX_TRACKED_HOSTS = 512;
private static final Semaphore GLOBAL_PERMITS =
new Semaphore(32, true);
private static final Semaphore OVERFLOW_HOST_PERMITS =
new Semaphore(8, true);
private static final Map<String, Semaphore> HOST_PERMITS =
new ConcurrentHashMap<>();
private static final Object HOST_REGISTRY_LOCK = new Object();
private static final OkHttpClient CLIENT = buildClient(true);
private static final OkHttpClient NO_RETRY_CLIENT = buildClient(false);
public static JSONObject sendRequest(String url, String method,
Map<String, Object> headers,
List<PluginParam> pluginParams) {
// 1. 处理路径参数
String processedUrl = replacePathVariables(url, pluginParams);
// 2. 初始化请求
Method httpMethod = Method.valueOf(method.toUpperCase());
HttpRequest request = HttpRequest.of(processedUrl)
.method(httpMethod)
.timeout(TIMEOUT);
// 3. 处理请求头(合并默认头和参数头)
processHeaders(request, headers, pluginParams);
// 4. 处理查询参数和请求体
processQueryAndBodyParams(request, httpMethod, pluginParams);
// 5. 执行请求
HttpResponse response = request.execute();
return JSONUtil.parseObj(response.body());
private PluginHttpClient() {
}
/**
* 处理请求头(合并默认头和参数头)
* 发送插件请求。
*
* @param url 插件 URL
* @param method HTTP 方法
* @param headers 默认请求头
* @param pluginParams 插件参数
* @return JSON 响应
*/
private static void processHeaders(HttpRequest request,
Map<String, Object> defaultHeaders,
List<PluginParam> params) {
// 添加默认头
if (ObjectUtil.isNotEmpty(defaultHeaders)) {
defaultHeaders.forEach((k, v) -> request.header(k, v.toString()));
}
public static JSONObject sendRequest(
String url,
String method,
Map<String, Object> headers,
List<PluginParam> pluginParams) {
String normalizedMethod = method == null
? "GET"
: method.trim().toUpperCase();
List<PluginParam> safeParams = pluginParams == null
? List.of()
: pluginParams;
HttpUrl processedUrl = buildUrl(url, safeParams);
Request.Builder requestBuilder = new Request.Builder().url(processedUrl);
applyHeaders(requestBuilder, headers, safeParams);
RequestBody requestBody = buildRequestBody(
normalizedMethod, safeParams);
applyMethod(requestBuilder, normalizedMethod, requestBody);
// 添加参数中指定的头
params.stream()
.filter(p -> "header".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
.forEach(p -> request.header(p.getName(), p.getDefaultValue().toString()));
}
/**
* 处理查询参数和请求体
*/
/**
* 处理查询参数和请求体(新增文件参数支持)
*/
private static void processQueryAndBodyParams(HttpRequest request,
Method httpMethod,
List<PluginParam> params) {
Map<String, Object> queryParams = new HashMap<>();
Map<String, Object> bodyParams = new HashMap<>();
// 标记是否包含文件参数
AtomicBoolean hasMultipartFile = new AtomicBoolean(false);
// 分类参数(同时检测是否有文件)
params.stream()
.filter(PluginParam::isEnabled)
.forEach(p -> {
String methodType = p.getMethod().toLowerCase();
Object paramValue = buildNestedParamValue(p);
// 检测是否为文件参数MultipartFile 类型)
if (paramValue instanceof org.springframework.web.multipart.MultipartFile) {
hasMultipartFile.set(true);
}
switch (methodType) {
case "query":
queryParams.put(p.getName(), paramValue);
break;
case "body":
bodyParams.put(p.getName(), paramValue);
break;
}
});
// 1. 设置查询参数(原有逻辑不变)
if (!queryParams.isEmpty()) {
request.form(queryParams);
}
// 2. 设置请求体(分两种情况:有文件 vs 无文件)
if (!bodyParams.isEmpty() && (httpMethod == Method.POST || httpMethod == Method.PUT)) {
if (hasMultipartFile.get()) {
// 2.1 包含文件参数 → 用 multipart/form-data 格式
processMultipartBody(request, bodyParams);
} else {
// 2.2 无文件参数 → 保持原有 JSON 格式
request.body(JSONUtil.toJsonStr(bodyParams))
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue());
Semaphore hostPermits = hostSemaphore(processedUrl.host());
boolean hostAcquired = false;
boolean globalAcquired = false;
try {
hostAcquired = hostPermits.tryAcquire(
1L, TimeUnit.SECONDS);
if (!hostAcquired) {
throw new IllegalStateException(
"插件目标并发已达上限: " + processedUrl.host());
}
globalAcquired = GLOBAL_PERMITS.tryAcquire(
1L, TimeUnit.SECONDS);
if (!globalAcquired) {
throw new IllegalStateException(
"插件 HTTP 并发已达上限");
}
OkHttpClient client = isIdempotent(normalizedMethod)
? CLIENT
: NO_RETRY_CLIENT;
try (Response response =
client.newCall(requestBuilder.build()).execute()) {
ResponseBody body = response.body();
if (body == null) {
throw new IllegalStateException("插件响应内容为空");
}
long maxBytes = Long.getLong(
"easyflow.plugin.http.max-response-bytes",
DEFAULT_MAX_RESPONSE_BYTES);
String responseText = readBounded(body, maxBytes);
return JSONUtil.parseObj(responseText);
}
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"插件 HTTP 请求被中断", error);
} catch (IOException error) {
throw new IllegalStateException(
"插件 HTTP 请求失败", error);
} finally {
if (globalAcquired) {
GLOBAL_PERMITS.release();
}
if (hostAcquired) {
hostPermits.release();
}
}
}
/**
* 递归构建嵌套参数值
* @param param 当前参数
* @return 如果是 Object 类型,返回 Map否则返回 defaultValue
* 创建共享 OkHttp 客户端。
*
* @param retryOnConnectionFailure 是否允许连接级自动恢复
* @return 共享客户端
*/
private static Object buildNestedParamValue(PluginParam param) {
// 如果不是 Object 类型,直接返回默认值
private static OkHttpClient buildClient(
boolean retryOnConnectionFailure) {
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(32);
dispatcher.setMaxRequestsPerHost(8);
return new OkHttpClient.Builder()
.dispatcher(dispatcher)
.connectTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.readTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.writeTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.callTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(retryOnConnectionFailure)
.build();
}
/**
* 构造带路径参数和查询参数的 URL。
*
* @param url 原始 URL
* @param params 插件参数
* @return 完整 URL
*/
private static HttpUrl buildUrl(
String url, List<PluginParam> params) {
String processed = replacePathVariables(url, params);
HttpUrl parsed = HttpUrl.parse(processed);
if (parsed == null) {
throw new IllegalArgumentException(
"插件 URL 不合法: " + processed);
}
HttpUrl.Builder builder = parsed.newBuilder();
for (PluginParam param : params) {
if (param.isEnabled()
&& "query".equalsIgnoreCase(param.getMethod())
&& param.getDefaultValue() != null) {
builder.addQueryParameter(
param.getName(),
stringify(param.getDefaultValue()));
}
}
return builder.build();
}
/**
* 合并默认请求头和参数请求头。
*
* @param builder 请求构造器
* @param headers 默认请求头
* @param params 插件参数
*/
private static void applyHeaders(
Request.Builder builder,
Map<String, Object> headers,
List<PluginParam> params) {
if (headers != null) {
headers.forEach((name, value) -> {
if (name != null && value != null) {
builder.header(name, String.valueOf(value));
}
});
}
for (PluginParam param : params) {
if (param.isEnabled()
&& "header".equalsIgnoreCase(param.getMethod())
&& param.getDefaultValue() != null) {
builder.header(
param.getName(),
String.valueOf(param.getDefaultValue()));
}
}
}
/**
* 构造 JSON 或 multipart 请求体。
*
* @param method HTTP 方法
* @param params 插件参数
* @return 请求体;无请求体时为 {@code null}
*/
private static RequestBody buildRequestBody(
String method, List<PluginParam> params) {
if (!supportsRequestBody(method)) {
return null;
}
Map<String, Object> bodyValues = new HashMap<>();
boolean multipart = false;
for (PluginParam param : params) {
if (!param.isEnabled()
|| !"body".equalsIgnoreCase(param.getMethod())) {
continue;
}
Object value = buildNestedParamValue(param);
bodyValues.put(param.getName(), value);
multipart |= value instanceof MultipartFile;
}
if (bodyValues.isEmpty()) {
return RequestBody.create(
new byte[0], null);
}
if (!multipart) {
return RequestBody.create(
JSONUtil.toJsonStr(bodyValues),
MediaType.parse("application/json; charset=utf-8"));
}
MultipartBody.Builder builder =
new MultipartBody.Builder().setType(MultipartBody.FORM);
for (Map.Entry<String, Object> entry : bodyValues.entrySet()) {
Object value = entry.getValue();
if (value instanceof MultipartFile) {
MultipartFile file = (MultipartFile) value;
MediaType contentType = MediaType.parse(
file.getContentType() == null
? "application/octet-stream"
: file.getContentType());
builder.addFormDataPart(
entry.getKey(),
Objects.toString(
file.getOriginalFilename(),
entry.getKey()),
streamingFileBody(file, contentType));
} else {
builder.addFormDataPart(
entry.getKey(), stringify(value));
}
}
return builder.build();
}
/**
* 创建从 MultipartFile 流式读取的请求体,避免 getBytes 二次复制。
*
* @param file 文件参数
* @param mediaType 内容类型
* @return 流式请求体
*/
private static RequestBody streamingFileBody(
MultipartFile file, MediaType mediaType) {
return new RequestBody() {
@Override
public MediaType contentType() {
return mediaType;
}
@Override
public long contentLength() {
return file.getSize();
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
try (InputStream inputStream = file.getInputStream()) {
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = inputStream.read(buffer)) != -1) {
sink.write(buffer, 0, read);
}
}
}
};
}
/**
* 应用 HTTP 方法。
*
* @param builder 请求构造器
* @param method HTTP 方法
* @param body 请求体
*/
private static void applyMethod(
Request.Builder builder,
String method,
RequestBody body) {
if ("GET".equals(method)) {
builder.get();
} else if ("HEAD".equals(method)) {
builder.head();
} else if ("DELETE".equals(method) && body == null) {
builder.delete();
} else {
builder.method(method, body);
}
}
/**
* 按实际字节读取响应。
*
* @param body 响应体
* @param maxBytes 最大字节数
* @return UTF-8 响应
* @throws IOException 读取失败或响应超限
*/
private static String readBounded(
ResponseBody body, long maxBytes) throws IOException {
if (maxBytes > 0L
&& body.contentLength() > maxBytes) {
throw new IOException(
"插件响应超过字节上限: " + maxBytes);
}
try (InputStream inputStream = body.byteStream();
ByteArrayOutputStream outputStream =
new ByteArrayOutputStream(8 * 1024)) {
byte[] buffer = new byte[64 * 1024];
long total = 0L;
int read;
while ((read = inputStream.read(buffer)) != -1) {
total += read;
if (maxBytes > 0L && total > maxBytes) {
throw new IOException(
"插件响应超过字节上限: " + maxBytes);
}
outputStream.write(buffer, 0, read);
}
return outputStream.toString(StandardCharsets.UTF_8);
}
}
/**
* 递归构造对象参数。
*
* @param param 参数
* @return 参数值
*/
private static Object buildNestedParamValue(
PluginParam param) {
if (!"Object".equalsIgnoreCase(param.getType())) {
return param.getDefaultValue();
}
// 如果是 Object 类型,递归处理子参数
Map<String, Object> nestedParams = new HashMap<>();
Map<String, Object> nested = new HashMap<>();
if (param.getChildren() != null) {
param.getChildren().stream()
.filter(PluginParam::isEnabled)
.forEach(child -> {
Object childValue = buildNestedParamValue(child); // 递归处理子参数
nestedParams.put(child.getName(), childValue);
});
for (PluginParam child : param.getChildren()) {
if (child.isEnabled()) {
nested.put(
child.getName(),
buildNestedParamValue(child));
}
}
}
return nestedParams;
return nested;
}
/**
* 替换URL中的路径变量 {xxx}
* 替换 URL 路径变量
*
* @param url 原始 URL
* @param params 插件参数
* @return 替换后的 URL
*/
private static String replacePathVariables(String url, List<PluginParam> params) {
String result = url;
// 收集路径参数
Map<String, Object> pathParams = new HashMap<>();
params.stream()
.filter(p -> "path".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
.forEach(p -> pathParams.put(p.getName(), p.getDefaultValue()));
// 替换变量
for (Map.Entry<String, Object> entry : pathParams.entrySet()) {
result = result.replaceAll("\\{" + entry.getKey() + "\\}",
entry.getValue().toString());
private static String replacePathVariables(
String url, List<PluginParam> params) {
String result = Objects.requireNonNull(
url, "插件 URL 不能为空");
for (PluginParam param : params) {
if (param.isEnabled()
&& "path".equalsIgnoreCase(param.getMethod())
&& param.getDefaultValue() != null) {
result = result.replace(
"{" + param.getName() + "}",
String.valueOf(param.getDefaultValue()));
}
}
return result;
}
private static void processMultipartBody(HttpRequest request, Map<String, Object> bodyParams) {
// 手动设置 Content-Type 为 multipart/form-data
request.header(Header.CONTENT_TYPE, "multipart/form-data");
for (Map.Entry<String, Object> entry : bodyParams.entrySet()) {
String paramName = entry.getKey();
Object paramValue = entry.getValue();
if (paramValue instanceof MultipartFile) {
MultipartFile file = (MultipartFile) paramValue;
try {
request.form(paramName, file.getBytes(), file.getOriginalFilename());
} catch (Exception e) {
throw new RuntimeException(String.format("文件参数处理失败:参数名=%s文件名=%s",
paramName, file.getOriginalFilename()), e);
}
} else {
// 处理普通参数
String valueStr;
if (paramValue instanceof String || paramValue instanceof Number || paramValue instanceof Boolean) {
valueStr = paramValue.toString();
} else {
valueStr = JSONUtil.toJsonStr(paramValue);
}
request.form(paramName, valueStr);
/**
* 获取主机隔离许可并严格限制注册表体积。
*
* @param host 主机名
* @return 主机许可
*/
private static Semaphore hostSemaphore(String host) {
Semaphore existing = HOST_PERMITS.get(host);
if (existing != null) {
return existing;
}
synchronized (HOST_REGISTRY_LOCK) {
existing = HOST_PERMITS.get(host);
if (existing != null) {
return existing;
}
if (HOST_PERMITS.size() >= MAX_TRACKED_HOSTS) {
return OVERFLOW_HOST_PERMITS;
}
Semaphore created = new Semaphore(8, true);
HOST_PERMITS.put(host, created);
return created;
}
}
/**
* 判断方法是否幂等。
*
* @param method HTTP 方法
* @return 是否允许连接级恢复
*/
private static boolean isIdempotent(String method) {
return "GET".equals(method)
|| "HEAD".equals(method)
|| "OPTIONS".equals(method);
}
/**
* 判断方法是否支持请求体。
*
* @param method HTTP 方法
* @return 是否支持请求体
*/
private static boolean supportsRequestBody(String method) {
return "POST".equals(method)
|| "PUT".equals(method)
|| "PATCH".equals(method);
}
/**
* 将普通或嵌套值转换为表单字符串。
*
* @param value 参数值
* @return 表单值
*/
private static String stringify(Object value) {
if (value == null) {
return "";
}
if (value instanceof String
|| value instanceof Number
|| value instanceof Boolean) {
return String.valueOf(value);
}
return JSONUtil.toJsonStr(value);
}
}