技术能力对比

序号

技术能力

技术实战

应用场景

1

表达式引擎

aviator、SpringEL、Groovy

轻量级:简单的求值场景

2

动态脚本

Groovy、Java Dynamic Class Loader

中量级:涉及大块复杂逻辑,涉及到代码块级别变化且需要调接口级别api。

3

插件化技术

PF4J

重量级:偏模块化的功能

一、表达式引擎

应用场景总结:针对于需要进行脚本求值场景,性能上aviator比SpringEL要好一些,可能是1-2倍or10倍or更多,但是本身计算就是ns级别,所以在非高性能场景可以忽略不计。

标准接口定义:

import java.util.Map;

public interface ExpressionParser {

    <T> T getValue(String expressionString, Map<String, Object> rootObject);

    <T> T getValue(String expressionString);
}

① aviator

官方文档:https://github.com/killme2008/aviatorscript

引入:

        <dependency>
            <groupId>com.googlecode.aviator</groupId>
            <artifactId>aviator</artifactId>
            <version>5.2.1</version>
        </dependency>

示例代码:

import com.boommapro.gaia.expression.ExpressionParser;
import com.googlecode.aviator.AviatorEvaluator;
import com.googlecode.aviator.AviatorEvaluatorInstance;
import com.googlecode.aviator.Expression;

import java.util.Map;


public class AviatorExpressionParser implements ExpressionParser {
    private static AviatorEvaluatorInstance aviatorEvaluator = AviatorEvaluator.getInstance();

    public <T> T getValue(String expression, Map<String, Object> context) {
        //开启缓存
        Expression expr = aviatorEvaluator.compile(expression, expression, true);
        return (T) expr.execute(context);

    }


    @Override
    public <T> T getValue(String expressionString) {
        return getValue(expressionString, null);
    }
}

② SpringEL

代码块参考:/post/springel-util/

官方文档:https://docs.spring.io/spring-framework/reference/core/expressions.html

import com.boommapro.gaia.expression.ExpressionParser;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextException;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.expression.*;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.support.StandardTypeLocator;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SpringExpressionParser implements ExpressionParser {

    private static final long serialVersionUID = 1L;

    private StandardEvaluationContext context;

    private org.springframework.expression.ExpressionParser expressionParser;

    private final ConcurrentHashMap<String, Expression> EXPRESSION_MAP = new ConcurrentHashMap<>();

    @Override
    public <T> T getValue(String expressionString) {
        return getValue(expressionString, null);
    }

    private static final SpringExpressionParser PARSER = new SpringExpressionParser();

    public static SpringExpressionParser getInstance() {
        return PARSER;
    }

    private SpringExpressionParser() {
        setApplicationContext(new GenericApplicationContext());
    }

    @Override
    @SuppressWarnings("all")
    public <T> T getValue(String expressionString, Map<String, Object> rootObject) {
        Expression expression = getExpression(expressionString);
        return ((T) expression.getValue(context, rootObject));
    }


    private Expression getExpression(String expressionString) {
        return EXPRESSION_MAP.computeIfAbsent(expressionString, s -> expressionParser.parseExpression(s));
    }


    @SuppressWarnings("all")
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if (applicationContext instanceof ConfigurableApplicationContext) {
            ConfigurableApplicationContext ac = (ConfigurableApplicationContext) applicationContext;
            context = new StandardEvaluationContext();
            context.addPropertyAccessor(new BeanExpressionContextAccessor());
            context.addPropertyAccessor(new BeanFactoryAccessor());
            context.addPropertyAccessor(new MapAccessor());
            context.addPropertyAccessor(new EnvironmentAccessor());
            context.setBeanResolver(new BeanFactoryResolver(ac.getBeanFactory()));
            context.setTypeLocator(new StandardTypeLocator(ac.getBeanFactory().getBeanClassLoader()));
            //初始化上下文
            expressionParser = new SpelExpressionParser();
            //注册默认工具方法
//            registerFunc(context,xxx.class);
        } else {
            throw new ApplicationContextException("can't cast ConfigurableApplicationContext");
        }
    }

    public void registerFunc(StandardEvaluationContext context, Class clazz) {
        Method[] methods = clazz.getDeclaredMethods();
        for (Method method : methods) {
            if (method.getModifiers() == 9) {
                String name = method.getName();
                context.registerFunction(name, method);
            }
        }
    }

    public void registerFunc(StandardEvaluationContext context, String className) throws ClassNotFoundException {
        Class<?> clazz = Class.forName(className);
        Method[] methods = clazz.getDeclaredMethods();
        for (Method method : methods) {
            if (method.getModifiers() == 9) {
                String name = method.getName();
                context.registerFunction(name, method);
            }
        }
    }
}

