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就行。

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腳本在調用和解析層面做的些許優化,文章標題浮誇了點惶恐惶恐,還有考慮不周路過請不吝指點。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章