重新認識訪問者模式:從實踐到本質

訪問者模式在設計模式中的知名度雖然不如單例模式,但也是少數幾個大家都能叫得上名字的設計模式了(另外幾個可能就是“觀察者模式”,“工廠模式” 了)。不過因為訪問者模式的複雜性,人們很少在應用系統中使用,經過本文的探索,我們一定會產生新的認識,發現其更加靈活廣泛的使用方式。
和一般介紹設計模式的文章不同,本文不會執著於死板的程式碼模板,而是直接從開源專案以及應用系統中的實踐出發,同時對比其他類似的設計模式,最後闡述其在程式設計正規化中的本質。

一  Calcite 中的訪問者模式

開源類庫常常利用訪問者風格的 API 遮蔽內部的複雜性,從這些 API 入手學習,能夠讓我們先獲得一個直觀感受。
Calcite 是一個 Java 語言編寫的資料庫基礎類庫,諸如 Hive,Spark 等諸多知名開源專案都在使用。其中 SQL 解析模組提供了訪問者模式的 API,我們利用它的 API 可以快速獲取 SQL 中我們需要的資訊,以獲取 SQL 中使用的所有函式為例:
import org.apache.calcite.sql.SqlCall;import org.apache.calcite.sql.SqlFunction;import org.apache.calcite.sql.SqlNode;import org.apache.calcite.sql.parser.SqlParseException;import org.apache.calcite.sql.parser.SqlParser;import org.apache.calcite.sql.util.SqlBasicVisitor;import java.util.ArrayList;import java.util.List;publicclass CalciteTest {publicstaticvoid main(String[] args) throws SqlParseException {String sql = "select concat('test-', upper(name)) from test limit 3"; SqlParser parser = SqlParser.create(sql); SqlNode stmt = parser.parseStmt(); FunctionExtractor functionExtractor = newFunctionExtractor(); stmt.accept(functionExtractor);// [CONCAT, UPPER] System.out.println(functionExtractor.getFunctions()); }privatestaticclass FunctionExtractor extends SqlBasicVisitor<Void> {private final List<String> functions = newArrayList<>();@Overridepublic Void visit(SqlCall call) {if (call.getOperator() instanceof SqlFunction) {functions.add(call.getOperator().getName()); }returnsuper.visit(call); }public List<String> getFunctions() {returnfunctions; } }}
程式碼中 FunctionExtractor 是 SqlBasicVisitor 的子類,並且重寫了它的 visit(SqlCall) 方法,獲取函式的名稱並收集在了 functions 中。
除了 visit(SqlCall) 外,還可以透過 visit(SqlLiteral)(常量),visit(SqlIdentifier)(表名/列名)等等,實現更加複雜的分析。
有人會想,為什麼 SqlParser不直接提供類似於 getFunctions 等方法直接獲取 SQL 中的所有函式呢?在上文的示例中,getFunctions 可能確實更加方便,但是 SQL 作為一個很複雜的結構,getFunctions 對於更加複雜的分析場景是不夠靈活的,效能也是更差的。如果需要,完全可以很簡單地實現一個如上文的 FunctionExtractor 來滿足需求。

二  動手實現訪問者模式

我們嘗試實現一個簡化版的 SqlVisitor
先定義一個簡化版的 SQL 結構。
將 select upper(name) from test where age > 20; 拆解到這個結構上層級關係如圖:
我們直接在 Java 程式碼中將上圖的結構構造出來:
SqlNode sql = new SelectNode(new FieldsNode(Arrays.asList(new FunctionCallExpression("upper", Arrays.asList(new IdExpression("name") )) )), Arrays.asList("test"),new WhereNode(Arrays.asList(new OperatorExpression(new IdExpression("age"),">",new LiteralExpression("20") ))) );
這個類中都有一個相同的方法,就是 accept:
@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }
這裡會透過多型分發到 SqlVisitor 不同的 visit 方法上:
abstractclassSqlVisitor<R> {abstract R visit(SelectNode selectNode);abstract R visit(FieldsNode fieldsNode);abstract R visit(WhereNode whereNode);abstract R visit(IdExpression idExpression);abstract R visit(FunctionCallExpression functionCallExpression);abstract R visit(OperatorExpression operatorExpression);abstract R visit(LiteralExpression literalExpression);}
SQL 結構相關的類如下:
abstractclassSqlNode{// 用來接收訪問者的方法publicabstract <R> R accept(SqlVisitor<R> sqlVisitor);}classSelectNodeextendsSqlNode{privatefinal FieldsNode fields;privatefinal List<String> from;privatefinal WhereNode where; SelectNode(FieldsNode fields, List<String> from, WhereNode where) {this.fields = fields;this.from = from;this.where = where; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }//... get 方法省略}classFieldsNodeextendsSqlNode{privatefinal List<Expression> fields; FieldsNode(List<Expression> fields) {this.fields = fields; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}classWhereNodeextendsSqlNode{privatefinal List<Expression> conditions; WhereNode(List<Expression> conditions) {this.conditions = conditions; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}abstractclassExpressionextendsSqlNode{}classIdExpressionextendsExpression{privatefinal String id;protected IdExpression(String id) {this.id = id; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}classFunctionCallExpressionextendsExpression{privatefinal String name;privatefinal List<Expression> arguments; FunctionCallExpression(String name, List<Expression> arguments) {this.name = name;this.arguments = arguments; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}classLiteralExpressionextendsExpression{privatefinal String literal; LiteralExpression(String literal) {this.literal = literal; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}classOperatorExpressionextendsExpression{privatefinal Expression left;privatefinal String operator;privatefinal Expression right; OperatorExpression(Expression left, String operator, Expression right) {this.left = left;this.operator = operator;this.right = right; }@Overridepublic <R> R accept(SqlVisitor<R> sqlVisitor) {return sqlVisitor.visit(this); }}
有的讀者可能會注意到,每個類的 accept 方法的程式碼都是一樣的,那為什麼不直接寫在父類  SqlNode 中呢?如果嘗試一下就會發現根本無法透過編譯,因為我們的 SqlVisitor 中根本就沒有提供 visit(SqlNode),即使添加了  visit(SqlNode),通過了編譯,程式的執行結果也是不符合預期的,因為此時所有的 visit 呼叫都會指向  visit(SqlNode),其他過載方法就形同虛設了。
導致這種現象的原因是,不同的 visit 方法互相之間只有引數不同,稱為“過載”,而 Java 的 “過載” 又被稱為 “編譯期多型”,只會根據 visit(this) 中 this 在編譯時的型別決定呼叫哪個方法,而它在編譯時的型別就是 SqlNode,儘管它在執行時可能是不同的子類。
所以,我們可能經常會聽說用動態語言寫訪問者模式會更加簡單,特別是支援模式匹配的函式式程式設計語言(這在 Java 18 中已經有較好支援),後面我們再回過頭來用模式匹配重新實現下本小節的內容,看看是不是簡單了很多。
接下來我們像之前一樣,是使用 SqlVisitor 嘗試解析出 SQL中所有的函式呼叫。
先實現一個 SqlVisitor,這個 SqlVisitor 所作的就是根據當前節點的結構以此呼叫 accept,最後將結果組裝起來,遇到 FunctionCallExpression 時將函式名稱新增到集合中:
class FunctionExtractor extends SqlVisitor<List<String>> {@Override List<String> visit(SelectNode selectNode) { List<String> res = new ArrayList<>(); res.addAll(selectNode.getFields().accept(this)); res.addAll(selectNode.getWhere().accept(this));return res; }@Override List<String> visit(FieldsNode fieldsNode) { List<String> res = new ArrayList<>();for (Expression field : fieldsNode.getFields()) { res.addAll(field.accept(this)); }return res; }@Override List<String> visit(WhereNode whereNode) { List<String> res = new ArrayList<>();for (Expression condition : whereNode.getConditions()) { res.addAll(condition.accept(this)); }return res; }@Override List<String> visit(IdExpression idExpression) {return Collections.emptyList(); }@Override List<String> visit(FunctionCallExpression functionCallExpression) {// 獲得函式名稱 List<String> res = new ArrayList<>(); res.add(functionCallExpression.getName());for (Expression argument : functionCallExpression.getArguments()) { res.addAll(argument.accept(this)); }return res; }@Override List<String> visit(OperatorExpression operatorExpression) { List<String> res = new ArrayList<>(); res.addAll(operatorExpression.getLeft().accept(this)); res.addAll(operatorExpression.getRight().accept(this));return res; }@Override List<String> visit(LiteralExpression literalExpression) {return Collections.emptyList(); }}
main 中的程式碼如下:
publicstaticvoid main(String[] args) {// sql 定義 SqlNode sql = new SelectNode( //select// concat("test-", upper(name))new FieldsNode(Arrays.asList(new FunctionCallExpression("concat", Arrays.asList(new LiteralExpression("test-"),new FunctionCallExpression("upper", Arrays.asList(new IdExpression("name")) ) )) )),// from test Arrays.asList("test"),// where age > 20new WhereNode(Arrays.asList(new OperatorExpression(new IdExpression("age"),">",new LiteralExpression("20") ))) );// 使用 FunctionExtractor FunctionExtractor functionExtractor = newFunctionExtractor(); List<String> functions = sql.accept(functionExtractor);// [concat, upper] System.out.println(functions); }
以上就是標準的訪問者模式的實現,直觀感受上比之前 Calcite 的 SqlBasicVisitor 用起來麻煩多了,我們接下來就去實現 SqlBasicVisitor

三  訪問者模式與觀察者模式

在使用 Calcite 實現的  FunctionExtractor 中,每次 Calcite 解析到函式就會呼叫我們實現的 visit(SqlCall) ,稱它為 listen(SqlCall) 似乎比  visit 更加合適。這也顯示了訪問者模式與觀察者模式的緊密聯絡。
在我們自己實現的 FunctionExtractor 中,絕大多數程式碼都是在按照一定的順序遍歷各種結構,這是因為訪問者模式給予了使用者足夠的靈活性,可以讓實現者自行決定遍歷的順序,或者對不需要遍歷的部分進行剪枝。
但是我們的需求 “解析出 SQL 中所有的函式”,並不關心遍歷的順序,只要在經過“函式”時通知一下我們即可,對於這種簡單需求,訪問者模式有點過度設計,觀察者模式會更加合適。
大多數使用訪問者模式的開源專案會給“標準訪問者”提供一個預設實現,比如 Calcite 的 SqlBasicVisitor,預設實現會按照預設的順序對 SQL 結構進行遍歷,而實現者只需要重寫它關心的部分就行了,這樣就相當於在訪問者模式的基礎上又實現了觀察者模式,即不丟失訪問者模式的靈活性,也獲得觀察者模式使用上的便利性。
我們給自己的實現也來新增一個 SqlBasicVisitor 吧:
classSqlBasicVisitor<R> extendsSqlVisitor<R> {@Override R visit(SelectNode selectNode) { selectNode.getFields().accept(this); selectNode.getWhere().accept(this);returnnull; }@Override R visit(FieldsNode fieldsNode) {for (Expression field : fieldsNode.getFields()) { field.accept(this); }returnnull; }@Override R visit(WhereNode whereNode) {for (Expression condition : whereNode.getConditions()) { condition.accept(this); }returnnull; }@Override R visit(IdExpression idExpression) {returnnull; }@Override R visit(FunctionCallExpression functionCallExpression) {for (Expression argument : functionCallExpression.getArguments()) { argument.accept(this); }returnnull; }@Override R visit(OperatorExpression operatorExpression) { operatorExpression.getLeft().accept(this); operatorExpression.getRight().accept(this);returnnull; }@Override R visit(LiteralExpression literalExpression) {returnnull; }}
SqlBasicVisitor 給每個結構都提供了一個預設的訪問順序,使用這個類我們來實現第二版的 FunctionExtractor
class FunctionExtractor2 extends SqlBasicVisitor<Void> {private final List<String> functions = newArrayList<>();@Override Void visit(FunctionCallExpression functionCallExpression) {functions.add(functionCallExpression.getName());returnsuper.visit(functionCallExpression); }public List<String> getFunctions() {returnfunctions; }}
它的使用如下:
class Main {publicstaticvoid main(String[] args) { SqlNode sql = new SelectNode(new FieldsNode(Arrays.asList(new FunctionCallExpression("concat", Arrays.asList(new LiteralExpression("test-"),new FunctionCallExpression("upper", Arrays.asList(new IdExpression("name")) ) )) )), Arrays.asList("test"),new WhereNode(Arrays.asList(new OperatorExpression(new IdExpression("age"),">",new LiteralExpression("20") ))) ); FunctionExtractor2 functionExtractor = newFunctionExtractor2(); sql.accept(functionExtractor); System.out.println(functionExtractor.getFunctions()); }}

四  訪問者模式與責任鏈模式

ASM 也是一個提供訪問者模式 API 的類庫,用來解析與生成 Java 類檔案,能想到的所有 Java 知名開源專案都有他的身影,Java8 的 Lambda 表示式特性甚至都是透過它來實現的。如果只是能解析與生成 Java 類檔案,ASM 或許還不會那麼受歡迎,更重要的是它優秀的抽象,它將常用的功能抽象為一個個小的訪問者工具類,讓複雜的位元組碼操作變得像搭積木一樣簡單。
假設需要按照如下方式修改類檔案:
  1. 刪除 name 屬性
  2. 給所有屬性新增 @NonNull 註解
但是出於複用和模組化的角度考慮,我們想把兩個步驟分別拆成獨立的功能模組,而不是把程式碼寫在一起。在 ASM 中,我們可以分別實現兩個小訪問者,然後串在一起,就變成能夠實現我們需求的訪問者了。
刪除 name 屬性的訪問者:
class DeleteFieldVisitor extends ClassVisitor {// 刪除的屬性名稱, 對於我們的需求,它就是 "name"private final String deleteFieldName;public DeleteFieldVisitor(ClassVisitor classVisitor, String deleteFieldName) {super(Opcodes.ASM9, classVisitor);this.deleteFieldName = deleteFieldName; }@Overridepublic FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {if (name.equals(deleteFieldName)) {// 不再向下游傳遞該屬性, 對於下游來說,就是被 "刪除了"returnnull; }// super.visitField 會去繼續呼叫下游 Visitor 的 visitField 方法returnsuper.visitField(access, name, descriptor, signature, value); }}
給所有屬性新增 @NonNull 註解的訪問者:
classAddAnnotationVisitorextendsClassVisitor{publicAddAnnotationVisitor(ClassVisitor classVisitor){super(Opcodes.ASM9, classVisitor); }@Overridepublic FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value){ FieldVisitor fieldVisitor = super.visitField(access, name, descriptor, signature, value);// 向下遊 Visitor 額外傳遞一個 @NonNull 註解 fieldVisitor.visitAnnotation("javax/annotation/Nonnull", false);return fieldVisitor; }}
在 main 中我們將它們串起來使用:
publicclassAsmTest {publicstaticvoidmain(String[] args) throws URISyntaxException, IOException { Path clsPath = Paths.get(AsmTest.class.getResource("/visitordp/User.class").toURI());byte[] clsBytes = Files.readAllBytes(clsPath);// 串聯 Visitor// finalVisitor = DeleteFieldVisitor -> AddAnnotationVisitor -> ClassWriter// ClassWriter 本身也是 ClassVisitor 的子類 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); ClassVisitor finalVisitor = new DeleteFieldVisitor(new AddAnnotationVisitor(cw), "name");// ClassReader 就是被訪問的物件 ClassReader cr = new ClassReader(clsBytes); cr.accept(finalVisitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);byte[] bytes = cw.toByteArray(); Files.write(clsPath, bytes); }}
透過訪問者模式與責任鏈模式的結合,我們不再需要將所有的邏輯都寫在一個訪問者中,我們可以拆分出多個通用的訪問者,透過組合他們實現更加多種多樣的需求。

五  訪問者模式與回撥模式

“回撥” 可以算是“設計模式的設計模式”了,大量設計模式中都有它的思想,諸如觀察者模式中的“觀察者”,“命令模式”中的“命令”,“狀態模式” 中的 “狀態” 本質上都可以看成一個回撥函式。
訪問者模式中的“訪問者”顯然也是一個回撥,和其他回撥模式最大的不同是,“訪問者模式” 是一種帶有“導航”的回撥,我們透過傳入的物件結構給實現者下一步回撥的“導航”,實現者根據“導航”決定下一步回撥的次序。
如果我想先訪問 fieldA,再訪問 fieldB,最後 fieldC,對應到訪問者的實現就是:
visit(SomeObject someObject) { someObject.fieldA.accept(this); someObject.fieldB.accept(this); someObject.fieldC.accept(this);}
這在實際中的應用就是 HATEOAS (Hypermedia as the Engine of Application State),HATEOAS 風格的 HTTP 介面除了會返回使用者請求的資料外,還會包含使用者下一步應該訪問的 URL,如果將整個應用的 API 比作一個家的話,假如使用者請求客廳的資料,那麼介面除了返回客廳的資料外,還會返回與客廳相連的 "廚房",“臥室”與“衛生間”的 URL:
----請求----GET /sittingroomHOST home.example.comAccept: application/xml----服務端返回----HTTP/1.1 200 OKContent-Type: application/xml<?xml version="1.0"?><sittingroom><!-- 客廳相關資料 --><television>海康</television><sofa>宜家</sofa><!-- 與客廳相連的其他房間, 方便使用者下一步訪問 --><linkrel="kitchen"href="https://home.example.com/sittingroom/kitchen"/><linkrel="bedroom"href="https://home.example.com/sittingroom/bedroom"/><linkrel="toilet"href="https://home.example.com/sittingroom/toilet"/></sittingroom>
這樣做的好處是,可以無縫地升級與更換資源的 URL(因為這些 URL 都是服務端返回的),而且開發者在不需要文件的情況下順著導航,可以摸索學會 API 的使用,解決了 API 組織混亂的問題。關於 HATEOAS 更實際的例子可以見 How to GET a Cup of Coffee[1]。

六  實際應用

之前舉的例子可能都更偏向於開源基礎類庫的應用,那麼在更加廣泛的應用系統中,它要如何應用呢?

1  複雜的巢狀結構訪問

現在的 toB 應用為了滿足不同企業的稀奇古怪的定製需求,提供的配置功能越來越複雜,配置項之間不再是簡單的正交獨立的關係,而是相互巢狀遞迴,正是訪問者模式發揮的場合。
釘釘審批的流程配置就是一個十分複雜的結構:
做過簡化的審批流模型如下:
模型和流程配置的對應關係如下圖:
RouteNode 除了像普通節點一樣透過 next 連線下一個節點,其中包含的每個 condition 又是一個完整的流程配置(遞迴定義),由此可見審批節點模型是複雜的巢狀結構。
除了整體結構複雜外,每個節點的配置也相當複雜:
面對如此複雜的配置,最好能透過配置解析二方包(下文中都簡稱為 SDK)對應用層遮蔽配置的複雜性。如果 SDK 只是返回一個圖結構給應用層的話,應用層就不得不感知節點之間的關聯並且每次都需要編寫容易出錯的遍歷演算法,此時訪問者模式就變成了我們的不二之選。
訪問者模式的實現套路和之前一樣的,就不多說了,我們舉個應用層例子:
  • 流程模擬:讓使用者在不實際執行流程的情況下就能看到流程的執行分支,方便除錯
classProcessSimulatorimplementsProcessConfigVisitor{private List<String> traces = new ArrayList<>();@Overridepublicvoidvisit(StartNode startNode){if (startNode.next != null) { startNode.next.accept(this); } }@Overridepublicvoidvisit(RouteNode routeNode){// 計算出滿足條件的分支for (CondtionNode conditionNode : routeNode.conditions) {if (evalCondition(conditionNode.condition)) { conditionNode.accept(this); } }if (routeNode.next != null) { routeNode.next.accept(this); } }@Overridepublicvoidvisit(ConditionNode conditionNode){if (conditionNode.next != null) { conditionNode.next.accept(this); } }@Overridepublicvoidvisit(ApproveNode approveNode){// 記錄下在模擬中訪問到的審批節點 traces.add(approveNode.id);if (approveNode.next != null) { approveNode.next.accept(this); } }}

2  SDK 隔離外部呼叫

為了保證 SDK 的純粹性,一般 SDK 中都不會去呼叫外部介面,但是為了實現一些需求又不得不這麼做,此時我們可以將外部呼叫放在應用層訪問者的實現中,然後傳入 SDK 中執行相關邏輯。
在上面提到的流程模擬中過程,條件計算常會包括外部介面呼叫,比如透過聯結器呼叫一個使用者指定介面決定流程分支,為了保證流程配置解析 SDK 的純粹性,不可能在 SDK 包中進行呼叫的,因此就在訪問者中呼叫。

七  使用 Java18 實現訪問者模式

回到最初的命題,用訪問者模式獲得 SQL 中所有的函式呼叫。前面說過,用函數語言程式設計語言中常見的模式匹配可以更加方便地實現,而最新的 Java18 中已經對此有比較好的支援。
從 Java 14 開始,Java 支援了一種新的 Record 資料型別,示例如下:
// sealed 表示膠囊型別, 即 Expression 只允許是當前檔案中 Num 和 AddsealedinterfaceExpression {// record 關鍵字代替 class, 用於定義 Record 資料型別record Num(intvalue) implements Expression {}record Add(int left, int right) implements Expression {}}
TestRecord 一旦例項化,欄位就是不可變的,並且它的 equals 和 hashCode 方法會被自動重寫,只要內部的欄位都相等,它們就是相等的:
publicstaticvoidmain(String[] args) { Num n1 = new Num(2);// n1.value = 10; 這行程式碼會導致編譯不過 Num n2 = new Num(2);// true System.out.println(n1.equals(n2));}
更加方便的是,利用 Java 18 中最新的模式匹配功能,可以拆解出其中的屬性:
publicinteval(Expression e) {returnswitch (e) {caseNum(intvalue) -> value;caseAdd(int left, int right) -> left + right; };}
我們首先使用 Record 型別重新定義我們的 SQL 結構:
sealed interface SqlNode { record SelectNode(FieldsNode fields, List<String> from, WhereNode where) implements SqlNode {} record FieldsNode(List<Expression> fields) implements SqlNode {} record WhereNode(List<Expression> conditions) implements SqlNode {} sealed interface Expression extends SqlNode { record IdExpression(String id) implements Expression {} record FunctionCallExpression(String name, List<Expression> arguments) implements Expression {} record LiteralExpression(String literal) implements Expression {} record OperatorExpression(Expression left, String operator, Expression right) implements Expression {} }}
然後利用模式匹配,一個方法即可實現之前的訪問,獲得所有函式呼叫:
public List<String> extractFunctions(SqlNode sqlNode) {returnswitch (sqlNode) {case SelectNode(FieldsNode fields, List<String> from, WhereNode where) -> { List<String> res = new ArrayList<>(); res.addAll(extractFunctions(fields)); res.addAll(extractFunctions(where));return res; }case FieldsNode(List<Expression> fields) -> { List<String> res = new ArrayList<>();for (Expression field : fields) { res.addAll(extractFunctions(field)); }return res; }case WhereNode(List<Expression> conditions) -> { List<String> res = new ArrayList<>();for (Expression condition : conditions) { res.addAll(extractFunctions(condition)); }return res; }case IdExpression(String id) -> Collections.emptyList();case FunctionCallExpression(String name, List<Expression> arguments) -> {// 獲得函式名稱 List<String> res = new ArrayList<>(); res.add(name);for (Expression argument : arguments) { res.addAll(extractFunctions(argument)); }return res; }case LiteralExpression(String literal) -> Collections.emptyList();case OperatorExpression(Expression left, String operator, Expression right) -> { List<String> res = new ArrayList<>(); res.addAll(extractFunctions(left)); res.addAll(extractFunctions(right));return res; } }}
對比一下第二小節的程式碼,最大的區別就是 sqlNode.accept(visitor) 被換成了對 extractFunctions 的遞迴呼叫。另外就是原本透過類來封裝的行為,變成了更加輕量的函式。我們將在下一小節探討其更加深入的含義。

八  重新認識訪問者模式

在 GoF 的設計模式原著中,對訪問者模式的描述如下:
表示一個作用於某物件結構中的各元素的操作。它使你可以在不改變各元素的類的前提下定義作用於這些元素的新操作。
從這句話可以看出,訪問者模式實現的所有功能本質上都可以透過給每個物件增加新的成員方法實現,利用面向物件多型的特性,父結構呼叫並且聚合子結構相應方法的返回結果,以之前的抽取 SQL 所有函式為例,這一次不用訪問者實現,而是在每個類中增加一個 extractFunctions 成員方法:
classSelectNodeextendsSqlNode {private final FieldsNode fields;private final List<String> from;private final WhereNode where; SelectNode(FieldsNode fields, List<String> from, WhereNode where) {this.fields = fields;this.from = from;this.where = where; }public FieldsNode getFields() {return fields; }public List<String> getFrom() {returnfrom; }public WhereNode getWhere() {returnwhere; }public List<String> extractFunctions() { List<String> res = new ArrayList<>();// 繼續呼叫子結構的 extractFunctions res.addAll(fields.extractFunctions()); res.addAll(selectNode.extractFunctions());return res; }}
訪問者模式本質上就是將複雜的類層級結構中成員方法全部都抽象到一個類中去:
這兩種編寫方式有什麼區別呢?Visitor 這個名字雖然看起來像名詞,但是從前面的例子和論述來看,它的實現類全部是關於操作的抽象,從模式匹配的實現方式中就更能看出這一點,ASM 中甚至將 Visitor 作為一個個小操作的抽象進行排列組合,因此兩種編寫方式也對應兩種世界觀:
  • 面向物件:認為操作必須和資料繫結到一起,即作為每個類的成員方法存在,而不是單獨抽取出來成為一個訪問者
  • 函數語言程式設計:將資料和操作分離,將基本操作進行排列組合成為更加複雜的操作,而一個訪問者的實現就對應一個操作
這兩個方式,在編寫的時候看起來區別不大,只有當需要新增修改功能的時候才能顯現出他們的天壤之別,假設現在我們要給每個類增加一個新操作:
  • 成員函式實現方式:需要給類層級結構的每個類增加一個實現,需要修改原來的程式碼,不符合開閉原則
  • 訪問者實現方式:新建一個訪問者即可,完全不影響原來的程式碼。佔優。
這種場景看起來是增加訪問者更加方便。那麼再看下一個場景,假設現在要在類層級結構中增加一個新類:
  • 成員函式實現方式:新建一個類即可,完全不影響原來程式碼。佔優。
  • 訪問者實現方式:需要給每個訪問者增加新類的程式碼實現,需要修改原來的程式碼,不符合開閉原則。
這兩個場景對應了軟體的兩種拆分方式,一種是按照資料拆分,一種是按照功能點拆分,以阿里雙十一的各個分會場與功能為例:盒馬,餓了麼和聚划算分別作為一個分會場參與了雙十一的促銷,他們都需要提供優惠券,訂單和支付等功能。
雖然在使用者看來 盒馬,餓了麼和聚划算是三個不同的應用,但是底層系統可以有兩種劃分方式:
  • 按應用劃分:盒馬,餓了麼,聚划算這個三個系統完全獨立,分別實現一遍三個功能點。雖然有重複造輪子的嫌疑,但是能夠短平快地支撐創新業務,這可能就是所謂的“拆中臺”。
  • 按功能劃分:將系統分為 優惠券系統,訂單系統和支付系統,然後三個應用都使用相同的功能系統,在功能系統內部透過配置或者拓展點的方式處理業務之間的不同。這其實就是所謂的 “中臺”,雖然能最大程度上地重用已有技術成果,但是中臺的種種限制也會遏制創新業務的發展。
任何一種劃分方式都要承受該種方式帶來的缺點。所有現實中的應用,不論是架構還是編碼,都沒有上面的例子那麼極端,而是兩種混用。比如 盒馬,餓了麼,聚划算 都可以在擁有自己系統的同時,複用優惠券這樣的按功能劃分的系統。對應到編碼也是一樣,軟體工程沒有銀彈,我們也要根據特性和場景決定是採用面向物件的抽象,還是訪問者的抽象。更多的時候需要兩者混用,將部分核心方法作為物件成員,利用訪問者模式實現應用層的那些瑣碎雜亂的需求。
招聘資訊:
筆者現任職於釘釘智慧辦公應用,團隊的審批系統是國內目前最大規模的工作流系統,其靈活的流程搭建和表單搭建能力服務了上百萬中小企業。
我們春季實習生招聘正在火熱進行中,崗位有服務端開發/前端開發,Base 地可以是杭州或者北京,如果你是 23 屆相關專業的畢業生,歡迎投簡歷到 [email protected],郵件標題為 “姓名-院校-技術方向-來自阿里技術”
[1]https://www.infoq.com/articles/webber-rest-workflow/

PolarDB-X 動手實踐系列

點選閱讀原文檢視詳情


相關文章