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