③ Groovy简化求值

Gaia 里的表达式统一用上面的 SpringEL,没有把 Groovy 当表达式引擎用;Groovy 用在代码节点里,实现见下文「二、① Groovy」。

如果确实要用 Groovy 做简单求值,要注意:GroovyShell 每次 evaluate/parse 都会编译出一个新类,高频调用会不断占用 Metaspace。正确做法是按表达式缓存编译结果,每次求值新建 Script 实例(Script 持有 Binding,不是线程安全的)。下面是按这个思路写的示例:

import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
import org.codehaus.groovy.runtime.InvokerHelper;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class GroovyExpressionParser implements ExpressionParser {

    private static final GroovyShell SHELL = new GroovyShell();
    // 同一个表达式只编译一次
    private static final Map<String, Class<? extends Script>> CACHE = new ConcurrentHashMap<>();

    @Override
    @SuppressWarnings("unchecked")
    public <T> T getValue(String expressionString, Map<String, Object> rootObject) {
        Class<? extends Script> clazz = CACHE.computeIfAbsent(expressionString, e -> SHELL.parse(e).getClass());
        Binding binding = new Binding(rootObject == null ? new HashMap<>() : new HashMap<>(rootObject));
        // 每次求值创建新的 Script 实例
        return (T) InvokerHelper.createScript(clazz, binding).run();
    }

    @Override
    public <T> T getValue(String expressionString) {
        return getValue(expressionString, null);
    }
}

二、动态脚本

应用场景总结:groovy适合轻量级引入,但是存在不兼容java lambda语法和部分语法不兼容等问题,在特殊情况下可以使用 Java Dynamic Class Loader

① Groovy

引入:

        <dependency>
            <groupId>org.codehaus.groovy</groupId>
            <artifactId>groovy-all</artifactId>
            <version>2.4.17</version>
        </dependency>

示例代码:

public class GroovyShellTest {

    public static void main(String[] args) {
        GroovyShell shell = new GroovyShell();
        Binding binding = new Binding();
        binding.setVariable("param", "param");
        String script = "";
        try {
            shell.evaluate(script);
        } catch (Exception e) {
            throw new GroovyRuntimeException("groovy脚本执行异常", e);
        } finally {
            shell.resetLoadedClasses();
        }
    }
}

Gaia 中的实现:代码节点用 GroovyClassLoader.parseClass 把脚本编译成类,再反射调用 execute(Map)。源码:GroovyCompilerEngine.java:

package cn.boommanpro.gaia.workflow.infra.extend.node.code.compiler.groovy;

import cn.boommanpro.gaia.workflow.infra.extend.node.code.compiler.CompilerEngine;
import cn.boommanpro.gaia.workflow.infra.extend.node.code.compiler.pojo.CompileResult;
import groovy.lang.GroovyClassLoader;

public class GroovyCompilerEngine implements CompilerEngine {

    private static final GroovyClassLoader loader = new GroovyClassLoader();

    @Override
    public CompileResult loadClass(String script) {
        try {
            Class<?> result = loader.parseClass(script);
            //这里比较简单,没有对代码进行reformat
            return CompileResult.success(result, script);
        } catch (Exception e) {
            return CompileResult.otherException(e);
        }
    }
}

脚本写法(示例 ExampleCode.groovy):

/**
 * Groovy 代码节点示例。
 * <p>
 * 约束:
 * <ul>
 *   <li>GroovyClassLoader.parseClass 解析脚本,类定义会被识别为主类</li>
 *   <li>需提供 execute(Map) 方法,返回 Map 作为节点输出</li>
 *   <li>可使用 Groovy 语法糖(字符串插值、def 等)</li>
 * </ul>
 * 输入参数 name(String),输出 greeting / upper。
 */
class ExampleCode {

    Map<String, Object> execute(Map<String, Object> inputs) {
        def result = new HashMap<String, Object>()
        def name = inputs.get("name") ?: ""
        result.put("greeting", "Hello, ${name}!".toString())
        result.put("upper", name.toString().toUpperCase())
        return result
    }
}

