序言
在《代碼失控與狀態機器(上)》的文末,我們留了一個解析「成員存取運算式」的“作業”,那麼,通過本文我們一起來完成這個作業。
首先,為什麼要苦哈哈的寫一個這樣看上去沒什麼用的解析器?因為在某些 IoC 或 AOP 容器中(不幸的是我需要實現一個這樣的 IoC 容器),常需要動態求解成員存取運算式的值,而解析運算式就是第一步。其實這個“作業”正是編譯器技術中詞法解析的簡化版,自己手動擼一遍,對理解《編譯原理》的前端處理技巧是一個很好的入門練手。
其次,我現在正在造一個 ORM 資料引擎,該資料引擎有個很酷的特性就是在 CRUD 中支援類似 GraphQL 這樣的功能(即資料模式運算式),所以我需要寫一個類 GraphQL 的解析器,這應該算是一個很有價值的案例。
如上,手寫各種“運算式”解析器是很有現實意義和價值的。
源碼
通用詞法解析模組(文法解析及編譯器暫未實現)
github.com/Zongsoft/Zongsoft.CoreLibrary/tree/master/src/Expressions
成員存取運算式解析器
github.com/Zongsoft/Zongsoft.CoreLibrary/tree/feature-data/src/Reflection/Expressions
資料模式運算式解析器
github.com/Zongsoft/Zongsoft.CoreLibrary/blob/feature-data/src/Data/SchemaParser.cs
基礎知識
BNF(Backus-Naur Form)巴克斯範式,在電腦科學領域,BNF 是一種適用於上下文無關的文法的符號表述,常用於描述計算程式設計語言的文法(文法)、文檔格式、指令集以及通訊協定等,總之它適用於需要準確描述的地方,如果不用這個東西,我們幾乎沒辦法準確而簡潔的表述像電腦程式設計語言這樣需要精準表達的東西。
除了基本的 BNF 以外,人們為了更簡潔的表達而進行了擴充和增強,譬如:EBNF(Extended Backus–Naur Form)、ABNF(Augmented Backus–Naur Form),我找了幾篇文章供大家參考(尤其是前三篇):
- 《BNF和ABNF的含義與用法》
- 《文法規範:BNF與ABNF》
- 《EBNF:Extended Backus–Naur Form》
- 《ABNF:Augmented BNF for Syntax Specifications(rfc5234)》
- 《C# Language Specification》(應用BNF最強大的範例:C#語言規範)
除非是去寫程式設計語言的編譯器,通常我們不用閱讀和編寫像 YACC(Yet Another Compiler Compiler) 或 ANTLR(ANother Tool for Language Recognition) 這些工具中的那些非常“精準”的 BNF 的文法。有關 YACC 和 ANTLR 的一個具體案例,我推薦下面這篇文章(不用摳細節,主要關注文法定義部分):
《TiDB 源碼閱讀系列文章(五)TiDB SQL Parser 的實現》
我推薦大家閱讀和採用各 SQL 手冊中使用的 BNF 方言來學習應用,因為它們文法約定簡單,對付一般應用情境足夠用。下面是它們的連結(個人比較偏好我軟的 Transact-SQL),敬請食用。
Transact-SQL 文法約定
docs.microsoft.com/zh-cn/previous-versions/sql/sql-server-2012/ms177563(v=sql.110)
PostgreSQL 手冊
www.postgresql.org/docs/10/static/index.html
MySQL 手冊
dev.mysql.com/doc/refman/8.0/en
Oracle 手冊
docs.oracle.com/en/database/oracle/oracle-database/18/sqlrf
文法規範
關於“成員存取運算式”的詳細文法(文法)可以參考《C#語言規範》,下面讓我們先看看之前寫的那個成員運算式的例子:
PropertyA.ListProperty[100].MethodA(PropertyB, 'String\'Constant for Arg2', 200, ['key'].XXX.YYY).Children['arg1', PropertyC.Foo]
我嘗試用自然語言來表述上面代碼的意思:
- 訪問某個對象中名為
PropertyA 的成員(屬性或欄位);
- 訪問上面成員值對象中名為
ListProperty 的成員(該成員為清單類型或該成員所屬類型有個索引器);
- 訪問上面成員值對象中名為
MethodA 的方法(方法的參數數目不限,此例為4個參數);
- 訪問上面方法傳回值對象中名為
Children 的成員(該成員為清單類型或該成員所屬類型有個索引器)。
補充說明:
- 方法參數數量不定(零或多個),參數類型可以是常量(字串、數字)或成員存取運算式;
- 列表屬性或索引器參數至少一個多則不限,參數類型同方法參數;
- 字串常量使用單引號或雙引號標註,支援
\ 反斜線轉義符;
- 數字常量支援尾綴標註,即“L”表示長整型、“M”或“m”表示 decimal 類型等。
如上,即使我寫了這麼長篇的文字,依然沒有精確而完整的完成對“成員運算式”的文法表達,可見我們必須藉助 BNF 這樣東西才能進行精準表達。下面是它的 BNF 範式(採用的是 Transact-SQL 文法規範):
expression ::= {member | indexer}[.member | indexer][...n]member ::= identifier | methodindexer ::= "[" {expression | constant}[,...n] "]"method ::= identifier([expression | constant][,...n])identifier ::= [_A-Za-z][_A-Za-z0-9]*constant ::= "string constant" | numbernumber ::= [0-9]+{.[0-9]}?[L|m|M|f|F]
如上,即使我們採用的不是能直接產生詞法解析器(Parser)的“高精準”的 BNF 運算式,但它依然足夠精確、簡潔。
狀態機器圖
有了確切的文法規範/文法(即 BNF 範式運算式)之後,我們就可以有的放矢的繪製運算式解析器的狀態機器圖了。
狀態說明:
- Identifier:標識態,表示處於成員(屬性、欄位、方法)名稱狀態;
- Separator:分隔字元態,表示處於成員分隔字元(即圓點)狀態;
- Gutter:空隙態,表示索引器或方法參數結束後,所處於的空隙狀態;
- Indexer:索引器態,表示處於的索引器內部的就緒狀態,它可以繼續接受一個有效非終結符,也可以是一個終結符;
- Parameter:參數態,表示處於索引器或方法參數的完結狀態,它必須等待一個終結符(逗號或括弧);
- String:字串常量態,表示處於字串常量的內部,它可以接受任一字元,如果遇到終結符(匹配的單引號或雙引號)則轉入參數態;
- Number:數字常量態,表示處於數字字面量,它可以接受任一數字字元,如果遇到終結符(尾綴符)則轉入參數態。
因為方法和索引器的參數有可能是運算式,因此在實現上需要進行遞迴棧處理,所以流程圖中標有壓棧(Push)、出棧(Pop)的行為,通過虛線表示對應的激發操作。所有左方括弧 [ 通路會激發壓棧操作,同時右方括弧 ] 通路會激發對應的出棧操作;因為版面問題,上述流程圖並沒有標註出圓括弧(方法參數)通路的出入棧的部分,但是邏輯等同於方括弧(索引器)部分。
提示:
- 如果在狀態遷移判定中出現狀態圖中未定義的字元,則表示輸入參數有特定的語法錯誤。
- 如果當文本解析完成時遞迴棧仍不為空白,則說明索引器或方法的參數沒有匹配完畢。
關於解析器狀態機器的設計,我沒有發現具有普適性的設計指導方案,大家可以根據自己的理解設定不同於的狀態定義;至於對狀態設定粒度的把握,總體原則是要具備邏輯或概念上的自恰性、並方便繪圖和編程實現就可以了。
源碼解析
位於 Zongsoft.Reflection.Expressions 命名空間中的介面和類整體上與 System.Linq.Expressions 命名空間中的相關類的設計類似。大致類圖如下:
提供解析功能的是 MemberExpressionParser 這個內部靜態類(狀態機器類),它的 Parse(string text) 即為狀態驅動函數,它遍曆輸入參數的文本字元,交給具體的私人方法 DoXXX(context) 進行狀態遷移判定,如此迴圈即完成整個解析工作,整體結構與《代碼失控與狀態機器(上)》中介紹的狀態機器的程式結構一致,具體代碼如下:
public static IMemberExpression Parse(string text, Action<string> onError){ if(string.IsNullOrEmpty(text)) return null; //建立解析內容物件 var context = new StateContext(text.Length, onError); //狀態遷移驅動 for(int i = 0; i < text.Length; i++) { context.Character = text[i]; switch(context.State) { case State.None: if(!DoNone(ref context, i)) return null; break; case State.Gutter: if(!DoGutter(ref context, i)) return null; break; case State.Separator: if(!DoSeparator(ref context, i)) return null; break; case State.Identifier: if(!DoIdentifier(ref context, i)) return null; break; case State.Method: if(!DoMethod(ref context, i)) return null; break; case State.Indexer: if(!DoIndexer(ref context, i)) return null; break; case State.Parameter: if(!DoParameter(ref context, i)) return null; break; case State.Number: if(!DoNumber(ref context, i)) return null; break; case State.String: if(!DoString(ref context, i)) return null; break; } } //擷取最終的解析結果 return context.GetResult();}
代碼簡義:
- 其中表示狀態的枚舉與上面的解析器狀態機器流程圖的定義完全一致。
- 內部的 StateContext 結構用來儲存解析過程中的各種資料、狀態、字元緩衝等,以及與上下文相關的操作方法等。
- 內部的 StateVector 結構用來儲存解析過程中的標記開關(布爾)的狀態,譬如當前數值常量的類型、當前字元是否位於字串常量的轉義符態、標識(Identifier)中間是否含有空白字元等。
其他延展
在 Zongsoft.Data 資料引擎裡面有個資料模式(Schema)的概念,它是一種在資料操作中定義資料形狀的運算式,有點類似於 GraphQL 運算式的功能(不含查詢條件)。
譬如有一個名為 Corporation 的企業實體類,它除了企業編號、名稱、簡稱等單值屬性外,還有企業法人、部門集合等這樣的“一對一”和“一對多”的複合(導航)屬性等。現在假設我們調用資料訪問類的 Select 方法進行查詢調用:
var entities = dataAccess.Select<Corporation>( Condition.GreaterThanEqual("RegisteredCapital", 100));
以上代碼錶示查詢 Corporation 實體對應的表,條件為 RegisteredCapital 註冊資本大於等於100萬元的記錄,但缺乏表達 Corporation 實體關聯的導覽屬性的語義。採用資料模式(Schema)來定義操作的資料形狀,大致如下:
var schema = @"CorporationId, Name, Abbr, RegisteredCapital,Principal{Name, FullName, Avatar},Departments:10(~Level, NumberOfPeople){ Name, Manager { Name, FullName, JobTitle, PhoneNumber }}";var entities = dataAccess.Select<Corporation>( schema, Condition.GreaterThanEqual("RegisteredCapital", 100) & Condition.Like("Principal.Name", "鐘%"));
通過資料存取方法中的 schema 參數,我們可以方便的定義資料形狀(含一對多導覽屬性的分頁和排序設定),這樣就省去了多次訪問資料庫進行資料遍曆的操作,大大提高了運行效率,同時簡化了代碼。
資料模式中各成員以逗號分隔,如果是複合屬性則可以用花括弧來限定其內部屬性集,對於一對多的複合屬性,還可以定義其分頁和排序設定。以下是它的 BNF 範式:
schema ::={ * | ! | !identifier | identifier[paging][sorting]["{"schema[,...n]"}"]} [,...n]identifier ::= [_A-Za-z][_A-Za-z0-9]*number ::= [0-9]+paging ::= ":"{ {*|?}| number[/{?|number}]}sorting ::="(" { [~|!]identifier }[,...n]")"
提示:驚歎號表示排除的意思,一個驚歎號表示排除之前的所有成員定義;以驚歎號打頭的成員標識,表示排除之前定義的該成員(如果之前有定義的話,沒有則忽略)。
分頁設定的釋義:
* 返回所有記錄(即不分頁);
? 返回第一頁,頁大小為系統預設值,等同於 1/? 格式(資料引擎預設設定);
n 返回 n 條記錄,等同於 1/n 格式;
n/m 返回第 n 頁,每頁 m 行;
n/? 返回第 n 頁,頁大小為系統預設值;
以上是資料模式運算式的解析器狀態機器圖,具體實現代碼這裡就不再贅述,總體上跟“成員存取運算式”解析器類似。
結尾
在很多應用狀態機器情境的編程中,繪製一個狀態機器圖對於實現是具有非常重要的指導意義,希望通過這兩個具體的案例能對大家有所啟示。
其實 Linux/Unix 中的命令列,也是一個很好的案例,有興趣的可以嘗試寫下它的 BNF 和解析狀態機器圖。
這次我們介紹了文本解析相關的狀態機器的設計和實現,其實還有與工作流程相關的通用狀態機器也是一個非常有趣的應用情境,通用狀態機器可以應用在遊戲、工作流程、商務邏輯驅動等方面。去年下半年因為業務線的需要,我花了差不多一兩個禮拜的時間實現了一個完備的通用狀態機器,自我感覺設計得不錯,但因為時間局促,在狀態泛型實現上有個小瑕疵,以後做完最佳化後再來介紹它的架構設計和實現,這個系列就先且到此為止罷。
提醒:
本文可能會更新,請閱讀原文:zongsoft.github.io/blog/zh-cn/zongsoft/coding-outcontrol-statemachine-2,以避免因內容陳舊而導致的謬誤,同時亦有更好的閱讀體驗。