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
了。
参考文档
Developing Applications with the Groovy Beans DSL
The Groovy Bean Definition DSL