注意:这里用的是一个全局的 GroovyClassLoader,每次 parseClass 都会生成新类且不会被卸载。脚本固定、只编译一次没问题;如果同一段脚本会被反复编译,应该像上面的示例一样按脚本内容缓存。

② Java Dynamic Class Loader

核心逻辑:通过 javax.tools.JavaCompiler 在内存中编译 .java 源码(注意类名需要加随机后缀),再用自定义 ClassLoader 加载字节码并实例化。

Gaia 中的完整实现在 compiler/java 目录下,分 4 步:

① 类名加随机后缀(AbstractCompiler.java):同一个 ClassLoader 里不能重复定义同名类,所以每次编译前把类名改成“原类名 + 时间戳 + 随机数”:

public JavaCompilerResult compile(String script, ClassLoader classLoader) {
    script = script.trim();
    Matcher matcher = PACKAGE_PATTERN.matcher(script);
    String pkg;
    if (matcher.find()) {
        pkg = matcher.group(1);
    } else {
        pkg = "";
    }
    matcher = CLASS_PATTERN.matcher(script);
    String cls;
    if (matcher.find()) {
        String match = matcher.group();
        String className = matcher.group(1);
        cls = className + System.currentTimeMillis() + random.nextInt(1000);
        match = match.replaceFirst(className, cls);
        script = matcher.replaceFirst(match).replace(String.format("%s.class", className), String.format("%s.class", cls));
    } else {
        throw new ScriptIllegalException("No such class name in " + script);
    }
    String fullClassName = pkg != null && !pkg.isEmpty() ? pkg + "." + cls : cls;
    return doCompile(fullClassName, script, classLoader);
}

② 在内存中编译(JavaCompiler.java):用 JDK 自带的编译器,源码和产物都放在内存里,不落盘:

@Override
protected JavaCompilerResult doCompile(String name, String script, ClassLoader classLoader) {
    // 创建新的DiagnosticCollector和MemoryJavaFileManager实例避免状态污染
    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
    MemoryJavaFileManager javaFileManager = new MemoryJavaFileManager(compiler.getStandardFileManager(diagnostics, null, null));

    // 将 extractedPath 添加到类路径
    SpringBootJarExtract.initJarCompilerEnvironment();
    //① 将source code -> MemoryJavaFileObject
    MemoryJavaFileObject javaSource = new MemoryJavaFileObject(name, script);

    try {
        // ③ 填充项目依赖到MemoryJavaFileManager
        fillThirdDependencyToFileManager(javaFileManager);
        // ④借助jdk本身的compiler 直接进行编译
        compiler.getTask(null, javaFileManager, diagnostics, OPTIONS, null, Arrays.asList(javaSource)).call();
        //编译错误处理
        validJavaCompilerSourceError(name, diagnostics, javaSource);
        return loadAndReturnCompilerResult(script,javaFileManager, name, classLoader);
    } catch (Exception e) {
        throw new ScriptIllegalException("script compile ERROR!error message:" + e.getMessage(), e);
    } finally {
        resourceClose(name, javaSource, javaFileManager);
    }
}

编译产物通过自定义的 MemoryJavaFileManager 拦截到内存:

@Override
public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind,
                                           FileObject sibling) throws IOException {
    MemoryJavaFileObject javaFileObject = new MemoryJavaFileObject(className, kind);
    fileObjectMap.put(javaFileObject.toUri(), javaFileObject);
    return javaFileObject;
}

③ 加载字节码(MemoryClassLoader.java):

package cn.boommanpro.gaia.workflow.infra.extend.node.code.compiler.java.dependency;


import java.net.URL;
import java.net.URLClassLoader;
import java.util.HashMap;
import java.util.Map;


public class MemoryClassLoader extends URLClassLoader {

    private final Map<String, byte[]> classCache = new HashMap<>();

    public MemoryClassLoader() {
        super(new URL[0], Thread.currentThread().getContextClassLoader());
    }


    public Class<?> loadClass(String fullName, byte[] classData) {
        return this.defineClass(fullName, classData, 0, classData.length);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        // 2. 缓存中没有,委托给父类加载器(双亲委派)
        try {
            // 1. 先尝试从缓存中加载
            byte[] classBytes = classCache.get(name);
            if (classBytes != null) {
                return defineClass(name, classBytes, 0, classBytes.length);
            }
            return super.findClass(name);
        } catch (ClassNotFoundException e) {
            // 父类加载器也找不到,抛出异常
            throw new ClassNotFoundException("Class " + name + " not found in memory or parent classloader", e);
        }
    }

