深入LINQ | 动态构建LINQ表达式
原文:bit.ly/3fwlKQJ
作者:Jeremy Likness
譯者:精致碼農-王亮
LINQ 是 Language Integrated Query(語言集成查詢)的縮寫,是我最喜歡的 .NET 和 C# 技術之一。使用 LINQ,開發者可以直接在強類型代碼中編寫查詢。LINQ 提供了一種標準的語言和語法,使不同的數據源的查詢編碼方法一致。
1一些基礎
考慮如下這個 LINQ 查詢(你可以把它粘貼到一個控制臺應用程序中運行)。
using System; using System.Linq;public?class?Program {public?static?void?Main(){var someNumbers = new?int[]{4, 8, 15, 16, 23, 42};var query =from num in someNumberswhere num > 10orderby num descendingselect num.ToString();Console.WriteLine(string.Join('-', query.ToArray()));// 42-23-16-15} }因為 someNumbers 是一個 IEnumerable<int>,該查詢是被 LINQ to Objects 解析的。同樣的查詢語法可用于像 Entity Framework Core 這樣的工具,生成針對關系型數據庫運行的 T-SQL。LINQ 可以使用兩種語法來編寫:查詢語法(如上所示)和(擴展)方法語法。這兩種語法在語義上是相同的,你使用哪一種語法取決于你的偏好。上面同樣的查詢可以用方法語法寫成這樣:
var secondQuery = someNumbers.Where(n => n > 10).OrderByDescending(n => n).Select(n => n.ToString());每個 LINQ 查詢都有三個階段:
設置一個數據源,稱為提供者(provider),供查詢時使用。例如,到目前為止的代碼使用了內置的 LINQ to Objects 提供者。你的 EF Core 項目使用的是 EF Core 提供者,它映射到你的數據庫。
查詢被定義并轉變成一個表達式樹(expression tree),我將在稍后介紹。
查詢被執行,數據被返回。
第 3 步很重要,因為 LINQ 使用了所謂的延遲執行(deferred execution)。在上面的例子中,secondQuery 定義了一個表達式樹,但還沒有返回任何數據。事實上,在你開始迭代數據之前,實際上什么都沒有發生。這很重要,因為它允許提供者通過只提供所要求的數據。例如,假設你想用 secondQuery 找到一個特定的字符串,所以你做了這樣的事情:
var found = false; foreach(var item in secondQuery.AsEnumerable()) {if (item == "23"){found = true;break;} }一個提供者通過枚舉器(enumerator)訪問,這樣它就可以一次輸入一個元素的數據。如果你在第三次迭代時得到了想要的值,可能實際上只有三條數據從數據庫中返回。另一方面,當你使用 .ToList() 擴展方法時,所有的數據都會立即被取出并填充到列表中。
2難題
我作為我們公司的 .NET 項目經理,我經常與客戶交談,了解他們的需求。最近,我與一位客戶進行了討論,他想在他們的網站上使用第三方控件來建立業務規則。更具體地說,業務規則是“謂詞”(predicates,譯注:也可以翻譯成判斷語句)或一組條件,可解析為 true 或 false。該工具可以用 JSON 或 SQL 格式生成規則。SQL 很香,可以持久化到給數據庫,但他們的要求是將“謂詞”應用于內存對象,作為服務器上的一個過濾器。他們正在考慮使用一種工具,將 SQL 翻譯成表達式(其實就是動態生成 LINQ)。我建議使用 JSON 格式,因為它可以被解析成 LINQ 表達式,針對內存中的對象運行,或者很容易應用到 Entity Framework Core 集合,相對 SQL 數據庫是更好的選擇。
我只要處理工具產生的 JSON:
{"condition": "and","rules": [{"label": "Category","field": "Category","operator": "in","type": "string","value": ["Clothing"]},{"condition": "or","rules": [{"label": "TransactionType","field": "TransactionType","operator": "equal","type": "boolean","value": "income"},{"label": "PaymentMode","field": "PaymentMode","operator": "equal","type": "string","value": "Cash"}]},{"label": "Amount","field": "Amount","operator": "equal","type": "number","value": 10}] }結構很簡單:有一個 AND 或 OR 條件,包含一組規則,要么是比較,要么是嵌套條件。我的目標有兩個:學習更多關于 LINQ 表達式的知識,以便更好地了解 EF Core 和相關技術;提供一個簡單的例子,說明如何在不依賴第三方工具的情況下使用 JSON。
3動態表達式
我創建了一個簡單的控制臺應用程序來測試我的假設,即解析 JSON 信息直接生成 LINQ 查詢。
https://github.com/JeremyLikness/ExpressionGenerator譯注:建議參照此 GitHub 源代碼閱讀本文,方便理解。
在本文的第一部分,將啟動項目設置為 ExpressionGenerator。如果你從命令行運行它,請確保 rules.json 在你的當前目錄中。
我將樣本 JSON 嵌入為 rules.json。使用 System.Text.Json 來解析文件,就是這么簡單:
var jsonStr = File.ReadAllText("rules.json"); var jsonDocument = JsonDocument.Parse(jsonStr);然后我創建了一個 JsonExpressionParser 來解析 JSON 并創建一個表達式樹。因為動態表達式是一個謂詞,所以表達式樹是由二元表達式 BinaryExpression 的實例構成的,這些實例計算一個左表達式和一個右表達式。這個計算可能是一個邏輯門(AND 或 OR),或一個比較(equal 或 greaterThan),或一個方法調用。對于 In 的情況,即我們想讓屬性 Category 出現在一個列表中,我使用 Contains。從概念上講,引用的 JSON 看起來像這樣:
/-----------AND-----------\| |/-AND-\ | Category IN ['Clothing'] Amount eq 10.0 /-OR-\TransactionType EQ 'income' PaymentMode EQ 'Cash'注意,每個節點都是二元的。讓我們開始解析吧!
4引入 Transaction
注意,這不是 System.Transaction(這里的 Transaction 不是指事務,而是指交易)。這是示例項目中使用的一個自定義類。我沒有在供應商的網站上花很多時間,所以我根據規則猜測實體可能的樣子。我想出了這個:
public?class?Transaction {public?int Id { get; set; }public?string Category { get; set; }public?string TransactionType { get; set; }public?string PaymentMode { get; set; }public?decimal Amount { get; set; } }我還添加了一些額外的方法,以使其易于生成隨機實例。你可以自己在 GitHub 代碼中看到這些。
5參數表達式
主要方法返回一個謂詞(predicate)函數。下面是該方法開始部分的代碼:
public?Func<T, bool> ParsePredicateOf<T>(JsonDocument doc) {var itemExpression = Expression.Parameter(typeof(T));var conditions = ParseTree<T>(doc.RootElement, itemExpression); }第一步是創建謂詞參數。謂詞可以傳遞給 Where 子句,如果我們自己寫的話,它看起來就像這樣:
var query = ListOfThings.Where(t => t.Id > 2);t => 是一個參數,代表列表中一個條目的類型。因此,我們為該類型創建一個參數。然后我們遞歸地遍歷 JSON 節點來建立樹。
6邏輯表達式
解析器的開頭看起來像這樣:
private Expression ParseTree<T>(JsonElement condition,ParameterExpression parm){Expression left = null;var gate = condition.GetProperty(nameof(condition)).GetString();JsonElement rules = condition.GetProperty(nameof(rules));Binder binder = gate == And ? (Binder)Expression.And : Expression.Or;Expression bind(Expression left, Expression right) =>left == null ? right : binder(left, right);gate 變量是條件,即“and”或“or”。規則語句得到一個節點,是相關規則的列表。我們正在跟蹤表達式的左邊和右邊。Binder 簽名是二元表達式的簡寫,定義如下:
private?delegate Expression Binder(Expression left, Expression right);binder 變量簡單地設置了頂層表達式:Expression.And 或 Expression.Or。兩者都使用左邊和右邊表達式來計算。
bind 函數更有趣一點。當我們遍歷樹時,我們需要建立各種節點。如果我們還沒有創建一個表達式(left 是 null),我們就從創建的第一個表達式開始。如果我們有一個現有的表達式,我們就用這個表達式來合并兩邊的內容。
現在,left 是 null,然后我們開始列舉屬于這個條件的規則:
foreach (var rule in rules.EnumerateArray())7屬性表達式
第一條規則是一個相等規則,所以我現在跳過條件部分。大致情況是下面這樣的:
string @operator = rule.GetProperty(nameof(@operator)).GetString(); string type = rule.GetProperty(nameof(type)).GetString(); string field = rule.GetProperty(nameof(field)).GetString(); JsonElement value = rule.GetProperty(nameof(value)); var property = Expression.Property(parm, field);首先,我們得到運算符(in)、類型(string)、字段(Category)和值(一個以Clothing為唯一元素的數組)。注意對 Expression.Property 的調用。這個規則的 LINQ 看起來是這樣的:
var filter = new List<string> { "Clothing" }; Transactions.Where(t => filter.Contains(t.Category));該屬性是 t.Category,所以我們根據父屬性(t)和字段名來創建它。
8常量和調用表達式
接下來,我們需要建立對 Contains 的調用。為了簡化,我在這里創建了一個對該方法的引用:
private?readonly MethodInfo MethodContains = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public).Single(m => m.Name == nameof(Enumerable.Contains)&& m.GetParameters().Length == 2);這就動態提取了 Enumerable 的 Contains 方法,該方法需要兩個參數:要使用的集合和要檢查的值。接下來的邏輯看起來像這樣:
if (@operator == In) {var contains = MethodContains.MakeGenericMethod(typeof(string));object val = value.EnumerateArray().Select(e => e.GetString()).ToList();var right = Expression.Call(contains,Expression.Constant(val),property);left = bind(left, right); }首先,我們使用 Enumerable.Contains 模板來創建一個 Enumerable<string>。接下來,我們獲取值的列表,把它變成一個 List<string>。最后,建立我們的調用,需要傳遞:
要調用的方法(contains)
要檢查的參數的值(帶有 Clothing 的列表,或者 Expression.Constant(val))
要對其進行檢查的屬性(t.Category)。
我們的表達式樹已經相當深了,有參數、屬性、調用和常量。記住,left 仍然是空的,所以對 bind 的調用只是將 left 設置為我們剛剛創建的調用表達式。到目前為止,看起來像這樣:
Transactions.Where(t => (new List<string> { "Clothing" }).Contains(t.Category));循環往復,下一個規則是一個嵌套條件。關鍵代碼如下:
if (rule.TryGetProperty(nameof(condition), out JsonElement check)) {var right = ParseTree<T>(rule, parm);left = bind(left, right);continue; }目前,left 被分配給 in 表達式。right 將被分配為解析新條件的結果。現在,我們的 binder 被設置為 Expression.And,所以當函數返回時,bind 的調用結果是這樣的:
Transactions.Where(t => (new List<string> { "Clothing" }).Contains(t.Category) && <something>);我們再來看看這里的“something”。
9比較表達式
首先,遞歸調用確定了一個新的條件存在,這次是一個邏輯 OR。binder 被設置為 Expression.Or,規則開始運算。第一條規則是關于 TransactionType 的。它被設置為布爾值,但根據我的推斷,它意味著用戶在界面中可以選擇一個值或切換到另一個值。因此,我把它實現為一個簡單的字符串比較。下面是建立比較的代碼:
object val = (type == StringStr || type == BooleanStr) ?(object)value.GetString() : value.GetDecimal(); var toCompare = Expression.Constant(val); var right = Expression.Equal(property, toCompare); left = bind(left, right);該值被解析為字符串或小數(后面的規則將使用小數格式)。然后,該值被轉換成一個常數,然后創建比較。注意它是通過屬性比較的。現在的變量看起來像這樣:
Transactions.Where(t => t.TransactionType == "income");在這個嵌套循環中,left 仍然是空的。解析器計算了下一條規則,即 PaymentMode。bind 函數把它變成了這個“或”語句:
Transactions.Where(t => t.TransactionType == "income" || t.PaymentMode == "Cash");其余的應該是不言自明的。表達式的一個很好的特點是它們可以重載 ToString() 來展現輸出。下面就是我們的表達式的樣子(為了方便查看,我手動進行了格式化):
((value(System.Collections.Generic.List`1[System.String]).Contains(Param_0.Category)And ((Param_0.TransactionType == "income")Or(Param_0.PaymentMode == "Cash")))And(Param_0.Amount == 10) )它看起來不錯......但我們還沒有完成!
10Lambda 表達式和編譯
接下來,我創建一個 lambda 表達式。這里定義了解析后的表達式的形狀,它將是一個謂詞(Func<T,bool>)。最后,返回編譯后的委托:
var conditions = ParseTree<T>(doc.RootElement, itemExpression); if (conditions.CanReduce) {conditions = conditions.ReduceAndCheck(); } var query = Expression.Lambda<Func<T, bool>>(conditions, itemExpression); return query.Compile();為了測試,我生成了 1000 個 Transaction。然后我應用過濾器并迭代結果,這樣我就可以手動測試條件是否滿足:
var predicate = jsonExpressionParser.ParsePredicateOf<Transaction>(jsonDocument); var transactionList = Transaction.GetList(1000); var filteredTransactions = transactionList.Where(predicate).ToList(); filteredTransactions.ForEach(Console.WriteLine);正如你所看到的,結果出來了(我平均每次運行約 70 次“命中”)。
11從內存到數據庫
生成的委托并不只是用于對象。我們也可以用它來訪問數據庫。
在這篇文章的其余部分,將啟動項目設置為 DatabaseTest。如果你從命令行運行它,要確保 databaseRules.json 在你的當前目錄中。
首先,我重構了代碼。還記得表達式是如何要求一個數據源的嗎?在前面的例子中,我們編譯了表達式,最后得到了一個對對象工作的委托。為了使用不同的數據源,我們需要在編譯表達式之前將其傳遞給它。這允許數據源對其進行編譯。如果我們傳遞已編譯的數據源,數據庫提供者將被迫從數據庫中獲取所有行,然后解析返回的列表。我們希望數據庫來做這些工作。我把大部分代碼移到一個名為 ParseExpressionOf<T> 的方法中,該方法返回 lambda。我把原來的方法重構成這樣:
public?Func<T, bool> ParsePredicateOf<T>(JsonDocument doc) {var query = ParseExpressionOf<T>(doc);return query.Compile(); }ExpressionGenerator 程序使用編譯后的查詢,DatabaseTest 使用原始的 lambda 表達式。它將其應用于一個本地的 SQLite 數據庫,以演示 EF Core 是如何解析表達式的。在數據庫中創建并插入 1000 條 Transaction 后,通過下面代碼查詢總數:
var count = await context.DbTransactions.CountAsync(); Console.WriteLine($"Verified insert count: {count}.");這會生成以下 SQL 語句:
SELECT?COUNT(*) FROM "DbTransactions" AS "d"謂詞被解析(這次是來自 databaseRules.json 中的一組新規則)并傳遞給 Entity Framework Core 提供者。
var parser = new JsonExpressionParser(); var predicate = parser.ParseExpressionOf<Transaction>(JsonDocument.Parse(await File.ReadAllTextAsync("databaseRules.json")));var query = context.DbTransactions.Where(predicate).OrderBy(t => t.Id);var results = await query.ToListAsync();打開 Entity Framework Core 日志記錄開關,我們能夠檢索到生成的 SQL,看到數據條目是如何被一次性獲取和在數據庫引擎中如何計算的。注意 PaymentMode 被檢查為“Credit”而不是“Cash”。
SELECT "d"."Id", "d"."Amount", "d"."Category", "d"."PaymentMode", "d"."TransactionType" FROM "DbTransactions" AS "d" WHERE ("d"."Category" IN ('Clothing') &((("d"."TransactionType" = 'income') AND "d"."TransactionType" IS NOT NULL) |(("d"."PaymentMode" = 'Credit') AND "d"."PaymentMode" IS NOT NULL))) &("d"."Amount" = '10.0') ORDER BY "d"."Id"該示例應用程序還打印了一個實體,以進行抽查。
12總結
LINQ 表達式是一個非常強大的工具,可以過濾和轉換數據。我希望這個例子有助于理解表達式樹是如何構建的。當然,解析表達式樹感覺有點像魔術。Entity Framework Core 是如何在表達式樹上行走以產生有意義的 SQL?我正在自己探索這個問題,并得到了 ExpressionVisitor 類的幫助。我將陸續發表更多關于這個問題的文章。
總結
以上是生活随笔為你收集整理的深入LINQ | 动态构建LINQ表达式的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 智能制造建设方案
- 下一篇: 幸运从来都只偏爱有准备的人——大龄码农的