我們專案組主要負責面向企業客戶的業務系統,企業的需求往往是多樣化且複雜的,對接不同企業時會有不同的客製化化的業務模型和流程。我們在業務系統中使用表示式引擎,集中設定管理業務規則,並實現實時決策和計算,可以提高系統的靈活性和響應能力,從而更好地滿足業務的需求。
舉個簡單的例子,假設我們有一個業務場景,在返利系統中,當推廣員滿足一定的獎勵條件時,就會給其對應的獎勵金額。例如某個產品的具體獎勵規則如下:
獎勵條件 | 獎勵金額 |
---|---|
拉新使用者數大於等於3個且客單價大於50元 | 100元 |
拉新使用者數大於等於5個且客單價大於100元 | 200元 |
拉新使用者數大於等於10個且客單價大於200元 | 500元 |
這個規則看起來很好實現,只要在程式碼裡寫幾個if else分支就可以了。但是如果返利系統對接了多家供應商,且每家提供的產品的獎勵規則都不同呢?再通過寫死的方式寫if else似乎就不太好了,每次增加修改刪除規則都需要系統發版上線。
引入規則引擎似乎就能解決這個問題,規則引擎的一個好處就是可以使業務規則和業務程式碼分離,從而降低維護難度,同時它還可以滿足業務人員通過編寫DSL或通過介面指定規則的訴求,這樣就可以在沒有開發人員參與的情況下建立規則了,這種說法聽起來似乎很有道理,但在實踐中卻很少行得通。首先,規則引擎有一定的學習成本,即使開發人員使用也需要進行專門的學習,更何況沒有任何程式設計背景的業務人員,其次,其實現的複雜度也高,如果業務規則複雜,規則制定者對規則引擎內部隱藏的程式流程不瞭解,很可能會得到意想不到的結果,最後,有些規則引擎還存在效能瓶頸。如果對規則引擎和表示式引擎都不熟悉,抽離的業務規則又需要由開發人員來制定,那麼相比之下表示式引擎就要容易上手的多,其語法更接近Java,而且有些表示式引擎還會將表示式編譯成位元組碼,在執行速度和資源利用方面可能就更有優勢。所以,對於此類業務場景,使用表示式引擎似乎更加合適一些。
本文主要對Java表示式引擎進行概要性介紹和分析,並提供一定建議,為團隊研發過程中對錶示式引擎的技術選型提供輸入。
本文將針對AviatorScript、MVEL、OGNL、SpEL、QLExpress、JEXL、JUEL幾種常見表示式引擎進行選型調研。先簡單介紹一下這幾種表示式引擎。
AviatorScript 是一門高效能、輕量級寄宿於 JVM 之上的指令碼語言。AviatorScript 可將表示式編譯成位元組碼。2010年作者在淘寶中介軟體負責Notify內部訊息中介軟體時開發並開源。它原來的定位一直只是一個表示式引擎,不支援 if/else 條件語句,也不支援for/while迴圈語句等,隨著5.0的釋出變身為一個通用指令碼語言,支援了這些語言特性。
檔案:https://www.yuque.com/boyan-avfmj/aviatorscript
MVEL是一種混合的動態/靜態型別的、可嵌入Java平臺的表示式語言,MVEL被眾多Java專案使用。MVEL 在很大程度上受到 Java 語法的啟發,但也有一些本質區別,目的是使其作為一種表示式語言更加高效,例如直接支援集合、陣列和字串匹配的操作符,以及正規表示式。最早版本釋出於2007年。
檔案:http://mvel.documentnode.com/
OGNL 是 Object-Graph Navigation Language(物件圖導航語言)的縮寫;它是一種表示式語言,用於獲取和設定 Java 物件的屬性,以及其他額外功能,如列表投影和選擇以及 lambda 表示式。於2005年釋出2.1.4版。
檔案:https://commons.apache.org/dormant/commons-ognl/language-guide.html
SpEL是一種功能強大的表示式語言,支援在執行時查詢和操作物件圖。該語言的語法與 Unified EL 相似,但提供了更多的功能,其中最主要的是方法呼叫和基本的字串模板功能。
檔案:https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/core.html
由阿里的電商業務規則、表示式(布林組合)、特殊數學公式計算(高精度)、語法分析、指令碼二次客製化等強需求而設計的一門動態指令碼引擎解析工具,於2012年開源。
檔案:https://github.com/alibaba/QLExpress
JEXL 旨在促進在 Java 編寫的應用程式和框架中實現動態指令碼功能。 JEXL 基於對 JSTL 表示式語言的一些擴充套件實現了一種表示式語言,支援 shell 指令碼或 ECMAScript 中的大部分構想。1.0版釋出於2005年。
檔案:https://commons.apache.org/proper/commons-jexl/reference/syntax.html
JUEL 是統一表示式語言 (EL) 的實現,該語言是 JSP 2.1 標準 (JSR-245) 的一部分,已在 JEE5 中引入。此外,JUEL 2.2 實現了 JSP 2.2 維護版本規範,完全符合 JEE6 標準。於2006年釋出2.1.0版本,2.2.7釋出於2014年。
檔案:https://juel.sourceforge.net/guide/start.html
Janino是一個超小、超快的Java編譯器,也可以用作表示式引擎,它的效能非常出色,根據官網介紹,Apache Spark、Apache Flink、Groovy等優秀的開源專案都在用Janino。
檔案:http://janino-compiler.github.io/janino/
由於Janino實際是一個Java編譯器,理論上其效能應該更接近於直接執行Java程式碼,其次作為表示式引擎使用起來比較複雜。因此,下面的對比中,Janino不參與比較,可以將其作為一個參照。
如下一些表示式引擎雖然也常見於各技術部落格,但由於長期沒有更新維護,因此沒有納入此次選型比較
Fel
Fel是輕量級的高效的表示式計算引擎。Fel源自於企業專案,設計目標是為了滿足不斷變化的功能需求和效能需求。專案託管於Google Code,上次更新是2012年,已經十幾年沒有更新了,所以沒有納入此次選型。
ik-expression
IK Expression是一個開源的(OpenSource),可延伸的(Extensible),基於java語言開發的一個超輕量級(Super lightweight)的公式化語言解析執行工具包。2009年2月釋出第一個版本,2009年10月釋出最後一個版本後再沒有新版本釋出,所以沒有納入此次選型。
JSEL
JSEL是一個相容 JavaScript 運算規則的簡單的表示式解釋引擎,你可以通過Map介面,或者JavaBean給出一個變數集合,能後通過表示式從這個集合中抽取變數,再通過表示式邏輯生成你需要的資料。2009年釋出第一個版本,2011年釋出最後一個版本後未再更新,所以沒有納入此次選型。
此外規則引擎如 Drools, urule, easy-rules 不參與此次選型比較。相對比較成熟完善的指令碼語言如Groovy也不參與選型比較。這篇文章主要針對相對輕量簡單的表示式引擎進行選型。
選擇表示式引擎,我們希望其社群支援情況良好、實現複雜度適中、執行速度快、安全並且簡單易學。所以,接下來將從社群支援情況、引入的大小和依賴、效能、安全性、使用案例和語法幾個方面對幾種表示式引擎進行比較評估。
社群支援情況可以輔助評估專案的健康度,有問題是不是能及時解決,專案是不是能持續演進等等,下面列出了GitHub star,watch,fork,last commit等資料,可以作為參考,由於資料隨著時間推移會產生變化,以下僅針對2023.10.29的資料進行分析。
由於 Spring 專案被廣泛使用,而SpEl又是Spring的一個子專案,所以從各項資料來看SpEl的社群支援情況是最好的。下面先排除SpEl分析其他幾個表示式引擎。
QLExpress,AviatorScript 和 MVEL 在國內使用比較多,這可能是他們star,watch,fork數較高的原因。說明這幾個專案受歡迎度,受認可度,影響力應該較高。
從issues,pull requests數來分析,可以看到 MVEL,AviatorScript 和 QLExpress 高於其他指令碼引擎,說明他們的使用者需求和反饋較多,也可能意味著專案面臨較多問題和挑戰。
MVEL,JEXL,OGNL 均有較多貢獻者參與。他們的社群共同作業、專案可持續性方面應該都比較不錯。
綜合以上分析,除SpEl外,QLExpress,AviatorScript 和 MVEL 的社群支援情況都相對較好。
程式碼大小和依賴可以輔助評估程式碼的複雜性,下面列出了各個Github倉庫的程式碼大小,可以作為一個參考(實際並不完全準確反映其實現的複雜性)。
以下是2023.10.29的資料
JUEL,QLExpress程式碼大小最小,都在600多KB;其次是 OGNL 1MB多一點;AviatorScript,MVEL,JEXL 大小都在2MB左右;SpEl由於在 spring-framework 倉庫中,上表中統計的是 spring-framework 的總量,單純看 SpEl 的模組 spring-expression 的話,大小是1.3MB左右。但是其還依賴了 spring-core 和 spring-jcl,再含這兩個的話,大小 7.4MB左右。
我們再結合各個專案的依賴來分析一下。
+- org.mvel:mvel2:jar:2.5.0.Final:compile
+- com.googlecode.aviator:aviator:jar:5.3.3:compile
+- com.alibaba:QLExpress:jar:3.3.1:compile
| +- commons-beanutils:commons-beanutils:jar:1.8.2:compile
| | \- (commons-logging:commons-logging:jar:1.1.1:compile - omitted for conflict with 1.2)
| \- commons-lang:commons-lang:jar:2.4:compile
+- org.codehaus.janino:janino:jar:3.1.10:compile
| \- org.codehaus.janino:commons-compiler:jar:3.1.10:compile
+- ognl:ognl:jar:3.4.2:compile
| \- org.javassist:javassist:jar:3.29.2-GA:compile
+- org.apache.commons:commons-jexl3:jar:3.3:compile
| \- commons-logging:commons-logging:jar:1.2:compile
+- org.springframework:spring-expression:jar:5.3.29:compile
| \- org.springframework:spring-core:jar:5.3.29:compile
| \- org.springframework:spring-jcl:jar:5.3.29:compile
+- de.odysseus.juel:juel-api:jar:2.2.7:compile
+- de.odysseus.juel:juel-impl:jar:2.2.7:compile
+- de.odysseus.juel:juel-spi:jar:2.2.7:compile
除了SpEl外,QLExpress,OGNL,JEXL也都有其他依賴。
如果考慮 commons-beanutils, commons-lang, commons-logging 三個依賴,QLExpress 引入的大小在 10MB左右。
如果考慮 javassist 依賴,OGNL 引入的大小是4MB多。
如果考慮 commons-logging 依賴,JEXL 引入的大小是2.5MB左右。
綜合來看,JUEL,AviatorScript,MVEL,JEXL 在引入大小和依賴方面要好於其他。
較好的效能意味著系統能夠快速地響應使用者的請求,減少等待時間,提升體驗。
效能方面主要通過 JMH 在字面量表示式、含有變數的表示式以及含有方法呼叫的表示式等使用場景對幾個表示式引擎進行測試。
JMH(Java Microbenchmark Harness),是用於程式碼微基準測試的工具套件,主要是基於方法層面的基準測試,精度可以達到納秒級。該工具是由 Oracle 內部實現 JIT 的大牛們編寫的,他們應該比任何人都瞭解 JIT 以及 JVM 對於基準測試的影響。
由於不同表示式引擎語法或特性稍有差別,下面測試中對於差異項會進行說明。
效能測試程式碼地址: GitHub
:1000 + 100.0 * 99 - (600 - 3 * 15) / (((68 - 9) - 3) * 2 - 100) + 10000 % 7 * 71
:6.7 - 100 > 39.6 ? 5 == 5 ? 4 + 5 : 6 - 1 : !(100 % 3 - 39.0 < 27) ? 8 * 2 - 199 : 100 % 3
說明:
由於QlExpress執行第2個表示式時報錯,需要增加圓括號,實際執行的是6.7 - 100 > 39.6 ? (5 == 5 ? 4 + 5 : 6 - 1) : (!(100 % 3 - 39.0 < 27) ? 8 * 2 - 199 : 100 % 3)
結果分析:
可以明顯看到 JEXL,JUEL,QlExpress這三個表示式引擎效能明顯不如其他引擎。
SpEl 在執行第1個算數操作時表現出色,但是在執行第2個巢狀三元操作時明顯不如AviatorScript,MVEL,OGNL引擎。
此輪測試中 AviatorScript,OGNL,MVEL表現出色。AviatorScript,OGNL 執行兩個表示式表現都比較出色,其中AviatorScript略好於OGNL。 MVEL 在執行第1個算數操作時表現最出色,但是在執行第2個巢狀三元操作時慢於AviatorScript,OGNL引擎。
:pi * d + b - (1000 - d * b / pi) / (pi + 99 - i * d) - i * pi * d / b
:piDecimal * dDecimal + bDecimal - (1000 - dDecimal * bDecimal / piDecimal) / (piDecimal + 99 - iDecimal * dDecimal) - iDecimal * piDecimal * dDecimal / bDecimal
:i * pi + (d * b - 199) / (1 - d * pi) - (2 + 100 - i / pi) % 99 == i * pi + (d * b - 199) / (1 - d * pi) - (2 + 100 - i / pi) % 99
:(clientVersion == '1.9.0' || clientVersion == '1.9.1' || clientVersion == '1.9.2') && deviceType == 'Xiaomi' && weight >= 4 && osVersion == 'Android 9.0' && osType == 'Android' && clientIp != null && requestTime <= now&& customer.grade > 1 && customer.age > 18
說明:
decimal.divide(otherDecimal, java.math.MathContext.DECIMAL128)
,其他實際執行的是decimal.divide(otherDecimal, scale, roundingMode)
,只是引數略有不同,分析時分組進行。i * pi + (d * b - 199) / (1 - d * pi) - (int)(2 + 100 - i / pi) % 99 == i * pi + (d * b - 199) / (1 - d * pi) - (int)(2 + 100 - i / pi) % 99
(clientVersion == '1.9.0' || clientVersion == '1.9.1' || clientVersion == '1.9.2') && deviceType == 'Xiaomi' && weight >= 4 && osVersion == 'Android 9.0' && osType == 'Android' && clientIp != nil && requestTime <= now&& customer.grade > 1 && customer.age > 18
結果分析:
第1個基本型別包裝類的算術計算 SpEl 最優。其次是AviatorScript,MVEL,OGNL。而JEXL,JUEL,QlExpress則不如其他引擎。
第2個BigDecimal型別的算術計算。由於底層實現不同,分為兩組。第1組 MVEL、AviatorScript和JEXL,AviatorScript 優於 MVEL 優於 JEXL。第2組 JUEL,QlExpress,OGNL和SpEl,效能由優到差依次是 OGNL,SpEl,JUEL,QlExpress。並且第1組由於精度更高,效能明顯都差於第2組。
第3個含有基本型別包裝類算數計算的布林表示式。SpEl 最優,AviatorScript 次之,接下來依次是 OGNL, MVEL,JUEL,JEXL,QlExpress。
第4個含有字串比較的布林表示式。AviatorScript,MVEL,JEXL,OGNL 效能優於 JUEL,QlExpress,SpEl。
:new java.util.Date()
:s.substring(b.d)
:s.substring(b.d).substring(a, b.c.e)
說明:
new java.util.Date()
時報錯,不支援new範例,本輪實際執行的是自定義函數fn:date()
s.substring
時報錯,需使用其提供的內部函數,本輪實際執行的是其內部函數string.substring
此輪測試中 SpEl 的表現最優,甚至比Janino還要快。MVEL,AviatorScript次之,在執行構造方法時MVEL要好於AviatorScript。JEXL 表現也比較出色。QlExpress,JUEL,OGNL這三個表示式引擎則不如其他引擎。
綜合以上測試結果,AviatorScript,SpEl,MVEL,OGNL效能表現相對較好。
AviatorScript 效能相對較好,表現均衡,但其語法相較其他引擎跟Java的差異略大。
SpEl 除了在個別場景下效能較差,大部分場景表現非常出色,尤其是在字面量和含有變數的算數計算及方法呼叫場景下。
MVEL 效能表現相對均衡,含有變數的算術計算略差於AviatorScript,其在字面量算術計算,方法呼叫場景下表現都非常出色。
OGNL 效能表現也相對均衡,但方法呼叫場景下表現不佳。
引入表示式引擎,應該重視系統的安全性和可靠性,比如要防止在不可信環境中被注入惡意指令碼,越權執行某些系統命令或使應用停止服務等。安全性方面主要通過漏洞披露、安全指南和設定比較幾種表示式引擎。
首先在https://cve.mitre.org/cve/search_cve_list.html通過關鍵字搜尋的方式粗略瞭解一下不同表示式引擎被公開的漏洞。這種方式可能不是非常的準確,由於不同表示式引擎的使用場景、使用方式、關注度的不同可能導致被公開的漏洞存在差異。比如我們所熟悉的 OGNL、SpEl 的關鍵字出現在漏洞中的頻率明顯高於其他表示式引擎。OGNL 在MyBatis和Struts中被使用,SpEl則在Spring中被廣泛使用,這兩個表示式引擎會被大部分專案間接使用,直接將使用者輸入作為表示式的一部分執行,很容易導致出現漏洞。
我們可以從這些公佈的漏洞中瞭解不同表示式引擎可能存在的安全隱患及其修復情況,在使用過程中儘可能避免出現類似問題。
此外,不推薦將表示式執行直接開放到不可信的環境,如果確實需要,應該詳細瞭解選擇的表示式引擎,是否提供了必要的設定選項可以避免某些安全隱患。
AviatorScript,QLExpress,JEXL均從不同程度提供了一些安全選項設定。
AviatorScript
// 在new語句和靜態方法呼叫中允許使用的類白名單 預設 null 表示無限制
AviatorEvaluator.setOption(Options.ALLOWED_CLASS_SET, Sets.newHashSet(List.class));
// 在new語句和靜態方法呼叫中允許使用的類白名單 包含子類 預設 null 表示無限制
AviatorEvaluator.setOption(Options.ASSIGNABLE_ALLOWED_CLASS_SET, Sets.newHashSet(List.class));
// 迴圈最大次數 預設 0 表示無限制
AviatorEvaluator.setOption(Options.MAX_LOOP_COUNT, 10000);
// 關閉某些特性
AviatorEvaluator.getInstance().disableFeature(Feature.Module);
AviatorEvaluator.getInstance().disableFeature(Feature.NewInstance);
// 只開啟需要的特性
AviatorEvaluator.setOption(Options.FEATURE_SET, Feature.asSet(Feature.If));
QLExpress
QLExpressRunStrategy.setSandBoxMode(true);
在沙箱模式中,不可以:
◦import Java 類
◦顯式參照 Java 類,比如String a = 'mmm'
◦取 Java 類中的欄位:a = new Integer(11); a.value
◦呼叫 Java 類中的方法:Math.abs(12)
可以:
◦使用 QLExpress 的自定義操作符/宏/函數,以此實現與應用的受控互動
◦使用. 操作符獲取 Map 的 key 對應的 value,比如 a 在應用傳入的表示式中是一個 Map,那麼可以通過 a.b 獲取
◦所有不涉及應用 Java 類的操作
// 設定編譯期白名單
QLExpressRunStrategy.setCompileWhiteCheckerList(Arrays.asList(
// 精確設定
CheckerFactory.must(Date.class),
// 子類設定
CheckerFactory.assignable(List.class)
));
// 設定執行時白名單// 必須將該選項設定為 true
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true);
// 有白名單設定時, 則黑名單失效
QLExpressRunStrategy.addSecureMethod(RiskBean.class, "secureMethod");
// 必須將該選項設定為 true
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true);
// 這裡不區分靜態方法與成員方法, 寫法一致
// 不支援過載, riskMethod 的所有過載方法都會被禁止
QLExpressRunStrategy.addSecurityRiskMethod(RiskBean.class, "riskMethod");
QLExpess 目前預設新增的黑名單有:
◦java.lang.System.exit
◦java.lang.Runtime.exec
◦java.lang.ProcessBuilder.start
◦java.lang.reflect.Method.invoke
◦java.lang.reflect.Class.forName
◦java.lang.reflect.ClassLoader.loadClass
◦java.lang.reflect.ClassLoader.findClass
//可通過timeoutMillis引數設定指令碼的執行超時時間:1000ms
Object r = runner.execute(express, context, null, true, false, 1000);
JEXL
// 使用中應該通過JexlSandbox的過載構造方法進行設定
new JexlBuilder().sandbox(new JexlSandbox()).create();
new JexlBuilder().permissions(JexlPermissions.RESTRICTED.compose("com.jd.*")).create();
// 關閉迴圈、new 範例,import等特性
new JexlBuilder().features(new JexlFeatures().loops(false).newInstance(false).importPragma(false)).create();
從業界使用情況可以瞭解不同表示式引擎的可行性、生態和整合性,以及最佳實踐,進而借鑑。從下表可以看到AviatorScript,MVEL,QLExpress在國內業務線均有使用案例,有些企業也有文章輸出,我們可以借鑑使用。
名稱 | 案例 |
---|---|
AviatorScript | liteflow,京東星鏈 |
MVEL | easy-rules,compileflow,京東星鏈 |
OGNL | MyBatis,Struts |
SpEl | Spring |
QLExpress | compileflow,liteflow,阿里內部業務線 |
JEXL | cat,Jelly |
JUEL | JSP |
Janino | Apache Spark、Apache Flink、Groovy |
易於理解和使用的語法可以提高開發效率,並降低學習成本。接下來從型別、操作符、控制語句、集合、方法定義幾方面比較一下不同表示式引擎的語法設計。
型別方面,AviatorScript 設計了特有的型別,使用時需要注意其型別轉換的優先順序long->bigint->decimal->double。AviatorScript、MVEL、OGNL、JEXL都支援BigInteger、BigDecimal字面量,這意味著進行精確計算時可以使用字面量,將更方便,如10.24B
就表示一個BigDecimal字面量(AviatorScript中BigDecimal字面量字尾是M)。此外AviatorScript、QLExpress還支援高精度計算的設定項。
操作符方面,QLExpress支援替換、自定義操作符及新增操作符別名,這可能有助於簡化複雜表示式或使表示式更加直觀,不過新增預置函數應該可以達到差不多的效果。AviatorScript也支援自定義部分操作符,不過支援數量相當有限。AviatorScript、SpEl、JEXL支援正則匹配操作符。
控制語句方面,除OGNL、SpEl、JUEL不支援控制語句外,其他都支援,不過需要注意 AviatorScript 的 else if
語法有些特殊寫作 elsif
,foreach語句跟Java也有所不同。
集合方面,除JUEL外其他都提供了快捷定義的方式,只不過語法不同。
函數定義方面,SpEl、JUEL均不支援,OGNL支援偽lambda定義,其他都支援定義函數。QLExpress不支援定義lambda。
綜合來看,和Java語法都或多或少存在一些差異。AviatorScript設計了自己特有的一些語法,使用的話需要熟悉一下。QLExpress支援自定義操作符,可以使表示式看起來更直觀。MVEL、JEXL的語法可能更接近Java,讓人更容易接受一些。OGNL、SpEl、JUEL的語法更簡單一些,不支援控制語句和函數定義,當然也可以通過預置一些函數變通解決一些較複雜的問題。
社群方面,SpEl無疑是最活躍的。AviatorScript,QLExpress,MVEL在國內很受歡迎,QLExpress 有阿里背書。
程式碼大小和依賴方面,AviatorScript,MVEL 依賴少,並且程式碼大小也偏小。
效能方面,如果你使用表示式引擎執行字面量算術計算或方法呼叫偏多可以選用SpEl,MVEL。如果希望整體效能表現較好可以選用 AviatorScript。
安全方面,如果想自定義安全選項,可以考慮 AviatorScript,QLExpress和JEXL。
使用案例方面,AviatorScript,MVEL,QLExpress在國內都有實際使用案例可循。
語法方面,可能存在一些主觀因素,僅供參考,個人覺得MVEL、JEXL的語法設計使用起來會更容易一些。
通過對以上幾個方面的評估和分析,希望可以幫助團隊基於自身情況及偏好選擇最適合自己專案的Java表示式引擎。
QLExpress: https://github.com/alibaba/QLExpress
AviatorScript: https://github.com/killme2008/aviatorscript
MVEL: https://github.com/mvel/mvel
OGNL: https://github.com/orphan-oss/ognl
SpEl: https://github.com/spring-projects/spring-framework
Janino: https://github.com/janino-compiler/janino
JUEL: https://github.com/beckchr/juel
JEXL: https://github.com/apache/commons-jexl
Fel: https://github.com/dbcxy/fast-el
ik-expression: https://code.google.com/archive/p/ik-expression/
JSEL: https://code.google.com/archive/p/lite/wikis/JSEL.wiki
JMH: https://www.cnblogs.com/wupeixuan/p/13091381.html
作者:京東科技 馮浩
來源:京東雲開發者社群 轉發請註明來源