代碼失控與狀態機(下)

序言

《代碼失控與狀態機(上)》的文末,我們留了一個解析「成員訪問表達式」的“作業”,那麼,通過本文我們一起來完成這個作業。

首先,爲什麼要苦哈哈的寫一個這樣看上去沒什麼用的解析器?因爲在某些 IoC 或 AOP 容器中(不幸的是我需要實現一個這樣的 IoC 容器),常需要動態求解成員訪問表達式的值,而解析表達式就是第一步。其實這個“作業”正是編譯器技術中詞法解析的簡化版,自己手動擼一遍,對理解《編譯原理》的前端處理技巧是一個很好的入門練手。

其次,我現在正在造一個 ORM 數據引擎,該數據引擎有個很酷的特性就是在 CRUD 中支持類似 GraphQL 這樣的功能(即數據模式表達式),所以我需要寫一個類 GraphQL 的解析器,這應該算是一個很有價值的案例。

如上,手寫各種“表達式”解析器是很有現實意義和價值的。

源碼

  • 通用詞法解析模塊(語法解析及編譯器暫未實現)
https://github.com/Zongsoft/Zongsoft.CoreLibrary/tree/master/src/Expressions
  • 成員訪問表達式解析器
https://github.com/Zongsoft/Zongsoft.CoreLibrary/tree/feature-data/src/Reflection/Expressions
  • 數據模式表達式解析器
https://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),我找了幾篇文章供大家參考(尤其是前三篇):

除非是去寫編程語言的編譯器,通常我們不用閱讀和編寫像 YACC(Yet Another Compiler Compiler) 或 ANTLR(ANother Tool for Language Recognition) 這些工具中的那些非常“精準”的 BNF 的語法。有關 YACC 和 ANTLR 的一個具體案例,我推薦下面這篇文章(不用摳細節,主要關注語法定義部分)

《TiDB 源碼閱讀系列文章(五)TiDB SQL Parser 的實現》

我推薦大家閱讀和採用各 SQL 手冊中使用的 BNF 方言來學習應用,因爲它們語法約定簡單,對付一般應用場景足夠用。下面是它們的鏈接(個人比較偏好我軟的 Transact-SQL),敬請食用。

  • Transact-SQL 語法約定
https://docs.microsoft.com/zh-cn/previous-versions/sql/sql-server-2012/ms177563(v=sql.110))
  • PostgreSQL 手冊
https://www.postgresql.org/docs/10/static/index.html
  • MySQL 手冊
https://dev.mysql.com/doc/refman/8.0/en
  • Oracle 手冊
https://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]

我嘗試用自然語言來表述上面代碼的意思:

  1. 訪問某個對象中名爲 PropertyA 的成員(屬性或字段);
  2. 訪問上面成員值對象中名爲 ListProperty 的成員(該成員爲列表類型或該成員所屬類型有個索引器);
  3. 訪問上面成員值對象中名爲 MethodA 的方法(方法的參數數目不限,此例爲4個參數);
  4. 訪問上面方法返回值對象中名爲 Children 的成員(該成員爲列表類型或該成員所屬類型有個索引器)。

補充說明:

  • 方法參數數量不定(零或多個),參數類型可以是常量(字符串、數字)或成員訪問表達式;
  • 列表屬性或索引器參數至少一個多則不限,參數類型同方法參數;
  • 字符串常量使用單引號或雙引號標註,支持 \ 反斜槓轉義符;
  • 數字常量支持尾綴標註,即“L”表示長整型、“M”或“m”表示 decimal 類型等。

如上,即使我寫了這麼長篇的文字,依然沒有精確而完整的完成對“成員表達式”的語法表達,可見我們必須藉助 BNF 這樣東西才能進行精準表達。下面是它的 BNF 範式(採用的是 Transact-SQL 語法規範)):

expression ::= {member | indexer}[.member | indexer][...n]

member ::=  identifier | method
indexer ::= "[" {expression | constant}[,...n] "]"
method ::= identifier([expression | constant][,...n])
identifier ::= [_A-Za-z][_A-Za-z0-9]*
constant ::= "string constant" | number
number ::= [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 和解析狀態機圖。

這次我們介紹了文本解析相關的狀態機的設計和實現,其實還有與工作流相關的通用狀態機也是一個非常有趣的應用場景,通用狀態機可以應用在遊戲、工作流、業務邏輯驅動等方面。去年下半年因爲業務線的需要,我花了差不多一兩個禮拜的時間實現了一個完備的通用狀態機,自我感覺設計得不錯,但因爲時間侷促,在狀態泛型實現上有個小瑕疵,以後做完優化後再來介紹它的架構設計和實現,這個系列就先且到此爲止罷。


愛的提醒

本文可能會更新,請閱讀原文:https://zongsoft.github.io/blog/zh-cn/zongsoft/coding-outcontrol-statemachine-2,以避免因內容陳舊而導致的謬誤,同時亦有更好的閱讀體驗。

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