Groovy脚本极限优化

createh53周前 (12-04)技术教程22

前段时间开发的项目,项目需求要求支持业务人员频繁业务需求变更,业务要求每次策略变更第一时间线上生效。结合项目业务需要,我们选择进行业务领域抽象,把业务变更的需求提炼成为脚本操作,每次业务人员对业务的操作变成为业务域的逻辑操作,针对业务流程上的不同需求变更就变成一条条脚本规则的动态变更。

因为团队主要开发语言是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就行。

  1. 我们对每次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脚本在调用和解析层面做的些许优化,还有考虑不周路过请不吝指点。

相关文章

C#即将回到巅峰,Java呢?

TIOBE前15名,C#排名仍然没有太大的变化,仍然是第5名。但占有率一直在稳步提高,已经从2017年的2.82%上升到了现在的8.21%。C#占有率最高峰已经是十年前2012年的事情了,当时占有率是...