数环通iPaaS动态类加载实践:让用户在运行时写Java代码
一个看似不可能的需求
做iPaaS平台,核心价值是帮用户连接不同系统、编排业务流程。但企业数据五花八门,不可能靠预设节点覆盖所有场景。于是一个需求自然浮出水面:
让用户在流程里写Java代码,平台在运行时编译执行。
注意,不是JavaScript那种解释执行。是真正的Java代码——能引用平台SDK、能import第三方JAR包、能享受强类型带来的安全性——在不重启服务的前提下,实时编译、即时生效。
这事儿的技术难度,远比表面看起来要大。
为什么不直接用JavaScript?
在我们的iPaaS平台里,其实已经有JavaScript脚本引擎了。但Java代码节点依然不可替代,原因有三:
- 客户的开发团队是Java技术栈。让Java开发者写JS处理复杂业务逻辑,心智负担太重
- 类型安全。企业数据处理对正确性要求极高,编译期类型检查能拦住大量低级错误
- 第三方JAR复用。客户有自己的加密库、协议SDK、数据解析工具,这些都是JAR包,JS没法直接用
所以我们需要一套完整的运行时Java代码编译 + 动态类加载 + 安全沙箱方案。
整体架构
先看全貌:
整个脚本引擎采用策略模式 + 工厂模式设计:
// 统一接口
public interface ScriptHandler {
<T> T execute(String scriptSource, Object params);
<T> T execute(String scriptSource, Object params, ExecuteConfig executeConfig);
}
// 工厂路由
public class ScriptFactory {
private static Map<ScriptTypeEnum, ScriptHandler> context = new HashedMap();
public static ScriptHandler getHandler(ScriptTypeEnum scriptType) {
return context.get(scriptType);
}
}
Java、JavaScript、Python三种脚本引擎实现同一个接口,通过ScriptFactory统一路由。上层调用方完全不需要关心底层是哪种引擎在执行。
核心实现:从源码字符串到可执行Class
Step 1:解析类名
用户写的是一段完整的Java源码字符串,我们首先需要从中提取全限定类名:
private final Pattern packagePattern = Pattern.compile("package\\s+(\\S+)\\s*;");
private final Pattern classPattern = Pattern.compile("class\\s+(\\S+)\\s+");
private String getFullClassName(String content) {
StringBuilder builder = new StringBuilder();
Matcher matcher = packagePattern.matcher(content);
if (matcher.find()) {
builder.append(matcher.group(1)).append(".");
}
matcher = classPattern.matcher(content);
if (matcher.find()) {
builder.append(matcher.group(1));
}
return builder.toString(); // 如 com.shuhuan.ipaas.script.java.Cat
}
这看起来简单,实际上隐含了一个设计决策:用户的代码必须声明package和class,这是强约定。好处是代码规范、避免命名冲突、方便缓存管理。
Step 2:构建ClassPath
编译Java代码需要一个完整的ClassPath,包括:
- 系统ClassPath:
java.class.path中的所有JAR - 应用ClassPath:Spring Boot打包后的FAT JAR内部依赖
- 动态JAR路径:用户上传的第三方JAR包
private String getClassPath(JavaContext context) {
// 双重检查锁 + TTL缓存
if (cachedClassPath != null &&
System.currentTimeMillis() - classPathLastBuilt < CLASSPATH_CACHE_TTL &&
!dynamicJarsChanged) {
return cachedClassPath;
}
synchronized (this) {
StringBuffer classpath = new StringBuffer();
String separator = System.getProperty("path.separator");
// 系统类路径
classpath.append(System.getProperty("java.class.path"));
// URLClassLoader中的URL(Spring Boot FAT JAR场景)
for (URL url : getClassPathUrls()) {
classpath.append(separator);
classpath.append(new File(url.getPath().replace("jar!", "jar")));
}
// 动态JAR文件
if (context != null && CollectionUtils.isNotEmpty(context.getFileNames())) {
for (String fileName : context.getFileNames()) {
classpath.append(separator);
classpath.append(new File(context.getDynamicJarFolder() + "/" + fileName));
}
}
cachedClassPath = classpath.toString();
return cachedClassPath;
}
}
这段代码有个细节值得注意:url.getPath().replace("jar!", "jar")。这是处理Spring Boot FAT JAR的嵌套路径格式(file:/xxx/app.jar!/BOOT-INF/lib/xxx.jar),去掉!才能被JavaCompiler正确识别。
Step 3:内存编译
核心编译逻辑使用JDK自带的javax.tools.JavaCompiler,所有编译产物都在内存中完成,不写磁盘:
关键实现——ClassFileManager接管了编译产物的输出:
public static final class ClassFileManager extends ForwardingJavaFileManager<StandardJavaFileManager> {
private final Map<String, JavaFileObject> fileObjectMap;
@Override
public JavaFileObject getJavaFileForOutput(Location location, String className,
JavaFileObject.Kind kind, FileObject sibling) {
JavaFileObject result = new JavaFileObject(className, kind);
fileObjectMap.put(className, result); // 编译结果存内存,不落盘
return result;
}
}
编译完成后,通过DynamicClassLoader.defineClass()将字节码注册到自定义ClassLoader中。
Step 4:并发编译控制
高并发场景下,同一段源码可能被多个流程同时执行。如果不加控制,会导致重复编译甚至ClassLoader冲突。
我们用源码维度的细粒度锁解决这个问题:
// 每段源码一把锁,互不干扰
private static final Map<String, ReentrantLockWithTime> COMPILE_LOCKS = new ConcurrentHashMap<>();
static Class<?> compile(String className, String sourceCode, ...) {
String sourceIdentifier = DynamicClassLoader.parseKey(sourceCode, context);
ReentrantLockWithTime compilerLock = COMPILE_LOCKS.computeIfAbsent(
sourceIdentifier, k -> new ReentrantLockWithTime());
compilerLock.lock();
try {
// 先查缓存,命中则直接loadClass
DynamicClassLoader cl = DynamicClassLoader.getInstance(sourceCode, context);
if (cl != null) {
return cl.loadClass(className);
}
// 未命中才编译
cl = DynamicClassLoader.buildInstance(sourceCode, context);
return compileClass(cl, className, sourceCode, compileOptions, checkOptions);
} finally {
compilerLock.unlock();
}
}
锁是带时间戳的,后台定时清理超过24小时的过期锁,防止内存泄漏:
private static final ScheduledExecutorService CLEANUP_EXECUTOR =
Executors.newSingleThreadScheduledExecutor();
static {
CLEANUP_EXECUTOR.scheduleAtFixedRate(Compile::cleanUpExpiredLocks, 1, 1, TimeUnit.HOURS);
}
三级缓存体系
动态编译最大的性能瓶颈是编译耗时。一次Java编译可能耗时200-500ms,但执行可能只需要5ms。所以缓存设计至关重要。
我们设计了三级缓存:
Level 1:compiledClassCache——按源码hash + 动态JAR列表作为key,缓存编译好的Class<?>对象。命中率最高、代价最低。
private Class<?> getCompiledClass(String sourceCode, JavaContext context) {
String cacheKey = generateCacheKey(sourceCode, context);
Class<?> cachedClass = compiledClassCache.get(cacheKey);
if (cachedClass != null) {
Long lastModified = classLastModifiedCache.get(cacheKey);
if (lastModified != null && System.currentTimeMillis() - lastModified < CACHE_TTL) {
return cachedClass; // 缓存命中
}
}
return null;
}
Level 2:DynamicClassLoader——Guava Cache管理,以MD5(fileNames + sourceCode)为key缓存整个ClassLoader实例。
Level 3:ClassPath字符串缓存——10分钟TTL + 动态JAR变化检测,避免每次都遍历文件系统。
实际运行数据显示,在流程重复执行的典型场景下,Level 1缓存命中率超过95%。绝大多数请求直接拿到已编译的Class,反射调用execute方法即可。
安全沙箱:防止恶意代码
让用户运行任意Java代码,安全风险极高。我们设计了黑名单 + 字节码依赖分析的双重防护:
黑名单机制
public static List<String> blackList() {
return new ArrayList<String>() {{
add("java.lang.Thread"); // 禁止创建线程
}};
}
Javassist字节码分析
编译完成后,不是直接执行,而是先用Javassist解析字节码的常量池,提取所有类依赖:
public static Set<String> getDependencies(InputStream is) throws Exception {
ClassFile cf = new ClassFile(new DataInputStream(is));
ConstPool constPool = cf.getConstPool();
HashSet<String> set = new HashSet<>();
for (int ix = 1, size = constPool.getSize(); ix < size; ix++) {
if (constPool.getTag(ix) == ConstPool.CONST_Class) {
set.add(constPool.getClassInfo(ix));
} else if (constPool.getTag(ix) == ConstPool.CONST_NameAndType) {
// 解析方法/字段描述符中的类引用
int descriptorIndex = constPool.getNameAndTypeDescriptor(ix);
String desc = constPool.getUtf8Info(descriptorIndex);
for (int p = 0; p < desc.length(); p++) {
if (desc.charAt(p) == 'L') {
set.add(desc.substring(++p, p = desc.indexOf(';', p)).replace('/', '.'));
}
}
}
}
return set;
}
如果发现引用了黑名单中的类,直接拒绝执行:
static private void safetyCheck(byte[] catBytes, String blackList) throws Exception {
Set<String> dependencies = JavassistUtil.getDependencies(new ByteArrayInputStream(catBytes));
List<String> notSupportedPackages = new ArrayList<>();
for (String d : dependencies) {
for (String forbidden : blackList.split(",")) {
if (d.startsWith(forbidden)) {
notSupportedPackages.add(d);
}
}
}
if (!notSupportedPackages.isEmpty()) {
throw new ReflectException("un support class:" + String.join(",", notSupportedPackages));
}
}
这比简单的文本匹配高明得多——分析的是编译后的字节码依赖,即使用户通过反射、动态代理等手段试图绕过文本检测,只要字节码层面引用了黑名单类,一样会被拦截。
动态JAR加载:让用户引入自己的依赖
很多企业有自己的加密SDK、业务工具类、协议解析库。我们支持用户上传JAR包,在运行时动态加入ClassPath:
@Data
public class JavaExecuteConfig extends ExecuteConfig {
/**
* 动态jar包文件夹路径
* 如:/home/admin/shuhuan-ipaas-engine/dynamicjars
*/
private String dynamicJarFolder;
}
DynamicClassLoader继承URLClassLoader,构造时将动态JAR的URL全部加入:
public class DynamicClassLoader extends URLClassLoader {
private DynamicClassLoader(JavaContext context) {
super(buildUrls(context), DynamicClassLoader.class.getClassLoader());
}
private static URL[] buildUrls(JavaContext context) {
List<String> fileNames = FileUtil.listFiles(context.getDynamicJarFolder());
URL[] urls = new URL[fileNames.size()];
for (int i = 0; i < fileNames.size(); i++) {
urls[i] = new File(context.getDynamicJarFolder() + "/" + fileNames.get(i)).toURL();
}
return urls;
}
}
这意味着用户可以这样写代码:
package com.customer.integration;
import com.customer.crypto.AESUtil; // 来自用户上传的JAR
public class DataEncryptor extends JavaScriptRunner {
@Override
public <T> T execute(Object params) {
Map<String, Object> data = (Map<String, Object>) params;
String raw = (String) data.get("sensitiveField");
String encrypted = AESUtil.encrypt(raw, "customer-secret-key");
data.put("sensitiveField", encrypted);
return (T) data;
}
}
自己的加密库、自己的业务逻辑、在平台流程里无缝运行。
用户怎么使用
对用户来说,体验非常简单——在流程编排器里拖一个"Java代码"节点,写代码,保存,立即执行。
用户只需要继承JavaScriptRunner并实现execute方法:
public abstract class JavaScriptRunner {
public abstract <T> T execute(Object params);
}
一个实际的使用例子——在流程中对上游数据做自定义转换:
package com.shuhuan.ipaas.script.java;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import java.util.*;
import java.text.SimpleDateFormat;
public class OrderDataTransform extends JavaScriptRunner {
@Override
public <T> T execute(Object params) {
JSONObject input = JSON.parseObject(JSON.toJSONString(params));
// 金额单位转换:分 → 元
long amountInCent = input.getLongValue("amount");
input.put("amount", amountInCent / 100.0);
// 时间格式标准化
String rawTime = input.getString("createTime");
if (rawTime != null && rawTime.length() == 13) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
input.put("createTime", sdf.format(new Date(Long.parseLong(rawTime))));
}
// 状态码映射
Map<String, String> statusMap = new HashMap<>();
statusMap.put("1", "待支付");
statusMap.put("2", "已支付");
statusMap.put("3", "已发货");
input.put("statusText", statusMap.getOrDefault(input.getString("status"), "未知"));
return (T) input;
}
}
写完保存,流程里的数据每次经过这个节点,都会自动应用这段逻辑。不需要重启、不需要发版、不需要运维介入。
性能数据
我们在JavaScriptHandler中内置了完整的性能指标采集:
public Map<String, Object> getPerformanceMetrics() {
metrics.put("totalCompileTime", totalCompileTime.get());
metrics.put("compileCount", compileCount.get());
metrics.put("cacheHitCount", cacheHitCount.get());
metrics.put("cacheMissCount", cacheMissCount.get());
metrics.put("cacheHitRate", ...);
metrics.put("averageCompileTime", ...);
metrics.put("averageExecuteTime", ...);
return metrics;
}
生产环境实际数据:
| 指标 | 数值 |
|---|---|
| 首次编译耗时 | 200-500ms |
| 缓存命中后执行耗时 | 3-8ms |
| 缓存命中率 | 95%+ |
| 最大缓存容量 | 300个Class + 200个ClassLoader |
| 编译锁清理周期 | 每小时清理超24小时的锁 |
首次执行有编译开销,但后续调用几乎和原生Java调用一样快。这是因为编译后的Class<?> 对象被缓存了——跟直接写在代码里的类在JVM层面没有本质区别。
几个踩过的坑
1. 相同类名不同代码的缓存冲突
早期缓存key只用了类名,导致用户修改代码后依然执行旧版本。现在改为源码hash + 动态JAR列表双重key:
private String generateCacheKey(String sourceCode, JavaContext context) {
String sourceHash = String.valueOf(sourceCode.hashCode());
String contextKey = context != null ? context.parseFlatFileNames() : "";
return contextKey + "|" + sourceHash;
}
同一个com.xxx.Cat类,代码改了一个字,缓存key就不同,保证执行的永远是最新版本。
2. Spring Boot FAT JAR的嵌套路径
生产环境是Spring Boot打包的FAT JAR,内部依赖的路径格式是xxx.jar!/BOOT-INF/lib/yyy.jar。JavaCompiler不认这种格式,需要手动处理:
classpath.append(new File(url.getPath().replace("jar!", "jar")));
3. ClassLoader泄漏
动态创建的ClassLoader如果不释放,持有的Class对象会导致Metaspace OOM。我们通过Guava Cache的TTL机制自动淘汰过期的ClassLoader,配合GC回收字节码占用的Metaspace。
4. 编译锁膨胀
高并发下COMPILE_LOCKS可能积累大量锁对象。解决方案是带时间戳的锁 + 后台定时清理:
private static class ReentrantLockWithTime extends ReentrantLock {
private volatile long createTime;
public boolean isExpired() {
return System.currentTimeMillis() - createTime > 24 * 60 * 60 * 1000L;
}
}
这套方案解决了什么问题
总结一下这套动态类加载方案给iPaaS平台带来的业务价值:
- 极致灵活性:用户可以在流程中写任意Java逻辑,不受预设节点能力限制
- 零发版:代码修改即时生效,不需要重启服务、不需要走发版流程
- 企业级安全:字节码级别的依赖分析 + 黑名单机制,防止恶意代码
- 高性能:三级缓存设计,95%+命中率,热路径执行耗时个位数毫秒
- 生态开放:支持用户上传自己的JAR包,真正实现"平台即接口"
- 多语言支持:ScriptFactory统一接口,Java/JavaScript/Python三种引擎同架构
对于有复杂数据转换、自定义业务逻辑、私有SDK集成需求的企业客户来说,这个能力是选择iPaaS平台的关键因素之一——不是所有的iPaaS都能让你在运行时写Java代码。
关于数环通
数环通是一款面向企业的无代码集成自动化平台(iPaaS),提供1000+应用连接器、可视化流程编排、实时事件驱动等核心能力,帮助企业快速打通系统孤岛、实现业务自动化。
所有评论(0)