Java搭配Groovy实现动态公式计算

最近项目用到了动态公式计算,也就是页面配置一个公式,服务端进行计算。我们后端使用Java,Java处理这部分公式可以使用表达式引擎Aviator,Jexl。也可以以使用JVM上的脚本语言像Jython,Groovy。表达式引擎不是一门单独的语言,局限性比较高,由于我对Python比较熟悉,最开始准备使用Jython,查了查资料发现和Java配合不是很好,最终决定使用Groovy,Groovy兼容Java的语法,不熟悉的地方可以直接使用Java语法。

GroovyShell

可以使用GroovyShell根据公式求值

// 绑定公式中的变量和对应的值
Binding binding = new Binding()
binding.setVariable("a",1)
binding.setVariable("b",2)

GroovyShell shell = new GroovyShell(binding);

// 进行计算 返回值是3
def result = shell.evaluate('a + b')

接口定义

接口使用Java定义

public interface DynamicFormulaService {

    /**
     * 根据公式和source中的值计算
     * @param source 源数据
     * @param formulaMap 公式
     * @return
     */
    List<Map<String, Object>> compute(List<Map<String, Object>> source , Map<String, String> formulaMap);
}

source为要被统计的源数据列表,key为公式中变量,如

{ price : 1.5 , quantity : 400 , total : null }

formulaMap中的key为结果,value为公式,如

{ total : price * quantity }

返回的结果为{ price : 1.5 , quantity : 400 , total : 600 }

在计算数值之前,会先遍历formulaMap,将value中有计算公式的数据进行递归拆分,完成嵌套公式解析

{a : b * c , b : d * e}解析的结果为{a : ( d * e ) * c , b : d * e}

实现

具体的实现使用Groovy,我这里在resources下新建了一个groovy的包存放我们的脚本

class DynamicFormulaServiceImpl implements DynamicFormulaService {

    // 匹配加减乘除符号
    def pattern = Pattern.compile('[\\+\\-\\*\\/\\(\\)]')
    // 加减乘除符号
    def formulaString = '''+-*/()'''


    @Override
    List<Map<String, Object>> compute(List<Map<String, Object>> source, Map<String, String> formulaMap) {
        formulaMap.each { k, v ->
            // 递归格式化公式
            formulaMap.put(k,formatFormula(formulaMap, k))
        }

        println formulaMap

        def result = this.compute(source, formulaMap)

        return result
    }

    def formatFormula(Map<String, String> formulaMap, String key) {
        String formula = formulaMap.get(key)
        // 是否含有公式
        if (!formula || formula.matches(pattern)) return key

        // 将变量用空格分隔方便替换
        formula = " ${formula} "
        formulaString.chars.each { c ->
            formula = formula.replace(c.toString(), " ${c} ".toString())
        }

        formula.splitEachLine(pattern, { v ->
            v.each {
                k ->
                    k = k.trim()
                    if (StringUtils.isEmpty(k) || StringUtils.isNumeric(k)) return
                    // 精确匹配替换
                    formula = formula.replaceFirst(" ${k} ", formatFormula(formulaMap, k))
            }
        })
        return "(${formula})"
    }

    def compute(List<Map<String, Object>> source, formulaMap) {
        source.each { e ->
            Binding binding = new Binding(e)
            formulaMap.each { k, v ->
                if (e.containsKey(k)) {
                    GroovyShell shell = new GroovyShell(binding);
                    def result = shell.evaluate(v)
                    println "${k} : ${result}"
                    e.put(k, result)
                }
            }
        }
        return source
    }
}

两种集成方式

groovy虽然是jvm平台上的语言,可编译成字节码运行,但毕竟不是Java本身,和Java的交互还需要一定的配置,我下面介绍两种配置方式。

Spring集成

DynamicFormulaServiceImpl还不能直接使用,要将它作为一个bean交给Spring统一管理,Spring也提供了对Groovy的支持

appcontextContext.xml

