一个看似不可能的需求

做iPaaS平台,核心价值是帮用户连接不同系统、编排业务流程。但企业数据五花八门,不可能靠预设节点覆盖所有场景。于是一个需求自然浮出水面:

让用户在流程里写Java代码,平台在运行时编译执行。

注意,不是JavaScript那种解释执行。是真正的Java代码——能引用平台SDK、能import第三方JAR包、能享受强类型带来的安全性——在不重启服务的前提下,实时编译、即时生效。

这事儿的技术难度,远比表面看起来要大。


为什么不直接用JavaScript?

在我们的iPaaS平台里,其实已经有JavaScript脚本引擎了。但Java代码节点依然不可替代,原因有三:

  1. 客户的开发团队是Java技术栈。让Java开发者写JS处理复杂业务逻辑,心智负担太重
  2. 类型安全。企业数据处理对正确性要求极高,编译期类型检查能拦住大量低级错误
  3. 第三方JAR复用。客户有自己的加密库、协议SDK、数据解析工具,这些都是JAR包,JS没法直接用

所以我们需要一套完整的运行时Java代码编译 + 动态类加载 + 安全沙箱方案。


整体架构

先看全貌:

Execution

Security Sandbox

Compilation Pipeline

Script Engine

User Layer

ScriptTypeEnum.JAVA

命中

未命中

安全

不安全

用户编写Java代码

ScriptFactory 路由分发

JavaScriptHandler 编译执行引擎

正则解析类名

编译缓存 ConcurrentHashMap

ClassPath构建 系统+动态JAR

JDK JavaCompiler API

DynamicClassLoader 自定义类加载器

黑名单校验

Javassist字节码依赖分析

反射调用execute方法

返回结果

拒绝执行

整个脚本引擎采用策略模式 + 工厂模式设计:

// 统一接口
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,包括:

  • 系统ClassPathjava.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 JavaCompiler DynamicClassLoader Compile JavaScriptHandler ClassFileManager JavaCompiler DynamicClassLoader Compile JavaScriptHandler alt [缓存命中] [缓存未命中] compile(className, sourceCode, options, context) getInstance(sourceCode, context) 返回缓存的ClassLoader loadClass(className) 返回Class<?> null buildInstance(sourceCode, context) getTask(out, fileManager, options, files) 编译产物写入内存ByteArray task.call() defineClass(name, bytes) 返回Class<?>

关键实现——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 3 - ClassPath缓存

Level 2 - ClassLoader缓存

Level 1 - Handler编译结果缓存

命中

未命中

命中

未命中

ConcurrentHashMap - compiledClassCache

TTL: 30分钟 / 最大300条

Key: contextKey + sourceHash

Guava LoadingCache - DynamicClassLoader

TTL: 30分钟 / 最大200条

Key: MD5 of fileNames+sourceCode

volatile String - cachedClassPath

TTL: 10分钟 / JAR变化失效

执行请求

直接执行

loadClass

编译

Level 1compiledClassCache——按源码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 2DynamicClassLoader——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;
}

平台处理

用户操作

上传JAR包到指定目录

编写Java代码 import第三方类

扫描dynamicJarFolder

构建ClassPath: 系统JAR + 动态JAR

编译时包含动态JAR的classpath

DynamicClassLoader加载动态JAR中的类

执行用户代码

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平台带来的业务价值:

  1. 极致灵活性:用户可以在流程中写任意Java逻辑,不受预设节点能力限制
  2. 零发版:代码修改即时生效,不需要重启服务、不需要走发版流程
  3. 企业级安全:字节码级别的依赖分析 + 黑名单机制,防止恶意代码
  4. 高性能:三级缓存设计,95%+命中率,热路径执行耗时个位数毫秒
  5. 生态开放:支持用户上传自己的JAR包,真正实现"平台即接口"
  6. 多语言支持:ScriptFactory统一接口,Java/JavaScript/Python三种引擎同架构

对于有复杂数据转换、自定义业务逻辑、私有SDK集成需求的企业客户来说,这个能力是选择iPaaS平台的关键因素之一——不是所有的iPaaS都能让你在运行时写Java代码


关于数环通

数环通是一款面向企业的无代码集成自动化平台(iPaaS),提供1000+应用连接器、可视化流程编排、实时事件驱动等核心能力,帮助企业快速打通系统孤岛、实现业务自动化。