    public void addClass(String name, byte[] classBytes) {
        classCache.put(name, classBytes);
    }
}

④ 缓存编译结果并调用(JavaCompilerEngine.java、DynamicCompileNode.java):同一段脚本 1 小时内只编译一次,执行时反射调用 execute(Map):

// 添加缓存,使用LoadingCache自动处理缓存加载和过期
private static final LoadingCache<String, CompileResult> scriptCache = CacheBuilder.newBuilder()
        .maximumSize(1000) // 最多缓存1000个脚本
        .expireAfterWrite(1, TimeUnit.HOURS) // 缓存1小时后过期
        .build(new CacheLoader<String, CompileResult>() {
            @Override
            public CompileResult load(String script) throws Exception {
                try {
                    JavaCompilerResult compile = javaCompiler.compile(script, new MemoryClassLoader());
                    return CompileResult.success(compile.getMainClass(),compile.getClazzCode(), compile.getClassList());
                } catch (Exception e) {
                    return CompileResult.otherException(e);
                }finally {

                }
            }
        }
private Map<String, Object> executeJava(Chain chain) {
    try {
        CompileResult compileResult = CompilerEngineEnum.JAVA.loadClass(code);
        if (compileResult.getCode().getCode() != 0) {
            throw new RuntimeException("Java compilation failed: " + compileResult.getMsg());
        }

        Class<?> clazz = compileResult.getClazz();
        Object instance = clazz.newInstance();

        // 检查是否实现了CodeExecute接口
        if (instance instanceof CodeExecute) {
            CodeExecute codeExecute = (CodeExecute) instance;
            Map<String, Object> parameterValues = chain.getParametersData(this);
            return codeExecute.execute(parameterValues != null ? parameterValues : new HashMap<>());
        }

        // 尝试调用execute方法
        Method executeMethod = clazz.getMethod("execute", Map.class);
        Map<String, Object> parameterValues = chain.getParametersData(this);
        return (Map<String, Object>) executeMethod.invoke(instance, parameterValues != null ? parameterValues : new HashMap<>());
    } catch (Exception e) {
        throw new RuntimeException("Java execution failed", e);
    }
}

脚本写法(示例 ExampleCode.java):

import java.util.HashMap;
import java.util.Map;

/**
 * Java 代码节点示例。
 * <p>
 * 约束:
 * <ul>
 *   <li>需提供 public 无参构造(默认即满足)</li>
 *   <li>需提供 {@code execute(Map<String,Object>)} 方法,返回 Map 作为节点输出</li>
 *   <li>无需实现 CodeExecute 接口,DynamicCompileNode 会通过反射调用 execute</li>
 * </ul>
 * 输入参数 name(String),输出 greeting / length。
 */
public class ExampleCode {

    public Map<String, Object> execute(Map<String, Object> inputs) {
        Map<String, Object> result = new HashMap<>();
        Object name = inputs.get("name");
        String nameStr = name == null ? "" : name.toString();
        result.put("greeting", "Hello, " + nameStr + "!");
        result.put("length", nameStr.length());
        return result;
    }
}

踩坑点:

  • ToolProvider.getSystemJavaCompiler() 只在 JDK 下可用,运行在 JRE 上会返回 null,部署镜像必须带 JDK。

  • Spring Boot 打成 fat jar 后,依赖是嵌套在 jar 里的,javac 读不到。Gaia 用 SpringBootJarExtract.java 先把依赖解压到 extracted 目录,再设置为编译的 CLASS_PATH。

  • 编译参数 -source 21 -target 21 要和运行时的 JDK 版本匹配。

三、插件化技术

① PF4J

官方文档:https://github.com/pf4j/pf4j

示例demo见:https://github.com/pf4j/pf4j/tree/master/demo

halo项目的最佳实践:https://github.com/halo-dev/halo

依赖引入:

        <dependency>
            <groupId>org.pf4j</groupId>
            <artifactId>pf4j</artifactId>
            <version>3.10.0</version>
        </dependency>

源码索引

文中 Groovy 和 Java 动态编译的实现均来自 Gaia 工作流引擎:https://github.com/boommanpro/gaia-workflow-engine