深入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,一经查实,立即删除!

相关文章

科幻作家阿西莫夫上世纪预言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;尤其是在日常的学习…

如何快速正确的安装 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;是摘要算法。今天小码甲带大家梳理加密算法、摘要…

还在集什么五福,史上最惨锦鲤再次来袭!奖品堪比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;}结…

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

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

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

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

VMware VSphere 虚拟化云计算学习配置笔记(四)

2:接下来配置安装vcenter-db&#xff0c;安装MS SQL 2008r2 在vcenter-db服务器上安装配置MS SQL 2008r2过程截图&#xff1a; 这里可能会要耐心等一下才会出现一下提示&#xff1a; 选择左边installation 之后出现右边第一项新的安装&#xff1a; 转载于:https://blog.51cto.c…

IT人回家过年的尴尬

全世界只有3.14 % 的人关注了数据与算法之美春节将至&#xff0c;有很多人已经踏上了返乡的旅途&#xff0c;回家开开心心过春节&#xff0c;归心似箭的心表明了他们对家乡的热爱&#xff0c;归心似箭的心显示了他们想念父母的情&#xff0c;归心似箭的心代表了他们对朋友的思。…

java 判断进程状态_获取远程服务器上 Java 进程的运行状态

为了安全考虑, 有些服务器会被限制登录. 本文介绍如何获取远程服务器上 Java 进程的运行状态.启动 jstatd 服务在服务器端启动 jstatd 服务后, 远程的机器可以通过 rmi 协议获取服务器上 Java 程序的运行状态.在服务器上创建 jstatd 的授权文件, 假设文件路径为/etc/jstatd.all…

聊一聊Elasticsearch和MySQL的常用操作

前言 ElasticSearch&#xff0c;开源的搜索和数据分析引擎&#xff0c;也是名列前茅的 nosql。很多时候会想拿它和关系型数据库做对比&#xff0c;严格上它们是没有太多可比性的。不过把有的东西放在一起比较&#xff0c;会帮助我们快速去理解一些 ElasticSearch 的内容。老黄这…

入门Python,限时1元!

爆竹声中一岁除&#xff0c;春风送暖入屠苏。猪年来袭&#xff0c;小天先跟各位模粉拜年啦&#xff01;当然&#xff0c;小天还准备了限时福利送给大家~套路&#xff1f;不存在的&#xff01;限时9元1深度学习限时体验第一章 深度学习概述与计算机视觉挑战1.1深度学习概述1.2 挑…

使用 Bridge to Kubernetes 简化云端开发

当我们面对一个大型应用程序&#xff0c;它有大量的微服务&#xff0c;并希望完成一些功能开发&#xff1f;我们面临许多挑战&#xff0c;其中之一将是处理正确的环境&#xff0c;如何进行开发。我们知道&#xff0c;在团队中解决这个问题的最佳方法是将其容器化并在云上托管。…