在.NET Core中使用Irony實現自己的查詢語言語法解析器


在之前《在ASP.NET Core中使用Apworks快速開發數據服務》一文的評論部分,.NET大神張善友為我提了個建議,可以使用Compile As a Service的Roslyn為語法解析提供支持。在此非常感激友哥給我的建議,也讓我了解了一些Roslyn的知識。使用Roslyn的一個很大的好處是,框架無需依賴第三方的組件,並且Roslyn也是.NET Foundation的一個開源項目,為.NET語言提供編譯服務,社區支持做的也非常出色。然而,經過一段時間的思考,我還是選擇了一個折中的方案:在Apworks中使用Irony作為查詢語言的語法解析器,與此同時,為查詢語言語法解析提供可擴展的框架級支持。

那么問題來了:為什么我需要在Apworks中設計查詢語言?Irony是什么?如何使用Irony實現自己的查詢語言語法解析器?下面我就一一為大家介紹。

Apworks中的查詢語言

很多體驗過Apworks數據服務(Apworks Data Services)案例:TaskList的讀者肯定有這樣的感受:為什么每次我新建的任務項目(Task Item)都是出現在列表中不確定的位置?難道新建的任務就不應該放在最前面嗎?是的,你的疑問沒有錯,在之前的TaskList中,的確存在這樣的問題,因為那時候Apworks數據服務在返回任務列表時,還不支持查詢和排序,也就是說,它只能默認以Id作為升序進行分頁,返回所有的數據。當然,在最近一版的Apworks數據服務中,通過基於Irony的語法解析器,已經能夠成功地支持查詢和排序了。

如果你之前有仔細閱讀《在ASP.NET Core中使用Apworks快速開發數據服務》一文,並按照文中的演練步驟實現過一個簡單的RESTful服務的話,那么,請你重新在Visual Studio 2017中打開你的解決方案,將Apworks相關庫更新到最新版本,然后不要修改任何代碼,直接運行你的應用。等應用程序運行后,執行一次GET請求,URL中你就可以使用query作為查詢條件輸入了。比如,使用curl執行下面的命令:

curl -G "http://localhost:58928/api/customers" --data-urlencode "query=name sw \"fr\""

你將得到下面的結果:

image

可以看到,數據服務返回了所有Name字段以“fr”開頭的客戶信息。當然,還支持排序操作。比如執行下面的命令:

curl -G "http://localhost:58928/api/customers" --data-urlencode "sort=name d"

將得到下面的結果:

image

此時返回結果已經按Name字段倒序排列。

在Apworks中,查詢語言支持以下操作和運算:

  • 邏輯運算:AND OR NOT
  • 關系運算:EQ(相等),NE(不等),LT(小於),LE(小於等於),GT(大於),GE(大於等於)
  • 字符串運算:SW(以某字符串開頭)、EW(以某字符串結尾)、CT(包含某字符串)
  • 括號優先級
  • 日期類型的比對

排序語言支持升序(用字母a表示)以及降序(用字母d表示),多個排序條件使用AND關鍵字連接。例如:name a AND email d,表示使用name字段做升序排序,並以email做降序排序。

以上就給大家大概介紹了一下Apworks數據服務對查詢和排序的支持功能。設計這部分功能的需求是顯而易見的:開發人員無需為一般的查詢和排序功能自定義額外的接口。或許你會問,為何不使用已有的框架,比如OData。不錯,OData的確可以提供統一的查詢界面,做系統集成也會相對容易,但一方面我還是覺得OData太重,Apworks數據服務我希望能夠提供更加簡單便捷的功能;另一方面,看上去目前OData還不支持.NET Core(應該是不支持,我不太確定,有知道的朋友也歡迎留言指正)。

實現這套查詢和排序語法,我使用的是一個.NET下開源的語法解析器生成工具集,它的名字叫做Irony

Irony簡介

Irony項目最開始是發布在微軟的Codeplex代碼托管服務上的,地址是:http://irony.codeplex.com/。在Codeplex上的好評數有51顆星,也已經很不錯了。可惜的是,最近一次更新是在2013年12月,看起來已經停止維護了,不過之前使用了一下,感覺這個項目確實不錯,不僅提供了開發庫,而且還有一個圖形化的語法解析器的測試工具,在寫完自己的自定義語言的語法之后,還可以通過這個工具進行測試。於是,我把它遷移到了Github,成為我的一個公共repo,地址是:https://github.com/daxnet/irony。當然,我沿用了原有的MIT許可協議,並在首頁的README.md中提供了原始地址(很可惜Codeplex將在年底關閉),並保留了開發者的名字。不僅如此,在一番踩坑之后,我把它遷移到了.NET Core平台。

在我的Irony Github Repo里,提供了一個非常簡單的案例,就是實現四則混合運算的字符串解析,並計算最終結果。當然,這個案例也被包含在了這個項目的源代碼里。大家可以自己下載查看。

