Java实现动态加载的逻辑

日常工作中我们经常遇到这样的场景,某某些逻辑特别不稳定,随时根据线上实际情况做调整,比如商品里的评分逻辑,比如规则引擎里的规则。

常见的可选方案有:

  1. JDK自带的ScriptEngine
  2. 使用groovy,如GroovyClassLoader、GroovyShell、GroovyScriptEngine
  3. 使用Spring的
  4. 使用JavaCC实现自己的DSL

后续我们会对每一个方案做具体说明。为了方便解说,我们假定有这样一个场景,我们有一些商品对象(Product),商品上有商品ID、静态评分、相关度评分、所属类目ID,我们想要计算商品的最终得分(final_score),后续流程会基于这个评分对商品做排序。Rule是我们对评分计算逻辑的抽象,support用于提示当前Rule是否适用给定Product,execute用于对给定Product做处理。RuleEngine负责维护一组Rule对象,当调用apply时,用所有Rule对给定Product做处理。

Java实现动态加载的逻辑_第1张图片

这3个文件的源码分别如下,Product类


package com.lws.rule;

import lombok.Data;

@Data
public class Product {
    private long id;
    private float staticScore;
    private float relationScore;
    private float finalScore;
    private int categoryId;
}

Rule接口

package com.lws.rule;


public interface Rule {
    public boolean support(Product p);
    public Product execute(Product p);
}

RuleEngine实现

package com.lws.rule;

import java.util.ArrayList;
import java.util.List;

public class RuleEngine {

    private List rules = new ArrayList<>();

    public Product apply(Product p) {
        for (Rule rule : rules) {
            if (p != null && rule.support(p)) {
                p = rule.execute(p);
            }
        }
        return p;
    }
}

1.ScriptEngine

1.1 前景提要

JDK自带ScriptEngine实现,JDK15之后默认ECMAScript引擎实现已经从JDK里移除,使用前需要自己引入nashorn-core的依赖


    org.openjdk.nashorn
    nashorn-core
    15.4

通过引入依赖自动添加ScriptEngine的实现,采用的是Java SPI的机制,关于Java SPI的更多信息查看文章Java SPI。通过ScriptEngineManager的代码能确定具体实现

Java实现动态加载的逻辑_第2张图片

1.2 具体实现

我们将通过ScriptEngine执行脚本的逻辑封装到一个方法内部,将一个Map对象绑定到Bindings上做为执行上下文

private Object eval(String expr, Map context) {
    try {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("JavaScript");
        Bindings bindings = engine.createBindings();
        bindings.putAll(context);
        return engine.eval(expr, bindings);
    } catch (Exception e) {
        log.error("fail to execute expression: " + expr, e);
        return null;
    }
}

新建一个类JavaScriptEngineRule做为Rule的实现类,support和execute都通过执行脚本返回的结果做为输出,而这两个脚本是可配置的,甚至可以从数据库、配置中心里读取

public class JavaScriptEngineRule implements Rule {

    private Logger log = LoggerFactory.getLogger(JavaScriptEngineRule.class);

    private String supportExpr;
    private String executeExpr;

    public JavaScriptEngineRule(String supportExpr, String executeExpr) {
        this.supportExpr = supportExpr;
        this.executeExpr = executeExpr;
    }

    @Override
    public boolean support(Product p) {
        if (StringUtils.isBlank(supportExpr)) {
            return true;
        } else {
            Boolean b = (Boolean) eval(supportExpr, Maps.of("product", p));
            return b != null && b;
        }
    }

    @Override
    public Product execute(Product p) {
        Product np = (Product) eval(executeExpr, Maps.of("product", p));
        return np;
    }

    private Object eval(String expr, Map context);
}
1.3 测试结果

我们预先定义了一条数据

Product p = new Product();
p.setId(1);
p.setCategoryId(1001);
p.setStaticScore(1F);
p.setRelationScore(3F);

定义执行的脚本,可以看到我们只处理id是基数,categoryId大于1000的Product,将finalScore修改为staticScore、relationScore按比例加层后总分。一段脚本代码里可以有多个语句,最后一条语句的执行结果做为ScriptEngine.eval的执行结果返回。



String supportExpr = "product.id % 2 == 1 && product.categoryId > 1000";
String executeExpr = "product.finalScore = product.staticScore * 0.6 + product.relationScore * 0.4; product";

