前段时间开发的项目,项目需求要求支持业务人员频繁业务需求变更,业务要求每次策略变更第一时间线上生效。结合项目业务需要,我们选择进行业务领域抽象,把业务变更的需求提炼成为脚本操作,每次业务人员对业务的操作变成为业务域的逻辑操作,针对业务流程上的不同需求变更就变成一条条脚本规则的动态变更。
因为团队主要开发语言是java,我们调研了QL Express 和 Groovy等脚本,最终选定Groovy脚本作为我们的脚本语言。我们使用Groovy支持业务人员频繁需求变更方案,首先对相关需求抽象出业务域,业务需求开发变成Groovy脚本,开发获取(转换)业务域数据接口。每次业务人员需求变更,我们修改业务脚本,线上获取到脚本变化,解析脚本语法树分析脚本依赖业务域,通过对应的业务域数据接口获取数据,然后加载数据执行对应脚本得到结果。
本文主要关注对Java调用Groovy脚本所做的优化,本文的优化重点并不是对Groovy脚本执行性能的极致优化,就像我们调研选取Groovy脚本支持我们的业务需求综合性能和易用性综合考量的结果。
Groovy调用优化
下面说的所有关于Groovy优化都是基于GroovyShell执行Groovy脚本的极限优化,
1.因为我们的业务流程涉及大量脚本调用,Groovy作为脚本语言,每次Java调用业务变更需求的Groovy脚本,Groovy都要经过重新编译生成Class,并new一个ClassLoader去加载一个对象,导致每次调用Groovy脚本执行时间大部分花在脚本编译上,而且也会导致大量的编译脚本Class对账,运行一段时间后将perm暴涨。
2.高并发情况下,执行赋值binding对象后,真正执行run操作时,拿到的Binding对象可能是其它线程赋值的对象,会出现执行脚本结果混乱的情况。
针对以上存在的问题,我对Groovy脚本调用进行了优化解决以上问题。
1.首先我们通过给每个脚本生成一个md5,每次脚本首次执行,我们会把Groovy脚本生成的Script对象进行缓存,缓存设置一定的过期时间,保证下次同一个脚本执行直接调用Script就行。
2. 我们对每次Script执行通过锁保证每次执行的Binding不会出现多线程混乱的情况。
以上优化对应的代码如下:
public class GroovyUtil {
private static GroovyShell groovyShell;
static {
groovyShell = new GroovyShell();
}
public static Object execute(String ruleScript, Map<String, Object> varMap) {
String scriptMd5 = null;
try {
scriptMd5 = Md5Util.encryptForHex(ruleScript);
} catch (Exception e) {
}
Script script;
if (scriptMd5 == null) {
script = groovyShell.parse(ruleScript);
} else {
String finalScriptMd5 = scriptMd5;
script = GroovyCache.getValue(GroovyCache.GROOVY_SHELL_KEY_PREFIX + scriptMd5,
() -> Optional.ofNullable(groovyShell.parse(ruleScript, generateScriptName(finalScriptMd5))),
new TypeReference<Script>() {
});
if (script == null) {
script = groovyShell.parse(ruleScript, generateScriptName(finalScriptMd5));
}
}
// 此处锁住script,为了防止多线程并发执行Binding数据混乱
synchronized(script) {
Binding binding = new Binding(varMap);
script.setBinding(binding);
return script.run();
}
}
private static String generateScriptName(String scriptName) {
return "Script" + scriptName + ".groovy";
}
}
// 缓存类
public class GroovyCache {
private static Cache<String, Optional<Object>> localMemoryCache =
CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();
private static FLogger LOGGER = FLoggerFactory.getLogger(GroovyCache.class);
public static String GROOVY_SHELL_KEY_PREFIX = "GROOVY_SHELL#";
public static <T> T getValue(String key, Callable<Optional<Object>> load, TypeReference<T> typeReference) {
try {
Optional<Object> value = localMemoryCache.get(key, load);
if (value.isPresent()) {
return (T) value.get();
}
return null;
} catch (Exception ex) {
LOGGER.error("获取缓存异常,key:{} ", key, ex);
}
return null;
}
}
以上为本次对Groovy脚本执行性能和易用性综合取舍后的一些优化,其实,还有其它一些方面的优化,比如,Groovy脚本里面尽量都用Java静态类型,可能减少Groovy动态类型检查等。 具体关于Groovy 、 Java 性能对比优化的文章可以参见这篇 https://dzone.com/articles/groovy-20-performance-compared
Groovy解析优化
下面说说我们怎么对Groovy脚本进行解析优化,结合我们的业务需求,我们的业务Groovy脚本就是大段大段对业务域操作的脚本,我们需要对一大段脚本分析出来里面包含所有我们的业务域,然后,针对业务域,我们从对应接口获取业务数据,然后执行最新修改业务脚本执行我们对应操作。我以淘宝可能情况为例说明,比如,淘宝上线优惠活动,对应脚本如下:
def getMaxVipCoupon(def CUSTOMER, List COUPONS) {
Boolean isVip = CUSTOMER.get('IS_VIP')
if (isVip) {
// 假设会员可以选取优惠券中最高的一张折扣
def coupon = COUPONS.findAll { it.get('isValid') == 1 }.max { it.get('AMOUNT') }
if (coupon.get('AMOUNT') > 0) {
OUT.errNo = 0
OUT.expanding.put("COUPON_AMOUNT", coupon.get('AMOUNT'))
OUT.expanding.put("COUPON_DESC", "会员最高优惠")
}
} else {
OUT.errNo = 400
OUT.expanding.put("COUPON_AMOUNT", 0)
OUT.expanding.put("COUPON_DESC", "关注成为会员即可享受优惠")
}
}
getMaxVipCoupon(CUSTOMER,COUPONS)
根据脚本的优惠信息,我们要获取至少三个外部业务域 CUSTOMER 、COUPONS和OUT ,然后根据业务域获取对应的数据,给用户选出满足条件的最大优惠信息。
针对以上需求,Groovy基础库内置强大的脚本语法分析相关辅助类,通过查看官方类库,我们看到 ClassCodeVisitorSupport 提供强大对Groovy脚本解析功能,我们通过集成ClassCodeVisitorSupport抽象类,自定义重写提供的方法,我们可以对Groovy高级定制分析。比如,针对业务脚本解析包含业务域需求,我们做了针对ClassCodeVisitorSupport类做了如下实现:
class GroovyShellVisitor extends ClassCodeVisitorSupport implements GroovyClassVisitor {
private static List<String> EXCLUDE_IN_PARAM
= ImmutableList.of("args", "context", "this", "super");
private Map<String, Class> dynamicVariables = new HashMap<>();
private Set<String> declarationVariables = new HashSet<>();
/**
* 记录Groovy解析过程的变量
**/
@Override
public void visitVariableExpression(VariableExpression expression) { //变量表达式分析
super.visitVariableExpression(expression);
if (EXCLUDE_IN_PARAM.stream().noneMatch(x -> x.equals(expression.getName()))) {
if (!declarationVariables.contains(expression.getName())) {
if (expression.getAccessedVariable() instanceof DynamicVariable) { // 动态类型,变量类型都是Object
dynamicVariables.put(expression.getName(), expression.getOriginType().getTypeClass());
} else {
// 静态类型 Groovy支持静态类型
dynamicVariables.put(expression.getName(), expression.getOriginType().getTypeClass());
}
}
}
}
/**
* 获取脚本内部声明的变量
*/
@Override
public void visitDeclarationExpression(DeclarationExpression expression) {
// 保存脚本内部定义变量
declarationVariables.add(expression.getVariableExpression().getName());
super.visitDeclarationExpression(expression);
}
/**
* 忽略对语法树闭包的访问
*/
@Override
public void visitClosureExpression(ClosureExpression expression) {
// ignore
}
public Set<String> getDynamicVariables() {
return dynamicVariables.keySet();
}
public Map<String, Class> getDynamicVarAndClass() {
return dynamicVariables;
}
@Override
protected SourceUnit getSourceUnit() {
return null;
}
}
其实个人从开始选型脚本调研,简单翻了了下源码的一些设计和看了几个使用Groovy的例子,很快就确定下来Groovy做为脚本能满足项目需求,佩服Groovy官方类库提供的强大支持和Groovy跟Java深度结合的易用性,据说某大厂的风控系统就是基于Groovy一点点写出来。
然后,具体获取脚本对应的业务域和业务域对应的类型实现如下:
/**
* 从缓存获取脚本的变量和变量类型 (Groovy2.0 支持Java强类型定义)
*/
public static Map<String, Class> getCacheBoundVarAndClassMap(final String scriptText, ClassLoader parent) {
String scriptMd5 = null;
try {
scriptMd5 = Md5Util.encryptForHex(scriptText);
} catch (Exception e) {
}
GroovyShellVisitor visitor;
Map<String, Class> dynamicVariableAndClass;
if (scriptMd5 == null) {
visitor = analyzeScriptVariables(scriptText, parent);
dynamicVariableAndClass = visitor.getDynamicVarAndClass();
} else {
dynamicVariableAndClass = GroovyCache.getValue(GroovyCache.SCRIPT_SHELL_KEY_PREFIX + scriptMd5,
() -> Optional.ofNullable(analyzeScriptVariables(scriptText, parent).getDynamicVarAndClass()),
new TypeReference<Map<String, Class>>() {
});
if (dynamicVariableAndClass == null) {
visitor = analyzeScriptVariables(scriptText, parent);
dynamicVariableAndClass = visitor.getDynamicVarAndClass();
}
}
return dynamicVariableAndClass;
}
/**
* 获取脚本的外部参数类型
*/
public static Set<String> getBoundVars(final String scriptText, ClassLoader parent) {
GroovyShellVisitor visitor = analyzeScriptVariables(scriptText, parent);
return visitor.getDynamicVariables();
}
/**
* 解析脚本得到Visitor
*/
private static GroovyShellVisitor analyzeScriptVariables(String scriptText, ClassLoader parent) {
assert scriptText != null;
GroovyClassVisitor visitor = new GroovyShellVisitor();
ScriptVariableAnalyzer.VisitorClassLoader myCL = new ScriptVariableAnalyzer.VisitorClassLoader(visitor, parent);
// simply by parsing the script with our classloader
// our visitor will be called and will visit all the variables
myCL.parseClass(scriptText);
return (GroovyShellVisitor) visitor;
}
类似上面对Groovy脚本调用缓存优化,我们对调用getCacheBoundVarAndClassMap方法同样通过缓存优化,我们可以高效获取脚本的包含业务域和对应业务域的类型。
以上是对自己使用Groovy脚本在调用和解析层面做的些许优化,还有考虑不周路过请不吝指点。