原文:bit.ly/3uAXliC
作者:Jeremy Likness
译者:精致码农-王亮
在上一篇深入LINQ | 动态构建LINQ表达式 博文中,我们探索了表达式的强大,并用它来动态地构建一个基于 JSON 的规则引擎。在这篇文章中,我们反过来,从表达式开始。考虑到表达式类型的多样性和表达式树的复杂性,分解表达式树有什么好的方法呢?我们能否对表达式进行变异,使其有不同的表现呢?
首先,如果你还没有读过第一篇文章,请花几分钟时间去看看。本系列的的源代码放在 GitHub:
https://github.com/JeremyLikness/ExpressionExplorer
1准备工作
首先,假设我有一个普通的 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,我将在本系列的下一篇文章中详细介绍这个问题。