深入LINQ | 揭开IQueryable的面纱

原文: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

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

  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}<");indent++;Visit(node.Left);indent--;Console.Write($"{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 能做的不仅仅是检查你的查询!

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> 来制作我们自己的查询器,该接口是其他接口的集合。下面是该接口要求的细则。

  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($"\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,我将在本系列的下一篇文章中详细介绍这个问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/301875.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

arcgispython脚本开发工具_06-05【求助】利用python脚本语言开发新的Arctoolbox工具

该楼层疑似违规已被系统折叠 隐藏此楼查看此楼下面是一段python代码&#xff0c;要如何才能在Arctoolbox中实现呢&#xff1f;#-*- encoding:UTF-8 -*-#!/usr/bin/env python# Author: 谢长波# Purpose: 按字段分割要素类# Created: 2012/10/9import sysimport arcpyimport str…

51单片机实现4位数以内的加减法

中南民族大学&#xff0c;电子信息工程学院&#xff0c;开设了一门课程&#xff0c;叫《嵌入式电路设计》&#xff0c;由舒老师带头&#xff0c;旨在引导低年级的学生入门嵌入式&#xff0c;其内容比较多&#xff0c;包括模电、数电、C语言、单片机、EDA技术等。最近有位师弟找…

科幻作家阿西莫夫上世纪预言2019: 计算机彻底变革教育,太空移民进行中

全世界只有3.14 % 的人关注了数据与算法之美2019年必将是充满机遇与挑战的一年&#xff0c;一年的时间世界可能发生很大的变化。我们应该如何期待新的一年&#xff1f;在这&#xff0c;数据汪带大家重新读1983年美国作家艾萨克阿西莫夫&#xff08;Isaac Asimov&#xff09;对2…

6月程序员平均工资出炉,你拖后腿了吗?

据有关部门统计&#xff1a;6月份全国招收程序员435501人&#xff0c;平均工资为15052元&#xff0c;很多小伙伴儿纷纷感慨工资被平均了。然而6月程序员工资的中位数却是13000元&#xff0c;这说明什么&#xff1f;也许不是被平均&#xff0c;而是真的拖后腿了&#xff0c;下面…

学习爬虫限时只需9.9,还在犹豫什么?

收拾行李回老家的小天&#xff0c;新春福利还是要准时送到大家手上的&#xff01;随着互联网的发展&#xff0c;google、百度等搜索引擎让我们获取信息愈加方便。Python是当今世界最热门的编程语言之一&#xff0c;在科研领域也发挥着强大的作用&#xff0c;尤其是在日常的学习…

eomj表情 mysql_mysql存储4字节的表情包数据报异常_Emoji表情包_Incorrect string value: #3...

本文章转载自&#xff1a;https://www.cnblogs.com/coprince/p/7485968.html原文如下&#xff1a;问题描述&#xff1a;从新浪微博抓取消息保存到MySQL数据中&#xff0c;对应数据库字段为varchar&#xff0c;字符编码utf-8。部分插入成功&#xff0c;部分插入失败&#xff0c;…

如何快速正确的安装 Ruby, Rails 运行环境

2019独角兽企业重金招聘Python工程师标准>>> 系统需求 首先确定操作系统环境&#xff0c;不建议在 Windows 上面搞&#xff0c;所以你需要用: Mac OS X任意 Linux 发行版本(Ubuntu,CentOS, Redhat, ArchLinux ...)强烈新手使用 Ubuntu 省掉不必要的麻烦&#xff01;…

你怕是对MD5算法有误解

大家常听到“MD5加密”、“对称加密”、“非对称加密”&#xff0c;那么MD5属于哪种加密算法&#xff1f;面试官问这样的问题&#xff0c;准是在给你挖坑。"MD5加密"纯属口嗨&#xff0c;MD5不是加密算法&#xff0c;是摘要算法。今天小码甲带大家梳理加密算法、摘要…

java main 声明_Java中main方面面试题

1.不用main方法如何定义一个类&#xff1f;不行&#xff0c;没有main方法我们不能运行Java类。在Java 7之前&#xff0c;你可以通过使用静态初始化运行Java类。但是&#xff0c;从Java 7开始就行不通了。2.main()方法需要的参数不是字符串数组&#xff1f;不是的&#xff0c;ma…

还在集什么五福,史上最惨锦鲤再次来袭!奖品堪比5年高考3年模拟!

全世界只有3.14 % 的人关注了数据与算法之美在锦鲤盛行的2018年我们超级数学建模也跟风来了一个“史上最惨锦鲤”活动为什么叫史上最惨锦鲤呢因为平常看一本数学书就已经头疼了何况我们奖品还是100本数学书试问除了学霸还有谁能承受这种殊荣巧的是最后的得主还真是一个学霸那就…

Python: logging日志模块简单示例

2019独角兽企业重金招聘Python工程师标准>>> Python的logging模块提供了通用的日志系统&#xff0c;可以方便第三方模块或者是应用使用。这个模块提供不同的日志级别&#xff0c;并可以采用不同的方式记录日志&#xff0c;比如文件&#xff0c;HTTP GET/POST&#x…

开源基金会为何辟谣 鸿蒙背后有何玄机

最近&#xff0c;鸿蒙OS曝光率极高&#xff0c;受网络舆论热捧。铁流原本是不太愿意碰这个雷的&#xff0c;因网友提问&#xff0c;所以谈一谈这个事情。OpenHarmony和Harmony是两回事6月2日&#xff0c;公司官宣鸿蒙OS&#xff0c;给出了百台机型的升级计划&#xff0c;而且还…

java get image获取根路径_Java 获取资源文件路径

1 问题描述通过源码运行时&#xff0c;一般使用如下方式读取资源文件&#xff1a;String str "1.jpg";资源文件与源码文件放在同一目录下&#xff0c;或者拥有同一父级目录&#xff1a;String str "a/b/1.jpg";这样直接编译运行没有问题&#xff0c;但是…

阿里云开源的Blink,计算能力很疯狂:一眨眼,全部都算好!

全世界只有3.14 % 的人关注了数据与算法之美前两天&#xff0c;阿里云宣布开源“计算王牌”——实时计算平台Blink&#xff0c;回馈给ApacheFlink社区。官方称&#xff0c;计算延迟已经降到毫秒级&#xff1a;浏览网页的时候&#xff0c;你只是眨了一下眼睛&#xff0c;但在淘宝…

NET问答: 如何用 C# 计算相对时间 ?

咨询区 Jeff Atwood&#xff1a;给定一个 DataTime 值&#xff0c;如何计算如下时间&#xff1f;比如说&#xff1a;2 小时前&#xff1f;3 天前&#xff1f;1 个月前&#xff1f;回答区 neuracnu&#xff1a;我在 DateTime 类上做了一个扩展方法&#xff0c;你可以给它传递未来…

有趣程序和让人捧腹大笑的注释,你的注释还不够骚

全世界只有3.14 % 的人关注了数据与算法之美一&#xff0c;这个是关于数组的操作&#xff0c;数据汪也是才知道&#xff0c;居然还有这种操作# include <stdio.h>int main(){ int num[] {1,2,3,4};printf("the 3[num] result is : %d\n", 2[num]);return 0;}结…

java linux下载文件_java 从linux 下载文件

public static void main(String[] args) {SshClient client new SshClient();try {ConsoleKnownHostsKeyVerification console new ConsoleKnownHostsKeyVerification();client.connect("192.168.13.51", 22);//IP和端口//设置用户名和密码PasswordAuthentication…

读jQuery之二十(Deferred对象)

Deferred对象是由 jQuery.Deferred 构造的&#xff0c;jQuery.Deferred 被实现为简单工厂模式。 它用来解决JS中的异步编程&#xff0c;它遵循 Common Promise/A 规范。实现此规范的还有 when.js 和 dojo。 $.Deferred作为新特性首次出现在版本1.5中&#xff0c;这个版本利用De…

.NET之盛派微信SDK简单操作

开篇语在6月5号&#xff0c;我报名去参加了微软举办的一个线下分享的技术市集&#xff0c;分享人是苏老师和赵老师(非常感谢)&#xff0c;其中苏老师讲述了关于一些容器的部署等操作(也可以观看我的历史文章)&#xff0c;其中关于部署的示例代码是使用盛派微信SDK做了一个对接微…

预售┃连锁反应装置积木好玩到尖叫!

▲数据汪特别推荐点击上图进入玩酷屋之前推荐的“小小机械师”成了孩子最受欢迎的玩具&#xff0c;玩了的孩子几乎没有不喜欢的。还有家长买多套送给朋友的孩子作为生日礼物。有家长来问&#xff0c;还有没有这种类型的新产品。和厂家咨询后&#xff0c;小木找到了一款“小小机…