Liam W
封面

深入 LINQ - 揭开 IQueryable 的面纱

作者
王亮·发表于 2 年前

原文:bit.ly/3uAXliC
作者:Jeremy Likness
译者:精致码农-王亮

上一篇博文中,我们探索了表达式的强大,并用它来动态地构建一个基于 JSON 的规则引擎。在这篇文章中,我们反过来,从表达式开始。考虑到表达式类型的多样性和表达式树的复杂性,分解表达式树有什么好的方法呢?我们能否对表达式进行变异,使其有不同的表现呢?

首先,如果你还没有读过第一篇文章,请花几分钟时间去看看。本系列的的源代码放在 GitHub:

https://github.com/JeremyLikness/ExpressionExplorer

准备工作

首先,假设我有一个普通的 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() =>
        
quot;(
{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 表达式列表。光是遍历表达式树,就有很多类型检查和转换了!

另一个 Visitor

LINQ 提供了一个名为 ExpressionVisitor 的特殊类。它包含了递归解析表达式树所需的所有逻辑。你只需将一个表达式传入 Visit 方法中,它就会访问每个节点并返回表达式(后面会有更多介绍)。它包含特定于节点类型的方法,这些方法可以被重载以拦截这个过程。下面是一个基本的实现,它简单地重写了某些方法,把信息写到控制台。

public class BasicExpressionConsoleWriter : ExpressionVisitor
{
    protected override Expression VisitBinary(BinaryExpression node)
    {
        Console.Write(
quot; binary:
{node.NodeType} "
); return base.VisitBinary(node); } protected override Expression VisitUnary(UnaryExpression node) { if (node.Method != null) { Console.Write(
quot; unary:
{node.Method.Name} "
); } Console.Write(
quot; unary:
{node.Operand.NodeType} "
); return base.VisitUnary(node); } protected override Expression VisitConstant(ConstantExpression node) { Console.Write(
quot; constant:
{node.Value} "
); return base.VisitConstant(node); } protected override Expression VisitMember(MemberExpression node) { Console.Write(
quot; member:
{node.Member.Name} "
); return base.VisitMember(node); } protected override Expression VisitMethodCall(MethodCallExpression node) { Console.Write(
quot; call:
{node.Method.Name} "
); return base.VisitMethodCall(node); } protected override Expression VisitParameter(ParameterExpression node) { Console.Write(
quot; 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

注意访问顺序。这可能需一点时间理解这个逻辑,但它是有意义的:

  1. OrderBy 是最外层的调用(后进先出),它接受一个列表和一个字段…
  2. OrderBy 的第一个参数是列表,它由 Take 提供…
  3. Take 需要一个列表,这是由 Skip 提供的…
  4. Skip 需要一个列表,由 Where 提供…
  5. Where 需要一个列表,该列表由 Thing 列表提供…
  6. Where 的第二个参数是一个 predicate lambda 表达式…
  7. …它是二元逻辑的 AndAlso
  8. 二元逻辑的左边是一个 Contains 调用…
  9. (跳过一堆的逻辑)
  10. Take 的第二个参数是 50…
  11. Skip 的第二个参数是 2…
  12. OrderBy 属性是 Created

你 Get 到这里的逻辑了吗?了解树是如何解析的,是使我们的 Visitor 更易读的关键。这里有一个更一目了然的输出实现:

public class ExpressionConsoleWriter
    : ExpressionVisitor
{
    int indent;

    private string Indent =>
        
quot;\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(
quot;
{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(
quot;.
{node.Member?.Name}."
); return node; } protected override Expression VisitMethodCall(MethodCallExpression node) { if (node.Object != null) { Visit(node.Object); } Console.Write(
quot;
{Indent}{node.Method.Name}( "
); var first = true; indent++; foreach (var arg in node.Arguments) { if (first) { first = false; } else { indent--; Console.Write(
quot;
{Indent},"
); indent++; } Visit(arg); } indent--; Console.Write(") "); return node; } protected override Expression VisitBinary(BinaryExpression node) { Console.Write(
quot;
{Indent}<"
); indent++; Visit(node.Left); indent--; Console.Write(
quot;
{Indent}{node.NodeType}"
); indent++; Visit(node.Right); indent--; Console.Write(">"); return node; } }

引入了新的入口方法 Parse 来解析并设置缩进。Indent 属性返回一个换行和基于当前缩进值的正确数量的制表符。它被各方法调用并格式化输出。

重写 VisitMethodCallVisitBinary 可以帮助我们了解其工作原理。在 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 能做的不仅仅是检查你的查询!

侵入查询

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(
quot;\r\n---\r\nQuery results:
{list.Count}"
);

而且,不幸的是,你将看到的是 50…原始“获取”的数量。问题是,我们生成了一个新的表达式,但我们没有在查询中替换它。事实上,我们不能…这是一个只读的属性,而表达式是不可改变的。那么现在怎么办?

移花接木

我们可以简单地通过实现 IOrderedQueryable<T> 来制作我们自己的查询器,该接口是其他接口的集合。下面是该接口要求的细则。

  1. ElementType - 这是简单的被查询元素的类型。
  2. Expression - 查询背后的表达式。
  3. Provider - 这就是查询提供者,它完成应用查询的实际工作。我们不实现自己的提供者,而是使用内置的,在这种情况下是 LINQ-to-Objects。
  4. 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(
quot;\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,我将在本系列的下一篇文章中详细介绍这个问题。