Irony的一個特色就是運用了C#的運算符重載,使得語法定義借用了C#的編譯功能(語法、類型檢查等),簡單直觀,又不容易出錯。比如,在如下案例中的語法定義類型中:

[Language("Expression Grammar", "1.0", "abc")]
public class ExpressionGrammar : Grammar
{
    /// <summary>
    /// Initializes a new instance of the <see cref="ExpressionGrammar"/> class.
    /// </summary>
    public ExpressionGrammar() : base(false)
    {
        var number = new NumberLiteral("Number");
        number.DefaultIntTypes = new TypeCode[] { TypeCode.Int16, TypeCode.Int32, TypeCode.Int64 };
        number.DefaultFloatType = TypeCode.Single;

        var identifier = new IdentifierTerminal("Identifier");
        var comma = ToTerm(",");

        var BinOp = new NonTerminal("BinaryOperator", "operator");
        var ParExpr = new NonTerminal("ParenthesisExpression");
        var BinExpr = new NonTerminal("BinaryExpression", typeof(BinaryOperationNode));
        var Expr = new NonTerminal("Expression");
        var Term = new NonTerminal("Term");

        var Program = new NonTerminal("Program", typeof(StatementListNode));

        Expr.Rule = Term | ParExpr | BinExpr;
        Term.Rule = number | identifier;

        ParExpr.Rule = "(" + Expr + ")";
        BinExpr.Rule = Expr + BinOp + Expr;
        BinOp.Rule = ToTerm("+") | "-" | "*" | "/";

        RegisterOperators(10, "+", "-");
        RegisterOperators(20, "*", "/");

        MarkPunctuation("(", ")");
        RegisterBracePair("(", ")");
        MarkTransient(Expr, Term, BinOp, ParExpr);

        this.Root = Expr;
    }
}

從中可以很容易理解:運算符(BinOp)包含+、-、*和/,而一個二元運算的表達式(BinExpr)由兩個表達式(Expr)和一個運算符(BinOp)組成,而二元運算的表達式又是表達式(Expr)的一種。通過這樣的語法定義,就可以使用Irony的Parser產生語法樹了:

var language = new LanguageData(new ExpressionGrammar());
var parser = new Parser(language);
var syntaxTree = parser.Parse(input);

怎么樣,是不是非常方便?

在遷移Irony項目的同時,我還將Irony的測試工具Irony Grammar Explorer分離出來成為了一個單獨的Github Repo。在你定義了上面的ExpressionGrammar類之后,編譯你的程序集,然后就可以使用Irony Grammar Explorer進行測試了。比如,使用Irony Grammar Explorer打開Apworks.Querying.Parsers.Irony程序集,它將自動掃描程序集中所有的Grammar定義,然后讓用戶對各種Grammar進行測試。值得一提的是,在測試界面,Irony Grammar Explorer還能根據語法定義,自動產生語法高亮:

image

點擊右邊的語法樹中的節點,即可定位到輸入字符串的相應部分。比較有趣的一點是,在Irony Grammar Explorer的Github Repo里,還包含了一個語法定義的案例庫:IronyExplorer.Samples,它包含了很多流行編程語言的語法定義。比如,下面是C# 3.5語言的語法測試效果:

image

有關Irony Grammar Explorer的其它功能,我就不一一介紹了,大家可以自己實踐一下。總的來說,Irony可以幫助大家快速方便地實現語法解析器,而且功能也能夠滿足絕大多數需求,針對.NET Core的支持,也使得Irony能夠直接被應用在跨平台的.NET應用程序中,並支持Docker部署。接下來的問題就更有趣了:我已經定義了自己的語法,並使用Irony Grammar Explorer通過了測試,接下來,我如何在我的應用程序中運用這個語法?換個方式問:我拿到了語法樹后,該怎么辦呢?

語法樹的處理

雖然我們能夠將字符串文本解析成一棵語法樹,能夠通過語法樹來體現一個字符串中各個部分的含義,以及它們之間的關系,但是如何能夠讓計算機來讀懂這棵樹,並執行相應的任務呢?這就涉及到語法樹的處理問題。參考編譯原理,詞法分析和語法分析已經由Irony完成,接下來的語義分析,就需要我們自己寫代碼了。

Irony Repo的案例代碼中,我們的目的是能夠解析一個四則運算表達式,並計算出結果,於是,我們定義了下面的對象模型:

因此,只需要將解析的語法樹轉換成上面的對象模型,也就能夠通過Evaluation.Value屬性,得到計算的最終結果。從代碼上看,向對象模型的轉換,是通過遞歸的方式遍歷語法樹實現的:

