深入LINQ | 揭开IQueryable的面纱
原文:bit.ly/3uAXliC
作者:Jeremy Likness
譯者:精致碼農(nóng)-王亮
在上一篇深入LINQ | 動態(tài)構(gòu)建LINQ表達(dá)式?博文中,我們探索了表達(dá)式的強(qiáng)大,并用它來動態(tài)地構(gòu)建一個基于 JSON 的規(guī)則引擎。在這篇文章中,我們反過來,從表達(dá)式開始。考慮到表達(dá)式類型的多樣性和表達(dá)式樹的復(fù)雜性,分解表達(dá)式樹有什么好的方法呢?我們能否對表達(dá)式進(jìn)行變異,使其有不同的表現(xiàn)呢?
首先,如果你還沒有讀過第一篇文章,請花幾分鐘時間去看看。本系列的的源代碼放在 GitHub:
https://github.com/JeremyLikness/ExpressionExplorer1準(zhǔn)備工作
首先,假設(shè)我有一個普通的 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 表達(dá)式,它可以生成 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 的屬性。
表達(dá)式的構(gòu)建方式不會太神秘。從列表開始,Enumerable.Where 方法被調(diào)用。第一個參數(shù)是一個可枚舉列表(IEnumerable<T>),第二個參數(shù)是一個謂詞(predicate)。在 predicate 內(nèi)部,string.Contains 被調(diào)用。Enumerable.Skip 方法接收一個可枚舉列表和一個代表計數(shù)的整數(shù)。雖然構(gòu)建查詢的語法看起來很簡單,但你可以把它想象成一系列漸進(jìn)的過濾器。Skip 調(diào)用是可枚舉列表的一個擴(kuò)展方法,它從 Where 調(diào)用中獲取結(jié)果,以此類推。
也為幫助理解,我畫了一個插圖來說明這點(diǎn):
然而,如果你想解析表達(dá)式樹,你可能會大吃一驚。有許多不同的表達(dá)式類型,每一種表達(dá)式都有不同的解析方式。例如,BinaryExpression 有一個 Left 和一個 Right,但是 MethodCallExpression 有一個 Arguments 表達(dá)式列表。光是遍歷表達(dá)式樹,就有很多類型檢查和轉(zhuǎn)換了!
2另一個 Visitor
LINQ 提供了一個名為 ExpressionVisitor 的特殊類。它包含了遞歸解析表達(dá)式樹所需的所有邏輯。你只需將一個表達(dá)式傳入 Visit 方法中,它就會訪問每個節(jié)點(diǎn)并返回表達(dá)式(后面會有更多介紹)。它包含特定于節(jié)點(diǎn)類型的方法,這些方法可以被重載以攔截這個過程。下面是一個基本的實現(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)建一個實例并將一個表達(dá)式傳給它。在這里,我們將把我們的查詢表達(dá)式傳遞給它:
new BasicExpressionConsoleWriter().Visit(query.Expression);運(yùn)行后它輸出不是很直觀的結(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注意訪問順序。這可能需一點(diǎn)時間理解這個邏輯,但它是有意義的:
OrderBy 是最外層的調(diào)用(后進(jìn)先出),它接受一個列表和一個字段...
OrderBy 的第一個參數(shù)是列表,它由 Take 提供...
Take 需要一個列表,這是由 Skip 提供的...
Skip 需要一個列表,由 Where 提供...
Where 需要一個列表,該列表由 Thing 列表提供...
Where 的第二個參數(shù)是一個 predicate lambda 表達(dá)式...
...它是二元邏輯的 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 來解析并設(shè)置縮進(jìn)。Indent 屬性返回一個換行和基于當(dāng)前縮進(jìn)值的正確數(shù)量的制表符。它被各方法調(diào)用并格式化輸出。
重寫 VisitMethodCall 和 VisitBinary 可以幫助我們了解其工作原理。在 VisitMethodCall 中,方法的名稱被打印出來,并有一個代表參數(shù)的開括號(。然后這些參數(shù)被依次訪問,將繼續(xù)對每個參數(shù)進(jìn)行遞歸,直到完成。然后打印閉括號)。因為該方法明確地訪問了子節(jié)點(diǎn),而不是調(diào)用基類,該節(jié)點(diǎn)被簡單地返回。這是因為基類也會遞歸地訪問參數(shù)并導(dǎo)致重復(fù)。對于二元表達(dá)式,先打印一個開角<,然后是訪問的左邊節(jié)點(diǎn),接著是二元操作的類型,然后是右邊節(jié)點(diǎn),最后是閉合。同樣,基類方法沒有被調(diào)用,因為這些節(jié)點(diǎn)已經(jīng)被訪問過了。
運(yùn)行這個新的 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?包含了以友好格式打印表達(dá)式樹所需的一切。你可以在這里查看源代碼:
https://github.com/dotnet/runtime/blob/master/src/libraries/System.Linq.Expressions/src/System/Linq/Expressions/ExpressionStringBuilder.cs解析表達(dá)式樹的能力是相當(dāng)強(qiáng)大的。我將在另一篇博文中更深入地挖掘它,在此之前,我想解決房間里的大象:除了幫助解析表達(dá)式樹之外,Visit 方法返回表達(dá)式的意義何在?事實證明,ExpressionVisitor 能做的不僅僅是檢查你的查詢!
3侵入查詢
ExpressionVisitor 的一個神奇的特點(diǎn)是能夠快速形成一個查詢。為了理解這點(diǎn),請考慮這個場景:你的任務(wù)是建立一個具有強(qiáng)大查詢功能的訂單輸入系統(tǒng),你必須快速完成它。你讀了我的文章,決定使用 Blazor WebAssembly 并在客戶端編寫 LINQ 查詢。你使用一個自定義的 visitor 來巧妙地序列化查詢,并將其傳遞給服務(wù)器,在那里你反序列化并運(yùn)行它。一切都進(jìn)行得很順利,直到安全審計。在那里,它被確定為查詢引擎過于開放。一個惡意的客戶端可以發(fā)出極其復(fù)雜的查詢,返回大量的結(jié)果集,從而使系統(tǒng)癱瘓。你會怎么做?
使用 visitor 方法的一個好處是,你不必為了修改一個子節(jié)點(diǎn)而重構(gòu)整個表達(dá)式樹。表達(dá)式樹是不可改變的,但是 visitor 可以返回一個全新的表達(dá)式樹。你可以寫好修改表達(dá)式樹的邏輯,并在最后收到完整的表達(dá)式樹和修改內(nèi)容。為了說明這一點(diǎn),讓我們編寫一個名為 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 并返回表達(dá)式。注意,它把 ExpressionHasTake 用來標(biāo)記表達(dá)式是否有Take。假設(shè)我們只想返回 5 個結(jié)果。理論上說,你可以在查詢的最后加上 Take:
var myQuery = theirQuery.Take(5); return myQuery.ToList();但這其中的樂趣在哪里呢?讓我們來修改一個表達(dá)式樹。我們將只覆蓋一個方法,那就是 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。如果是,它將設(shè)置 ExpressionHasTake 標(biāo)志。第二個參數(shù)是要讀取的數(shù)字,所以該值被檢查并與最大值比較。如果它超過了允許的最大值,就會建立一個新的節(jié)點(diǎn),把它限制在最大值范圍內(nèi)。這個新節(jié)點(diǎn)將被返回,而不是原來的節(jié)點(diǎn)。如果該方法不是 Enumerable.Take,那么就會調(diào)用基類,一切都會“像往常一樣”被解析。
我們可以通過運(yùn)行下面代碼來測試它:
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嗎!?試試運(yùn)行這個:
var list = query.ToList(); Console.WriteLine($"\r\n---\r\nQuery results: {list.Count}");而且,不幸的是,你將看到的是 50......原始“獲取”的數(shù)量。問題是,我們生成了一個新的表達(dá)式,但我們沒有在查詢中替換它。事實上,我們不能......這是一個只讀的屬性,而表達(dá)式是不可改變的。那么現(xiàn)在怎么辦?
4移花接木
我們可以簡單地通過實現(xiàn) IOrderedQueryable<T> 來制作我們自己的查詢器,該接口是其他接口的集合。下面是該接口要求的細(xì)則。
ElementType - 這是簡單的被查詢元素的類型。
Expression - 查詢背后的表達(dá)式。
Provider - 這就是查詢提供者,它完成應(yīng)用查詢的實際工作。我們不實現(xiàn)自己的提供者,而是使用內(nèi)置的,在這種情況下是 LINQ-to-Objects。
GetEnumerator - 運(yùn)行查詢的時候會調(diào)用它,你可以隨心所欲地建立、擴(kuò)展和修改,但一旦調(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(); }它相當(dāng)簡單。它接收了一個現(xiàn)有的查詢,然后使用 ExpressionTakeRestrainer 來生成一個新的表達(dá)式。它使用現(xiàn)有的提供者(例如,如果這是一個來自 DbSet<T> 的查詢,在 SQL Server 上使用 EF Core,它將翻譯成一個 SQL 語句)。當(dāng)枚舉器被請求時,它不會傳遞原始表達(dá)式,而是傳遞翻譯后的表達(dá)式。
讓我們來使用它吧:
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(),那么你就可以隨心所欲地修改查詢。但是當(dāng)你的代碼不負(fù)責(zé)具體化查詢的時候呢?如果你暴露了一個類庫,比如一個倉儲類,它有下面這個接口會怎么樣?
public IQueryable<Thing> QueryThings { get; }或在使用 EF Core 的情況:
public DbSet<Thing> Things { get; set; }當(dāng)調(diào)用者調(diào)用 ToList() 時,你如何“攔截”查詢?這需要一個 Provider,我將在本系列的下一篇文章中詳細(xì)介紹這個問題。
總結(jié)
以上是生活随笔為你收集整理的深入LINQ | 揭开IQueryable的面纱的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: web容器获取SSL指纹实现和ByPas
- 下一篇: 你怕是对MD5算法有误解