实际测试代码,后续的测试都会重复使用预定义的数据和执行输出,但不会再反复贴出

Rule rule = new JavaScriptEngineRule(supportExpr, executeExpr);
if (rule.support(p)) {
    p = rule.execute(p);
}
System.out.println(p);

2. 使用Groovy能力

通过JavaScript的ScriptEngine使用动态逻辑,用起来还算简单,但是也有一个明显的问题,JavaScript引擎没法调用工程内的Java类库,如果我想要在动态逻辑里发生HTTP请求、使用JDBC、发生MQ消息等等,就很难做到。而Groovy能帮助我们达成这些目标。

2.1 GroovyClassLoader

将完整的Rule实现存储到字符串中(数据库、配置中心),由GroovyClassLoader解析生成Class,再通过反射创建实例。我们创建的Rule实现类名字是GroovyClassLoaderRule,他会将所有调用委托给通过反射创建的实例。

public class GroovyClassLoaderRule implements Rule {

    private String subClass = """
            package com.lws.rule.impl;    
            import com.lws.rule.Product;
            import com.lws.rule.Rule;  
            public class TemporaryGroovySubClass implements Rule {  
                @Override
                public boolean support(Product p) {
                    return p.getId() % 2 == 1 && p.getCategoryId() > 1000;
                }  
                @Override
                public Product execute(Product p) {
                    double score = p.getStaticScore() * 0.6 + p.getRelationScore() * 0.4;
                    p.setFinalScore((float)score);
                    return p;
                }
            }
            """;

    private Rule instance;

    public void init() throws InstantiationException, IllegalAccessException {
        GroovyClassLoader classLoader = new GroovyClassLoader();
        Class clazz = classLoader.parseClass(subClass);
        instance = (Rule)clazz.newInstance();
    }

    @Override
    public boolean support(Product p) {
        return instance.support(p);
    }

    @Override
    public Product execute(Product p) {
        return instance.execute(p);
    }
}

可以看到subClass字符串里已经是正常的Java代码了,Java1.7的代码基本都能正常编译。通过调用init方法,我们创建了Rule的实例。这里由一个比较容易成为陷阱的问题是,使用完全相同的subClass内容,创建两个GroovyClassLoaderRule实例时,实际创建的是两个ClassLoader实例,存在完全不同的两个Class对象,会占用两份JVM永久代空间

GroovyClassLoaderRule rule = new GroovyClassLoaderRule();
rule.init();

GroovyClassLoaderRule rule1 = new GroovyClassLoaderRule();
rule1.init();

System.out.println(rule.getInstance().getClass().getName());  // 这里输出的名字完全相同
System.out.println(rule1.getInstance().getClass().getName());

System.out.println(rule.getInstance().getClass() == rule1.getInstance().getClass()); // 但Class对象却不是一个

问题根本的原因是同一个ClassLoader同一个类只能加载一次,要反复加载同一个类名就需要使用不同的ClassLoader。为了解决这个问题可以:

  1. 添加缓存,代码的MD5做为缓存KEY,GroovyClassLoader解析Class对象做为值,复用这个Class对象
  2. 促进Class和ClassLoader回收

我们知道Class回收前提是:

  1. 该Class下的对象都已经被回收
  2. 没有对当前Class的直接引用
  3. 加载当前Class的ClassLoader没有直接引用
 2.2 GroovyShell

GroovyClassLoader通过动态的源码直接创建了一个Class对象,有时候我们的动态逻辑并没有那么复杂。GroovyShell的使用方式更像ScriptEngine,可以指定一段脚本直接返回计算结果。

如果是直接执行脚本来获取结果,GroovyShell的实现和之前的JavaScriptEngineRule基本一致,执行修改eval方法的实现

private Object eval(String expr, Product product) {
    Binding binds = new Binding();
    binds.setVariable("product", product);
    GroovyShell shell = new GroovyShell(binds);
    Script script = shell.parse(expr);
    return script.run();
}

这段代码里的先执行shell.parse,再执行script.run,可以用evaluate方法直接代码,evaluate方法内部实际调用的parse、run方法

private Object eval(String expr, Product product) {
    Binding binds = new Binding();
    binds.setVariable("product", product);
    GroovyShell shell = new GroovyShell(binds);
    return shell.evaluate(expr);
}

