Java实现动态加载的逻辑

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

常见的可选方案有:

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

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

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

java 复制代码
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接口

java 复制代码
package com.lws.rule;


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

RuleEngine实现

java 复制代码
package com.lws.rule;

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

public class RuleEngine {

    private List<Rule> 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的依赖

XML 复制代码
<dependency>
    <groupId>org.openjdk.nashorn</groupId>
    <artifactId>nashorn-core</artifactId>
    <version>15.4</version>
</dependency>

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

1.2 具体实现

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

java 复制代码
private Object eval(String expr, Map<String, Object> 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都通过执行脚本返回的结果做为输出,而这两个脚本是可配置的,甚至可以从数据库、配置中心里读取

java 复制代码
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<String, Object> context);
}
1.3 测试结果

我们预先定义了一条数据

java 复制代码
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的执行结果返回。

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

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

java 复制代码
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,他会将所有调用委托给通过反射创建的实例。

java 复制代码
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永久代空间

java 复制代码
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方法的实现

java 复制代码
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方法

java 复制代码
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关键字

java 复制代码
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逻辑通过一个函数实现的话

java 复制代码
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的实现就完全相同了。

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

3. Spring的lang:groovy

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

java 复制代码
public interface ProductFactory {
    public Product getProduct();
}

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

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

XML文件配置

XML 复制代码
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:lang="http://www.springframework.org/schema/lang"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/lang http://www.springframework.org/schema/lang/spring-lang-2.5.xsd">

    <lang:groovy id="factory" refresh-check-delay="5000" script-source="file:D:/Workspace/groovy/ProductFactoryImpl.java"/>

</beans>

测试代码

java 复制代码
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 实现原理

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

3.2 无法转型

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

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

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

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

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

4. JavaCC自定义DSL

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

5. 我该如何选择

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

相关推荐
爱的叹息14 分钟前
Java 集合框架中 `List` 接口及其子类的详细介绍,并用 UML 图表展示层次结构关系,用表格对比各个类的差异。
java·list·uml
qzw121042 分钟前
Java与Elasticsearch集成详解,以及使用指南
java·elasticsearch·jenkins
爱的叹息42 分钟前
分别用树型和UML结构展示java集合框架常见接口和类
java·开发语言·uml
马院代表人43 分钟前
Java入职篇(4)——git的使用
java·git·职场和发展
猿六凯1 小时前
历年云南大学计算机复试上机真题
java·华为od·华为
尽力不摆烂的阿方1 小时前
《图解设计模式》 学习笔记
java·笔记·学习·设计模式
Java韩立2 小时前
基于Spring Boot的航司互售系统
java·spring boot·后端
东阳马生架构3 小时前
Netty基础—4.NIO的使用简介二
java·网络·netty
陌路物是人非3 小时前
MinIo前后端实现
java·docker·html·minio
字节源流3 小时前
【SpringMVC】常用注解:@ModelAttribute
java·开发语言