深入LINQ | 動態構建LINQ表示式

語言: CN / TW / HK

原文: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 someNumbers
where num > 10
orderby num descending
select 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 查詢都有三個階段:

  1. 設定一個數據源,稱為 提供者 (provider),供查詢時使用。例如,到目前為止的程式碼使用了內建的  LINQ to Objects 提供者。你的 EF Core 專案使用的是 EF Core 提供者,它對映到你的資料庫。

  2. 查詢被定義並轉變成一個 表示式樹 (expression tree),我將在稍後介紹。

  3. 查詢被執行,資料被返回。

第 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 仍然是空的。解析器計算了下一條規則,即  PaymentModebind 函式把它變成了這個“或”語句:

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)
)

它看起來不錯......但我們還沒有完成!

10 Lambda 表示式和編譯

接下來,我建立一個 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 類的幫助。我將陸續發表更多關於這個問題的文章。

往期 精彩 回顧

【推薦】.NET Core開發實戰視訊課程   ★★★

.NET Core實戰專案之CMS 第一章 入門篇-開篇及總體規劃

【.NET Core微服務實戰-統一身份認證】開篇及目錄索引

Redis基本使用及百億資料量中的使用技巧分享(附視訊地址及觀看指南)

.NET Core中的一個介面多種實現的依賴注入與動態選擇看這篇就夠了

10個小技巧助您寫出高效能的ASP.NET Core程式碼

用abp vNext快速開發Quartz.NET定時任務管理介面

在ASP.NET Core中建立基於Quartz.NET託管服務輕鬆實現作業排程

現身說法:實際業務出發分析百億資料量下的多表查詢優化

關於C#非同步程式設計你應該瞭解的幾點建議

C#非同步程式設計看這篇就夠了