在之前《在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\""
你將得到下面的結果:
可以看到,數據服務返回了所有Name字段以“fr”開頭的客戶信息。當然,還支持排序操作。比如執行下面的命令:
curl -G "http://localhost:58928/api/customers" --data-urlencode "sort=name d"
將得到下面的結果:
此時返回結果已經按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還能根據語法定義,自動產生語法高亮:
點擊右邊的語法樹中的節點,即可定位到輸入字符串的相應部分。比較有趣的一點是,在Irony Grammar Explorer的Github Repo里,還包含了一個語法定義的案例庫:IronyExplorer.Samples,它包含了很多流行編程語言的語法定義。比如,下面是C# 3.5語言的語法測試效果:
有關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表達式正確產生,測試順利通過:
總結
本文介紹了Apworks中自定義查詢語句在Apworks數據服務中的應用,並介紹了查詢語句和排序語句的實現方式,與此同時對Irony Grammar Parser進行了介紹。Apworks中查詢語句的實現還是相對簡單的,目前不支持內嵌對象的屬性查詢,比如Customer.Address.Country EQ “China” 這樣的查詢是不支持的。為了保證實現過程相對簡單快速,今后也不打算支持。如果需要用到這種內嵌對象屬性的查詢,請擴展DataServiceController以實現自己的特定API來完成。
接下來我會介紹Entity Framework Core在Apworks數據服務中的使用(雖然已經預告了好幾次了-_-!!)。