您当前的位置: 首页 > 

寒冰屋

暂无认证

  • 0浏览

    0关注

    2286博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

深入LINQ | 揭开IQueryable的面纱

寒冰屋 发布时间:2021-06-08 13:47:24 ,浏览量:0

在上一篇深入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 Things(int count)
{
    var things = new List();
    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),第二个参数是一个谓词(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

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

  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 =>
        $"\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}");
        return node;
    }
}

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

重写 VisitMethodCall 和 VisitBinary 可以帮助我们了解其工作原理。在 VisitMethodCall 中,方法的名称被打印出来,并有一个代表参数的开括号(。然后这些参数被依次访问,将继续对每个参数进行递归,直到完成。然后打印闭括号)。因为该方法明确地访问了子节点,而不是调用基类,该节点被简单地返回。这是因为基类也会递归地访问参数并导致重复。对于二元表达式,先打印一个开角 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)
        ,2)
    ,5)
,t.Created.t)

但是等等...有5吗!?试试运行这个:

var list = query.ToList();
Console.WriteLine($"\r\n---\r\nQuery results: {list.Count}");

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

4移花接木

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

  1. ElementType - 这是简单的被查询元素的类型。

  2. Expression - 查询背后的表达式。

  3. Provider - 这就是查询提供者,它完成应用查询的实际工作。我们不实现自己的提供者,而是使用内置的,在这种情况下是 LINQ-to-Objects。

  4. GetEnumerator - 运行查询的时候会调用它,你可以随心所欲地建立、扩展和修改,但一旦调用这它,查询就被物化了。

这里是 TranslatingHost 的一个实现,它翻译了查询:

public class TranslatingHost : IOrderedQueryable, IOrderedQueryable
{
    private readonly IQueryable query;

    public Type ElementType => typeof(T);

    private Expression TranslatedExpression { get; set; }

    public TranslatingHost(IQueryable 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 GetEnumerator()
        => Provider.CreateQuery(TranslatedExpression)
        .GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

它相当简单。它接收了一个现有的查询,然后使用 ExpressionTakeRestrainer 来生成一个新的表达式。它使用现有的提供者(例如,如果这是一个来自 DbSet 的查询,在 SQL Server 上使用 EF Core,它将翻译成一个 SQL 语句)。当枚举器被请求时,它不会传递原始表达式,而是传递翻译后的表达式。

让我们来使用它吧:

var transformedQuery =
    new TranslatingHost(query, 5);
var list2 = transformedQuery.ToList();
Console.WriteLine($"\r\n---\r\nModified query results: {list2.Count}");

这次的结果是我们想要的......只返回 5 条记录。

到目前为止,我已经介绍了检查一个现有的查询并将其换掉。这在你执行查询时是有帮助的。如果你的代码是执行 query.ToList(),那么你就可以随心所欲地修改查询。但是当你的代码不负责具体化查询的时候呢?如果你暴露了一个类库,比如一个仓储类,它有下面这个接口会怎么样?

public IQueryable QueryThings { get; }

或在使用 EF Core 的情况:

public DbSet Things { get; set; }

当调用者调用 ToList() 时,你如何“拦截”查询?这需要一个 Provider,我将在本系列的下一篇文章中详细介绍这个问题。

关注
打赏
1665926880
查看更多评论
立即登录/注册

微信扫码登录

0.1397s