private Evaluation PerformEvaluate(ParseTreeNode node)
{
  switch (node.Term.Name)
  {
    case "BinaryExpression":
        var leftNode = node.ChildNodes[0];
        var opNode = node.ChildNodes[1];
        var rightNode = node.ChildNodes[2];
        Evaluation left = PerformEvaluate(leftNode);
        Evaluation right = PerformEvaluate(rightNode);
        BinaryOperation op = BinaryOperation.Add;
        switch (opNode.Term.Name)
        {
            case "+":
                op = BinaryOperation.Add;
                break;
            case "-":
                op = BinaryOperation.Sub;
                break;
            case "*":
                op = BinaryOperation.Mul;
                break;
            case "/":
                op = BinaryOperation.Div;
                break;
        }
        return new BinaryEvaluation(left, right, op);
    case "Number":
        var value = Convert.ToSingle(node.Token.Text);
        return new ConstantEvaluation(value);
  }

  throw new InvalidOperationException($"Unrecognizable term {node.Term.Name}.");
}

以上完整代碼請參考Evaluator的實現。整個案例及使用方式可以點擊https://github.com/daxnet/irony#example查看。可以看到,使用Irony來實現一個四則混合運算的計算器還是非常方便的。

在Apworks中,我們需要的是能夠將一個表達查詢語義的語法樹,轉換成Lambda表達式,以便於后台數據庫引擎能夠直接執行Lambda表達式完成查詢。通過數據庫引擎執行Lambda表達式的優勢是非常明顯的,比如Entity Framework Core可以通過Lambda表達式生成高效的SQL語句並在數據庫服務器上執行,性能方面也能兼顧得非常好。

類似的,我們使用.NET Expression的對象模型,通過遍歷查詢語句的語法樹來生成表達式模型,最后轉換成Lambda表達式即可。具體過程就不再贅述了,請參考Apworks的源代碼。現在我們來看看實際效果。

假設我們的測試數據如下:

Customers.Add(new Customer { Id = 1, Email = "jim@example.com", Name = "jim", DateRegistered = DateTime.Now.AddDays(-1) });
Customers.Add(new Customer { Id = 2, Email = "tom@example.com", Name = "tom", DateRegistered = DateTime.Now.AddDays(-2) });
Customers.Add(new Customer { Id = 3, Email = "alex@example.com", Name = "alex", DateRegistered = DateTime.Now.AddDays(-3) });
Customers.Add(new Customer { Id = 4, Email = "carol@example.com", Name = "carol", DateRegistered = DateTime.Now.AddDays(-4) });
Customers.Add(new Customer { Id = 5, Email = "david@example.com", Name = "david", DateRegistered = DateTime.Now.AddDays(-5) });
Customers.Add(new Customer { Id = 6, Email = "frank@example.com", Name = "frank", DateRegistered = DateTime.Now.AddDays(-6) });
Customers.Add(new Customer { Id = 7, Email = "peter@example.com", Name = "peter", DateRegistered = DateTime.Now.AddDays(-7) });
Customers.Add(new Customer { Id = 8, Email = "paul@example.com", Name = "paul", DateRegistered = DateTime.Now.AddDays(1) });
Customers.Add(new Customer { Id = 9, Email = "winter@example.com", Name = "winter", DateRegistered = DateTime.Now.AddDays(2) });
Customers.Add(new Customer { Id = 10, Email = "julie@example.com", Name = "julie", DateRegistered = DateTime.Now.AddDays(3) });
Customers.Add(new Customer { Id = 11, Email = "jim@example.com", Name = "jim", DateRegistered = DateTime.Now.AddDays(4) });
Customers.Add(new Customer { Id = 12, Email = "brian@example.com", Name = "brian", DateRegistered = DateTime.Now.AddDays(5) });
Customers.Add(new Customer { Id = 13, Email = "david@example.com", Name = "david", DateRegistered = DateTime.Now.AddDays(6) });
Customers.Add(new Customer { Id = 14, Email = "daniel@example.com", Name = "daniel", DateRegistered = DateTime.Now.AddDays(7) });
Customers.Add(new Customer { Id = 15, Email = "jill@example.com", Name = "jill", DateRegistered = DateTime.Now.AddDays(8) });

下面調試單元測試,並查看所產生的Lambda表達式,可以看到,Lambda表達式正確產生,測試順利通過:

image

總結

本文介紹了Apworks中自定義查詢語句在Apworks數據服務中的應用,並介紹了查詢語句和排序語句的實現方式,與此同時對Irony Grammar Parser進行了介紹。Apworks中查詢語句的實現還是相對簡單的,目前不支持內嵌對象的屬性查詢,比如Customer.Address.Country EQ “China” 這樣的查詢是不支持的。為了保證實現過程相對簡單快速,今后也不打算支持。如果需要用到這種內嵌對象屬性的查詢,請擴展DataServiceController以實現自己的特定API來完成。

接下來我會介紹Entity Framework Core在Apworks數據服務中的使用(雖然已經預告了好幾次了-_-!!)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM