原文:bit。ly3uAXliC 作者:JeremyLikness 译者:精致码农王亮 在上一篇博文中,我们探索了表达式的强大,并用它来动态地构建一个基于JSON的规则引擎。在这篇文章中,我们反过来,从表达式开始。考虑到表达式类型的多样性和表达式树的复杂性,分解表达式树有什么好的方法呢?我们能否对表达式进行变异,使其有不同的表现呢? 首先,如果你还没有读过第一篇文章,请花几分钟时间去看看。本系列的的源代码放在GitHub:https:github。comJeremyLiknessExpressionExplorer准备工作 首先,假设我有一个普通的CLR实体类(你可能听说过它被称为POCO),该类名为Thing。下面是它的定义:publicclassThing{publicThing(){IdGuid。NewGuid()。ToString();CreatedDateTimeOffset。Now;NameGuid。NewGuid()。ToString()。Split()〔0〕;}publicstringId{get;set;}publicstringName{get;set;}publicDateTimeOffsetCreated{get;privateset;}publicstringGetId()Id;publicoverridestringToString()34;({Id}:{Name}{Created});} 为了模拟,我添加了一个静态方法,使其很容易生成N个数量的Thing:publicstaticIListThingThings(intcount){varthingsnewListThing();while(count0){things。Add(newThing());}returnthings;} 现在我可以生成一个数据源并查询它。这里有一个LINQ表达式,它可以生成500个Thing并查询它们:varqueryThing。Things(500)。AsQueryable()。Where(tt。Name。Contains(a,StringComparison。InvariantCultureIgnoreCase)t。CreatedDateTimeOffset。Now。AddDays(1))。Skip(2)。Take(50)。OrderBy(tt。Created); 如果你对query调用ToString(),你会得到这样的结果:System。Collections。Generic。List1〔ExpressionExplorer。Thing〕。Where(t(t。Name。Contains(a,InvariantCultureIgnoreCase)AndAlso(t。CreatedDateTimeOffset。Now。AddDays(1))))。Skip(2)。Take(50)。OrderBy(tt。Created) 你可能没有注意到,query有一个名为Expression的属性。 表达式的构建方式不会太神秘。从列表开始,Enumerable。Where方法被调用。第一个参数是一个可枚举列表(IEnumerable),第二个参数是一个谓词(predicate)。在predicate内部,string。Contains被调用。Enumerable。Skip方法接收一个可枚举列表和一个代表计数的整数。虽然构建查询的语法看起来很简单,但你可以把它想象成一系列渐进的过滤器。Skip调用是可枚举列表的一个扩展方法,它从Where调用中获取结果,以此类推。 也为帮助理解,我画了一个插图来说明这点: 然而,如果你想解析表达式树,你可能会大吃一惊。有许多不同的表达式类型,每一种表达式都有不同的解析方式。例如,BinaryExpression有一个Left和一个Right,但是MethodCallExpression有一个Arguments表达式列表。光是遍历表达式树,就有很多类型检查和转换了!另一个Visitor LINQ提供了一个名为ExpressionVisitor的特殊类。它包含了递归解析表达式树所需的所有逻辑。你只需将一个表达式传入Visit方法中,它就会访问每个节点并返回表达式(后面会有更多介绍)。它包含特定于节点类型的方法,这些方法可以被重载以拦截这个过程。下面是一个基本的实现,它简单地重写了某些方法,把信息写到控制台。publicclassBasicExpressionConsoleWriter:ExpressionVisitor{protectedoverrideExpressionVisitBinary(BinaryExpressionnode){Console。Write(34;binary:{node。NodeType});returnbase。VisitBinary(node);}protectedoverrideExpressionVisitUnary(UnaryExpressionnode){if(node。Method!null){Console。Write(34;unary:{node。Method。Name});}Console。Write(34;unary:{node。Operand。NodeType});returnbase。VisitUnary(node);}protectedoverrideExpressionVisitConstant(ConstantExpressionnode){Console。Write(34;constant:{node。Value});returnbase。VisitConstant(node);}protectedoverrideExpressionVisitMember(MemberExpressionnode){Console。Write(34;member:{node。Member。Name});returnbase。VisitMember(node);}protectedoverrideExpressionVisitMethodCall(MethodCallExpressionnode){Console。Write(34;call:{node。Method。Name});returnbase。VisitMethodCall(node);}protectedoverrideExpressionVisitParameter(ParameterExpressionnode){Console。Write(34;p:{node。Name});returnbase。VisitParameter(node);}} 要使用它,只需创建一个实例并将一个表达式传给它。在这里,我们将把我们的查询表达式传递给它:newBasicExpressionConsoleWriter()。Visit(query。Expression); 运行后它输出不是很直观的结果,如下:call:OrderBycall:Takecall:Skipcall:Whereconstant:System。Collections。Generic。List1〔ExpressionExplorer。Thing〕unary:Lambdabinary:AndAlsocall:Containsmember:Namep:tconstant:aconstant:InvariantCultureIgnoreCasebinary:GreaterThanmember:Createdp:tcall:AddDaysmember:Nowconstant:1p:tconstant:2constant:50unary:Lambdamember:Createdp:tp:t 注意访问顺序。这可能需一点时间理解这个逻辑,但它是有意义的:OrderBy是最外层的调用(后进先出),它接受一个列表和一个字段。。。OrderBy的第一个参数是列表,它由Take提供。。。Take需要一个列表,这是由Skip提供的。。。Skip需要一个列表,由Where提供。。。Where需要一个列表,该列表由Thing列表提供。。。Where的第二个参数是一个predicatelambda表达式。。。。。。它是二元逻辑的AndAlso。。。二元逻辑的左边是一个Contains调用。。。(跳过一堆的逻辑)Take的第二个参数是50。。。Skip的第二个参数是2。。。OrderBy属性是Created。。。 你Get到这里的逻辑了吗?了解树是如何解析的,是使我们的Visitor更易读的关键。这里有一个更一目了然的输出实现:publicclassExpressionConsoleWriter:ExpressionVisitor{intindent;privatestringIndent34;r{newstring(,indent)};publicvoidParse(Expressionexpression){indent0;Visit(expression);}protectedoverrideExpressionVisitConstant(ConstantExpressionnode){if(node。ValueisExpressionvalue){Visit(value);}else{Console。Write(34;{node。Value});}returnnode;}protectedoverrideExpressionVisitParameter(ParameterExpressionnode){Console。Write(node。Name);returnnode;}protectedoverrideExpressionVisitMember(MemberExpressionnode){if(node。Expression!null){Visit(node。Expression);}Console。Write(34;。{node。Member?。Name}。);returnnode;}protectedoverrideExpressionVisitMethodCall(MethodCallExpressionnode){if(node。Object!null){Visit(node。Object);}Console。Write(34;{Indent}{node。Method。Name}();varfirsttrue;indent;foreach(vararginnode。Arguments){if(first){firstfalse;}else{indent;Console。Write(34;{Indent},);indent;}Visit(arg);}indent;Console。Write());returnnode;}protectedoverrideExpressionVisitBinary(BinaryExpressionnode){Console。Write(34;{Indent});indent;Visit(node。Left);indent;Console。Write(34;{Indent}{node。NodeType});indent;Visit(node。Right);indent;Console。Write();returnnode;}} 引入了新的入口方法Parse来解析并设置缩进。Indent属性返回一个换行和基于当前缩进值的正确数量的制表符。它被各方法调用并格式化输出。 重写VisitMethodCall和VisitBinary可以帮助我们了解其工作原理。在VisitMethodCall中,方法的名称被打印出来,并有一个代表参数的开括号(。然后这些参数被依次访问,将继续对每个参数进行递归,直到完成。然后打印闭括号)。因为该方法明确地访问了子节点,而不是调用基类,该节点被简单地返回。这是因为基类也会递归地访问参数并导致重复。对于二元表达式,先打印一个开角,然后是访问的左边节点,接着是二元操作的类型,然后是右边节点,最后是闭合。同样,基类方法没有被调用,因为这些节点已经被访问过了。 运行这个新的visitor:newExpressionConsoleWriter()。Visit(query。Expression); 输出结果可读性更好:OrderBy(Take(Skip(Where(System。Collections。Generic。List1〔ExpressionExplorer。Thing〕,t。Name。Contains(a,InvariantCultureIgnoreCase)AndAlsot。Created。GreaterThan。Now。AddDays(1)t),2),50),t。Created。t) 要想查看完整的实现,LINQ本身的ExpressionStringBuilder包含了以友好格式打印表达式树所需的一切。你可以在这里查看源代码:https:github。comdotnetruntimeblobmastersrclibrariesSystem。Linq。ExpressionssrcSystemLinqExpressionsExpressionStringBuilder。cs 解析表达式树的能力是相当强大的。我将在另一篇博文中更深入地挖掘它,在此之前,我想解决房间里的大象:除了帮助解析表达式树之外,Visit方法返回表达式的意义何在?事实证明,ExpressionVisitor能做的不仅仅是检查你的查询!侵入查询 ExpressionVisitor的一个神奇的特点是能够快速形成一个查询。为了理解这点,请考虑这个场景:你的任务是建立一个具有强大查询功能的订单输入系统,你必须快速完成它。你读了我的文章,决定使用BlazorWebAssembly并在客户端编写LINQ查询。你使用一个自定义的visitor来巧妙地序列化查询,并将其传递给服务器,在那里你反序列化并运行它。一切都进行得很顺利,直到安全审计。在那里,它被确定为查询引擎过于开放。一个恶意的客户端可以发出极其复杂的查询,返回大量的结果集,从而使系统瘫痪。你会怎么做? 使用visitor方法的一个好处是,你不必为了修改一个子节点而重构整个表达式树。表达式树是不可改变的,但是visitor可以返回一个全新的表达式树。你可以写好修改表达式树的逻辑,并在最后收到完整的表达式树和修改内容。为了说明这一点,让我们编写一个名为ExpressionTakeRestrainer的特殊Visitor:publicclassExpressionTakeRestrainer:ExpressionVisitor{privateintmaxTake;publicboolExpressionHasTake{get;privateset;}publicExpressionParseAndConstrainTake(Expressionexpression,intmaxTake){this。maxTakemaxTake;ExpressionHasTakefalse;returnVisit(expression);}} 特殊的ParseAndConstrainTake方法将调用Visit并返回表达式。注意,它把ExpressionHasTake用来标记表达式是否有Take。假设我们只想返回5个结果。理论上说,你可以在查询的最后加上Take:varmyQuerytheirQuery。Take(5);returnmyQuery。ToList(); 但这其中的乐趣在哪里呢?让我们来修改一个表达式树。我们将只覆盖一个方法,那就是VisitMethodCall:protectedoverrideExpressionVisitMethodCall(MethodCallExpressionnode){if(node。Method。Namenameof(Enumerable。Take)){ExpressionHasTaketrue;if(node。Arguments。Count2node。Arguments〔1〕isConstantExpressionconstant){vartakeCount(int)constant。Value;if(takeCountmaxTake){vararg1Visit(node。Arguments〔0〕);vararg2Expression。Constant(maxTake);varmethodCallExpression。Call(node。Object,node。Method,new〔〕{arg1,arg2});returnmethodCall;}}}returnbase。VisitMethodCall(node);} 该逻辑检查方法的调用是否是Enumerable。Take。如果是,它将设置ExpressionHasTake标志。第二个参数是要读取的数字,所以该值被检查并与最大值比较。如果它超过了允许的最大值,就会建立一个新的节点,把它限制在最大值范围内。这个新节点将被返回,而不是原来的节点。如果该方法不是Enumerable。Take,那么就会调用基类,一切都会像往常一样被解析。 我们可以通过运行下面代码来测试它:newExpressionConsoleWriter()。Parse(newExpressionTakeRestrainer()。ParseAndConstrainTake(query。Expression,5)); 看看下面的结果:查询已被修改为只取5条数据。OrderBy(Take(Skip(Where(System。Collections。Generic。List1〔ExpressionExplorer。Thing〕,t。Name。Contains(a,InvariantCultureIgnoreCase)AndAlsot。Created。GreaterThan。Now。AddDays(1)t),2),5),t。Created。t) 但是等等。。。有5吗!?试试运行这个:varlistquery。ToList();Console。WriteLine(34;rrQueryresults:{list。Count}); 而且,不幸的是,你将看到的是50。。。。。。原始获取的数量。问题是,我们生成了一个新的表达式,但我们没有在查询中替换它。事实上,我们不能。。。。。。这是一个只读的属性,而表达式是不可改变的。那么现在怎么办?移花接木 我们可以简单地通过实现IOrderedQueryable来制作我们自己的查询器,该接口是其他接口的集合。下面是该接口要求的细则。ElementType这是简单的被查询元素的类型。Expression查询背后的表达式。Provider这就是查询提供者,它完成应用查询的实际工作。我们不实现自己的提供者,而是使用内置的,在这种情况下是LINQtoObjects。GetEnumerator运行查询的时候会调用它,你可以随心所欲地建立、扩展和修改,但一旦调用这它,查询就被物化了。 这里是TranslatingHost的一个实现,它翻译了查询:publicclassTranslatingHostT:IOrderedQueryableT,IOrderedQueryable{privatereadonlyIQueryableTquery;publicTypeElementTypetypeof(T);privateExpressionTranslatedExpression{get;set;}publicTranslatingHost(IQueryableTquery,intmaxTake){this。queryquery;vartranslatornewExpressionTakeRestrainer();TranslatedExpressiontranslator。ParseAndConstrainTake(query。Expression,maxTake);}publicExpressionExpressionTranslatedExpression;publicIQueryProviderProviderquery。Provider;publicIEnumeratorTGetEnumerator()Provider。CreateQueryT(TranslatedExpression)。GetEnumerator();IEnumeratorIEnumerable。GetEnumerator()GetEnumerator();} 它相当简单。它接收了一个现有的查询,然后使用ExpressionTakeRestrainer来生成一个新的表达式。它使用现有的提供者(例如,如果这是一个来自DbSet的查询,在SQLServer上使用EFCore,它将翻译成一个SQL语句)。当枚举器被请求时,它不会传递原始表达式,而是传递翻译后的表达式。 让我们来使用它吧:vartransformedQuerynewTranslatingHostThing(query,5);varlist2transformedQuery。ToList();Console。WriteLine(34;rrModifiedqueryresults:{list2。Count}); 这次的结果是我们想要的。。。。。。只返回5条记录。 到目前为止,我已经介绍了检查一个现有的查询并将其换掉。这在你执行查询时是有帮助的。如果你的代码是执行query。ToList(),那么你就可以随心所欲地修改查询。但是当你的代码不负责具体化查询的时候呢?如果你暴露了一个类库,比如一个仓储类,它有下面这个接口会怎么样?publicIQueryableThingQueryThings{get;} 或在使用EFCore的情况:publicDbSetThingThings{get;set;} 当调用者调用ToList()时,你如何拦截查询?这需要一个Provider,我将在本系列的下一篇文章中详细介绍这个问题。