<?xml version="1.0" encoding="UTF-8"?>
<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 https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/lang https://www.springframework.org/schema/lang/spring-lang.xsd">

    <lang:groovy id="dynamicFormulaService" script-source="classpath:groovy/DynamicFormulaServiceImpl.groovy">
    </lang:groovy>
</beans>

Spring Framework 4.0 也支持beans{}写法

applicationContext.groovy

import groovy.DynamicFormulaServiceImpl
beans {
    dynamicFormulaService(DynamicFormulaServiceImpl)
}

如果想热部署,定期扫描脚本变化,可通过配置ScriptFactoryPostProcessor来实现


beans {
    scriptFactoryPostProcessor(ScriptFactoryPostProcessor) {
        defaultRefreshCheckDelay = 5000
    }
    dynamicFormulaService(GroovyScriptFactory,'classpath:groovy/DynamicFormulaServiceImpl.groovy')

    // 也可以读取配置文件取路径
    String scriptPath = environment.getProperty('script.path')
    dynamicFormulaService(GroovyScriptFactory,'${scriptPath}DynamicFormulaServiceImpl.groovy')

}

引用以上配置

@SpringBootApplication
@ImportResource("classpath:applicationContext.groovy")
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Maven插件编译

通过groovy/GMavenPlus插件可在build时将groovy代码编译成jvm的class字节码文件。

<plugin>
    <groupId>org.codehaus.gmavenplus</groupId>
    <artifactId>gmavenplus-plugin</artifactId>
    <version>1.11.0</version>
    <executions>
        <execution>
            <goals>
                <goal>addSources</goal>
                <goal>addTestSources</goal>
                <goal>generateStubs</goal>
                <goal>compile</goal>
                <goal>generateTestStubs</goal>
                <goal>compileTests</goal>
                <goal>removeStubs</goal>
                <goal>removeTestStubs</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <sources>
        <source>
        <directory>${project.basedir}/src/main/java</directory>
        <includes>
            <include>**/*.groovy</include>
        </includes>
        </source>
        </sources>
    </configuration>
</plugin>

默认source目录为${project.basedir}/src/main/groovy,我这里将groovy文件放在了Java工程中,所以单独加了一些配置。

总结

好了,现在我们的动态公式功能就可以使用了,由于Groovy我也是第一次使用,很多东西都是现用现查,用起来还不太灵活。之后我会深入学习一下,把我感觉有趣的东西写出来。

补充

上线后发现占用内存过高,怀疑有内存泄漏,查找资料发现是GroovyShell导致的,Groovy会动态加载Class,并且这个Class不会被回收,导致内存泄漏。想解决这个问题也比较简单,接入缓存即可,将脚本缓存起来,不用频繁加载,这部分功能GroovyScriptEngineImpl已经帮我们写好了,GroovyScriptEngineImpl是JSR-223(Scripting for the Java Platform)的Groovy实现。


<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-jsr223</artifactId>
    <version>${groovy.version}</version>
</dependency>

使用方法也比较简单


ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");
engine.eval("(1..10).forEach(e -> println e)");

我将原来的compute改写,获取ScriptEngine部分,我写到了外面,防止重复创建对象,减少开销


def compute(List<Map<String, Object>> source, formulaMap) {
        source.each { e ->
            Bindings binding = new SimpleBindings(e)
            formulaMap.each { k, v ->
                if (e.containsKey(k)) {
                    def result = engine.eval(v as String,binding)
                    println "${k} : ${result}"
                    e.put(k, result)
                }
            }
        }
        return source
    }

上线发现不光内存泄漏问题解决,性能也提高了,因为不用频繁动态的创建和加载Class了。

参考文档

Spring Apache Groovy

Developing Applications with the Groovy Beans DSL

The Groovy Bean Definition DSL

springboot集成groovy脚本

Groovy Bean Configuration in Spring Framework 4

Maven中java与Groovy的混合开发

GMavenPlus Examples


Java搭配Groovy实现动态公式计算
https://blog.yjll.blog/post/7b75058.html
作者
简斋
发布于
2020年7月15日
许可协议