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