深入LINQ | 揭開IQueryable的面紗
原文:bit.ly/3uAXliC
作者:Jeremy Likness
譯者:精致碼農(nóng)-王亮
在上一篇博文中,我們探索了表達式的強大,并用它來動態(tài)地構(gòu)建一個基于 JSON 的規(guī)則引擎。在這篇文章中,我們反過來,從表達式開始。考慮到表達式類型的多樣性和表達式樹的復雜性,分解表達式樹有什么好的方法呢?我們能否對表達式進行變異,使其有不同的表現(xiàn)呢?
首先,如果你還沒有讀過第一篇文章,請花幾分鐘時間去看看。本系列的的源代碼放在 GitHub:
https://github.com/JeremyLikness/ExpressionExplorer1準備工作
首先,假設我有一個普通的 CLR 實體類(你可能聽說過它被稱為 POCO),該類名為 Thing。下面是它的定義:
public class Thing
{
public Thing()
{
Id = Guid.NewGuid().ToString();
Created = DateTimeOffset.Now;
Name = Guid.NewGuid().ToString().Split("-")[0];
}
public string Id { get; set; }
public string Name { get; set; }
public DateTimeOffset Created { get; private set; }
public string GetId() => Id;
public override string ToString() =>
$"({Id}: {Name}@{Created})";
}為了模擬,我添加了一個靜態(tài)方法,使其很容易生成 N 個數(shù)量的 Thing:
public static IList<Thing> Things(int count)
{
var things = new List<Thing>();
while (count-- > 0)
{
things.Add(new Thing());
}
return things;
}現(xiàn)在我可以生成一個數(shù)據(jù)源并查詢它。這里有一個 LINQ 表達式,它可以生成 500 個 Thing 并查詢它們:
var query = Thing.Things(500).AsQueryable()
.Where(t =>
t.Name.Contains("a", StringComparison.InvariantCultureIgnoreCase) &&
t.Created > DateTimeOffset.Now.AddDays(-1))
.Skip(2)
.Take(50)
.OrderBy(t => t.Created);如果你對 query 調(diào)用 ToString(),你會得到這樣的結(jié)果:
System.Collections.Generic.List`1[ExpressionExplorer.Thing]
.Where(t =>
(t.Name.Contains("a", InvariantCultureIgnoreCase)
AndAlso
(t.Created > DateTimeOffset.Now.AddDays(-1))))
.Skip(2)
.Take(50)
.OrderBy(t => t.Created)你可能沒有注意到,query 有一個名為 Expression 的屬性。
表達式的構(gòu)建方式不會太神秘。從列表開始,Enumerable.Where 方法被調(diào)用。第一個參數(shù)是一個可枚舉列表(IEnumerable<T>),第二個參數(shù)是一個謂詞(predicate)。在 predicate 內(nèi)部,string.Contains 被調(diào)用。Enumerable.Skip 方法接收一個可枚舉列表和一個代表計數(shù)的整數(shù)。雖然構(gòu)建查詢的語法看起來很簡單,但你可以把它想象成一系列漸進的過濾器。Skip 調(diào)用是可枚舉列表的一個擴展方法,它從 Where 調(diào)用中獲取結(jié)果,以此類推。
也為幫助理解,我畫了一個插圖來說明這點:

然而,如果你想解析表達式樹,你可能會大吃一驚。有許多不同的表達式類型,每一種表達式都有不同的解析方式。例如,BinaryExpression 有一個 Left 和一個 Right,但是 MethodCallExpression 有一個 Arguments 表達式列表。光是遍歷表達式樹,就有很多類型檢查和轉(zhuǎn)換了!
2另一個 Visitor
LINQ 提供了一個名為 ExpressionVisitor 的特殊類。它包含了遞歸解析表達式樹所需的所有邏輯。你只需將一個表達式傳入 Visit 方法中,它就會訪問每個節(jié)點并返回表達式(后面會有更多介紹)。它包含特定于節(jié)點類型的方法,這些方法可以被重載以攔截這個過程。下面是一個基本的實現(xiàn),它簡單地重寫了某些方法,把信息寫到控制臺。
public class BasicExpressionConsoleWriter : ExpressionVisitor
{
protected override Expression VisitBinary(BinaryExpression node)
{
Console.Write($" binary:{node.NodeType} ");
return base.VisitBinary(node);
}
protected override Expression VisitUnary(UnaryExpression node)
{
if (node.Method != null)
{
Console.Write($" unary:{node.Method.Name} ");
}
Console.Write($" unary:{node.Operand.NodeType} ");
return base.VisitUnary(node);
}
protected override Expression VisitConstant(ConstantExpression node)
{
Console.Write($" constant:{node.Value} ");
return base.VisitConstant(node);
}
protected override Expression VisitMember(MemberExpression node)
{
Console.Write($" member:{node.Member.Name} ");
return base.VisitMember(node);
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
Console.Write($" call:{node.Method.Name} ");
return base.VisitMethodCall(node);
}
protected override Expression VisitParameter(ParameterExpression node)
{
Console.Write($" p:{node.Name} ");
return base.VisitParameter(node);
}
}要使用它,只需創(chuàng)建一個實例并將一個表達式傳給它。在這里,我們將把我們的查詢表達式傳遞給它:
new BasicExpressionConsoleWriter().Visit(query.Expression);運行后它輸出不是很直觀的結(jié)果,如下:
call:OrderBy call:Take call:Skip call:Where
constant:System.Collections.Generic.List`1[ExpressionExplorer.Thing] unary:Lambda
binary:AndAlso call:Contains member:Name p:t constant:a
constant:InvariantCultureIgnoreCase binary:GreaterThan member:Created p:t
call:AddDays member:Now constant:-1 p:t constant:2 constant:50
unary:Lambda member:Created p:t p:t注意訪問順序。這可能需一點時間理解這個邏輯,但它是有意義的:
OrderBy是最外層的調(diào)用(后進先出),它接受一個列表和一個字段...OrderBy的第一個參數(shù)是列表,它由Take提供...Take需要一個列表,這是由Skip提供的...Skip需要一個列表,由Where提供...Where需要一個列表,該列表由Thing列表提供...Where的第二個參數(shù)是一個 predicate lambda 表達式......它是二元邏輯的
AndAlso...二元邏輯的左邊是一個
Contains調(diào)用...(跳過一堆的邏輯)
Take的第二個參數(shù)是 50...Skip的第二個參數(shù)是 2...OrderBy屬性是Created...
你 Get 到這里的邏輯了嗎?了解樹是如何解析的,是使我們的 Visitor 更易讀的關(guān)鍵。這里有一個更一目了然的輸出實現(xiàn):
public class ExpressionConsoleWriter
: ExpressionVisitor
{
int indent;
private string Indent =>
$"\r\n{new string('\t', indent)}";
public void Parse(Expression expression)
{
indent = 0;
Visit(expression);
}
protected override Expression VisitConstant(ConstantExpression node)
{
if (node.Value is Expression value)
{
Visit(value);
}
else
{
Console.Write($"{node.Value}");
}
return node;
}
protected override Expression VisitParameter(ParameterExpression node)
{
Console.Write(node.Name);
return node;
}
protected override Expression VisitMember(MemberExpression node)
{
if (node.Expression != null)
{
Visit(node.Expression);
}
Console.Write($".{node.Member?.Name}.");
return node;
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
if (node.Object != null)
{
Visit(node.Object);
}
Console.Write($"{Indent}{node.Method.Name}( ");
var first = true;
indent++;
foreach (var arg in node.Arguments)
{
if (first)
{
first = false;
}
else
{
indent--;
Console.Write($"{Indent},");
indent++;
}
Visit(arg);
}
indent--;
Console.Write(") ");
return node;
}
protected override Expression VisitBinary(BinaryExpression node)
{
Console.Write($"{Indent}<");
indent++;
Visit(node.Left);
indent--;
Console.Write($"{Indent}{node.NodeType}");
indent++;
Visit(node.Right);
indent--;
Console.Write(">");
return node;
}
}引入了新的入口方法 Parse 來解析并設置縮進。Indent 屬性返回一個換行和基于當前縮進值的正確數(shù)量的制表符。它被各方法調(diào)用并格式化輸出。
重寫 VisitMethodCall 和 VisitBinary 可以幫助我們了解其工作原理。在 VisitMethodCall 中,方法的名稱被打印出來,并有一個代表參數(shù)的開括號(。然后這些參數(shù)被依次訪問,將繼續(xù)對每個參數(shù)進行遞歸,直到完成。然后打印閉括號)。因為該方法明確地訪問了子節(jié)點,而不是調(diào)用基類,該節(jié)點被簡單地返回。這是因為基類也會遞歸地訪問參數(shù)并導致重復。對于二元表達式,先打印一個開角<,然后是訪問的左邊節(jié)點,接著是二元操作的類型,然后是右邊節(jié)點,最后是閉合。同樣,基類方法沒有被調(diào)用,因為這些節(jié)點已經(jīng)被訪問過了。
運行這個新的 visitor:
new ExpressionConsoleWriter().Visit(query.Expression);輸出結(jié)果可讀性更好:
OrderBy(
Take(
Skip(
Where( System.Collections.Generic.List`1[ExpressionExplorer.Thing]
,
<t.Name.
Contains( a
,InvariantCultureIgnoreCase)
AndAlso
<t.Created.
GreaterThan.Now.
AddDays( -1) >>t)
,2)
,50)
,t.Created.t)要想查看完整的實現(xiàn), LINQ 本身的 ExpressionStringBuilder 包含了以友好格式打印表達式樹所需的一切。你可以在這里查看源代碼:
https://github.com/dotnet/runtime/blob/master/src/libraries/System.Linq.Expressions/src/System/Linq/Expressions/ExpressionStringBuilder.cs解析表達式樹的能力是相當強大的。我將在另一篇博文中更深入地挖掘它,在此之前,我想解決房間里的大象:除了幫助解析表達式樹之外,Visit 方法返回表達式的意義何在?事實證明,ExpressionVisitor 能做的不僅僅是檢查你的查詢!
3侵入查詢
ExpressionVisitor 的一個神奇的特點是能夠快速形成一個查詢。為了理解這點,請考慮這個場景:你的任務是建立一個具有強大查詢功能的訂單輸入系統(tǒng),你必須快速完成它。你讀了我的文章,決定使用 Blazor WebAssembly 并在客戶端編寫 LINQ 查詢。你使用一個自定義的 visitor 來巧妙地序列化查詢,并將其傳遞給服務器,在那里你反序列化并運行它。一切都進行得很順利,直到安全審計。在那里,它被確定為查詢引擎過于開放。一個惡意的客戶端可以發(fā)出極其復雜的查詢,返回大量的結(jié)果集,從而使系統(tǒng)癱瘓。你會怎么做?
使用 visitor 方法的一個好處是,你不必為了修改一個子節(jié)點而重構(gòu)整個表達式樹。表達式樹是不可改變的,但是 visitor 可以返回一個全新的表達式樹。你可以寫好修改表達式樹的邏輯,并在最后收到完整的表達式樹和修改內(nèi)容。為了說明這一點,讓我們編寫一個名為 ExpressionTakeRestrainer 的特殊 Visitor:
public class ExpressionTakeRestrainer : ExpressionVisitor
{
private int maxTake;
public bool ExpressionHasTake { get; private set; }
public Expression ParseAndConstrainTake(
Expression expression, int maxTake)
{
this.maxTake = maxTake;
ExpressionHasTake = false;
return Visit(expression);
}
}特殊的 ParseAndConstrainTake 方法將調(diào)用 Visit 并返回表達式。注意,它把 ExpressionHasTake 用來標記表達式是否有Take。假設我們只想返回 5 個結(jié)果。理論上說,你可以在查詢的最后加上 Take:
var myQuery = theirQuery.Take(5);
return myQuery.ToList();但這其中的樂趣在哪里呢?讓我們來修改一個表達式樹。我們將只覆蓋一個方法,那就是 VisitMethodCall:
protected override Expression VisitMethodCall(MethodCallExpression node)
{
if (node.Method.Name == nameof(Enumerable.Take))
{
ExpressionHasTake = true;
if (node.Arguments.Count == 2 &&
node.Arguments[1] is ConstantExpression constant)
{
var takeCount = (int)constant.Value;
if (takeCount > maxTake)
{
var arg1 = Visit(node.Arguments[0]);
var arg2 = Expression.Constant(maxTake);
var methodCall = Expression.Call(
node.Object,
node.Method,
new[] { arg1, arg2 } );
return methodCall;
}
}
}
return base.VisitMethodCall(node);
}該邏輯檢查方法的調(diào)用是否是 Enumerable.Take。如果是,它將設置 ExpressionHasTake 標志。第二個參數(shù)是要讀取的數(shù)字,所以該值被檢查并與最大值比較。如果它超過了允許的最大值,就會建立一個新的節(jié)點,把它限制在最大值范圍內(nèi)。這個新節(jié)點將被返回,而不是原來的節(jié)點。如果該方法不是 Enumerable.Take,那么就會調(diào)用基類,一切都會“像往常一樣”被解析。
我們可以通過運行下面代碼來測試它:
new ExpressionConsoleWriter().Parse(
new ExpressionTakeRestrainer()
.ParseAndConstrainTake(query.Expression, 5));看看下面的結(jié)果:查詢已被修改為只取 5 條數(shù)據(jù)。
OrderBy(
Take(
Skip(
Where( System.Collections.Generic.List`1[ExpressionExplorer.Thing]
,
<t.Name.
Contains( a
,InvariantCultureIgnoreCase)
AndAlso
<t.Created.
GreaterThan.Now.
AddDays(-1) >>t)
,2)
,5)
,t.Created.t)但是等等...有5嗎!?試試運行這個:
var list = query.ToList();
Console.WriteLine($"\r\n---\r\nQuery results: {list.Count}");而且,不幸的是,你將看到的是 50......原始“獲取”的數(shù)量。問題是,我們生成了一個新的表達式,但我們沒有在查詢中替換它。事實上,我們不能......這是一個只讀的屬性,而表達式是不可改變的。那么現(xiàn)在怎么辦?
4移花接木
我們可以簡單地通過實現(xiàn) IOrderedQueryable<T> 來制作我們自己的查詢器,該接口是其他接口的集合。下面是該接口要求的細則。
ElementType- 這是簡單的被查詢元素的類型。Expression- 查詢背后的表達式。Provider- 這就是查詢提供者,它完成應用查詢的實際工作。我們不實現(xiàn)自己的提供者,而是使用內(nèi)置的,在這種情況下是 LINQ-to-Objects。GetEnumerator- 運行查詢的時候會調(diào)用它,你可以隨心所欲地建立、擴展和修改,但一旦調(diào)用這它,查詢就被物化了。
這里是 TranslatingHost 的一個實現(xiàn),它翻譯了查詢:
public class TranslatingHost<T> : IOrderedQueryable<T>, IOrderedQueryable
{
private readonly IQueryable<T> query;
public Type ElementType => typeof(T);
private Expression TranslatedExpression { get; set; }
public TranslatingHost(IQueryable<T> query, int maxTake)
{
this.query = query;
var translator = new ExpressionTakeRestrainer();
TranslatedExpression = translator
.ParseAndConstrainTake(query.Expression, maxTake);
}
public Expression Expression => TranslatedExpression;
public IQueryProvider Provider => query.Provider;
public IEnumerator<T> GetEnumerator()
=> Provider.CreateQuery<T>(TranslatedExpression)
.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}它相當簡單。它接收了一個現(xiàn)有的查詢,然后使用 ExpressionTakeRestrainer 來生成一個新的表達式。它使用現(xiàn)有的提供者(例如,如果這是一個來自 DbSet<T> 的查詢,在 SQL Server 上使用 EF Core,它將翻譯成一個 SQL 語句)。當枚舉器被請求時,它不會傳遞原始表達式,而是傳遞翻譯后的表達式。
讓我們來使用它吧:
var transformedQuery =
new TranslatingHost<Thing>(query, 5);
var list2 = transformedQuery.ToList();
Console.WriteLine($"\r\n---\r\nModified query results: {list2.Count}");這次的結(jié)果是我們想要的......只返回 5 條記錄。
到目前為止,我已經(jīng)介紹了檢查一個現(xiàn)有的查詢并將其換掉。這在你執(zhí)行查詢時是有幫助的。如果你的代碼是執(zhí)行 query.ToList(),那么你就可以隨心所欲地修改查詢。但是當你的代碼不負責具體化查詢的時候呢?如果你暴露了一個類庫,比如一個倉儲類,它有下面這個接口會怎么樣?
public IQueryable<Thing> QueryThings { get; }或在使用 EF Core 的情況:
public DbSet<Thing> Things { get; set; }當調(diào)用者調(diào)用 ToList() 時,你如何“攔截”查詢?這需要一個 Provider,我將在本系列的下一篇文章中詳細介紹這個問題。


去TM收費,我要在線 Vip 視頻解析!

【限時刪】劉*55頁ppt大瓜,比項*醒的還要精彩!