测试脚本可以用JavaScriptEngineRule的脚本,也可以自己稍作修改,在返回值前在return关键字

String supportExpr = "product.id % 2 == 1 && product.categoryId > 1000";
String executeExpr = "product.finalScore = product.staticScore * 0.6 + product.relationScore * 0.3; product";
GroovyShellRule rule = new GroovyShellRule(supportExpr, executeExpr);

除了直接调用脚本之外,GroovyShell还允许我们定义和调用函数,比如我们将上面的executeExpr逻辑通过一个函数实现的话

private String functions = """
        def support(p) {
           return p.id % 2 == 1 && p.categoryId > 1000
        }
        def execute(p) {
            p.finalScore = p.staticScore * 0.6 + p.relationScore * 0.3; 
            return p;
        }
        """;
private Object eval(String method, Product product) {
    GroovyShell shell = new GroovyShell();
    Script script = shell.parse(functions);
    return script.invokeMethod(method, product);
}
2.3 GroovyScriptEngine

GroovyScriptEngine和GroovyClassLoader类似,不同的是GroovyScriptEngine指定根目录,通过文件名自动加载根目录下的文件,创建了instance实例之后,逻辑和GroovyClassLoader的实现就完全相同了。

public void init() throws Exception {
    GroovyScriptEngine engine = new GroovyScriptEngine("src/main/java/groovy");
    Class clazz = engine.loadScriptByName("TemporaryGroovySubClass.java");
    instance = clazz.newInstance();
}

3. Spring的lang:groovy

当今主流的Java应用,尤其是Web端应用,基本都托管在Spring容器下,如果代码由变更的情况下,Bean实例的逻辑自动变更的话,还是很方便的。我定义几个最简单的类

public interface ProductFactory {
    public Product getProduct();
}

我们期望动态加载的实现,测试过程中,我会修改id字段的值,来查看Bean是否重新加载

public class ProductFactoryImpl implements ProductFactory{
    public Product getProduct() {
        Product p = new Product();
        p.setId(1L);
        return p;
    }
}

XML文件配置



    

测试代码

public class SpringMain {

    public static void main(String[] args) throws InterruptedException {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
        ProductFactory factory = (ProductFactory) context.getBean("factory");
        while (true) {
            Thread.sleep(1000);
            System.out.println(factory.getProduct());
        }
    }
}
3.1 实现原理

生成的Bean是Spring提供的代理Bean,通过AOP生成代理对象,代理对象下面包含实际的数据对象,通过刷新这个数据对象让Bean表现的像是自动更新。

3.2 无法转型

一开始我没有为ProductFactoryImpl定义接口,在Java的main方法里直接引用了ProductFactoryImpl类(因为他也在ClassPath下),这回导致Java的类加载器加载这个Class对象。运行时再次加载ProductFactoryImpl,成为一个新的Class对象。而这两个Class对象分属于不同的类加载,相互之间无法转换,也无法赋值。

同样是因为一开始没有定义接口,导致设置必须使用类代理proxy-target-class="true"配置最终导致如下报错

Java实现动态加载的逻辑_第3张图片

究其原因是在AOP调用的时候,通过method实例反射调用,而执行过程中却发现这个method不是target对象里的method。具体证据如下:

Java实现动态加载的逻辑_第4张图片

target上的getProduct方法,和invokeJoinpointUsingReflection的method方法已经不是同一个实例。

总的来说,要想正确的使用,需要注意两点,为script-source执行的对象设计接口,不用指定proxy-target-class。通过日志可以看到product.id的修改是生效的。

Java实现动态加载的逻辑_第5张图片

4. JavaCC自定义DSL

JavaCC定义自己的DSL提供了更多的灵活性,也会大大的增加成本,自己定义的DSL可能会有潜在的问题,后续我们会专门出一篇JavaCC的文章,敬请期待。

5. 我该如何选择

如果只支持简单的逻辑,ScriptEngine够用的情况下直接用ScriptEngine即可。对动态脚本的能力要求较高时选择Groovy的方案,要注意Class的回收。做成通过数据库/配置中心加载动态代码的改造相对较大,如果不介意依然依赖文件系统特定位置的文件的话,也不失为一种选择。

你可能感兴趣的:(最佳实践,java,spring)