将 C# Lambda 表达式转换为通用过滤器描述符和 HTTP 查询字符串






4.98/5 (25投票s)
关于如何使用 .NET ExpressionVisitor 将 lambda 表达式转换为适合筛选数据和创建查询字符串的封装数据类的想法
引言
在解决方案的基础架构层中,为将代码中表达的业务条件转换为解决方案其他层(无论是数据库还是 Web 服务)可用的格式,构建一种机制是相当普遍的。以下两种常见场景是这种情况的一个例子:
- 假设我们想将筛选条件从 C# 客户端内部传递到 HTTP 服务。这些条件可以包含在查询字符串集合中,但手动通过字符串连接构建查询字符串不仅看起来不美观、不简洁,而且极有可能难以调试和维护。
- 有时我们可能希望将筛选条件转换为 SQL
WHERE
子句,而无需使用 ORM 工具。同样,通过手动字符串操作来构建数据库查询的 SQLWHERE
子句,看起来容易出错且难以维护。
作为一种优雅的工具,“lambda 表达式”提供了一种简洁方便的方式来描述筛选条件,但处理这些表达式并非易事。幸运的是,ExpressionVisitor
类在 System.Linq.Expressions
命名空间中是一个检查、修改和转换 lambda 表达式的出色工具。
在本文中,我们将主要使用 ExpressionVisitor
类来解决上述第一个场景。
背景
在深入细节之前,让我们对表达式的通用概念,然后是更特殊的类型条件表达式,最后对 ExpressionVisitor
类进行非常简短的介绍。它会非常简短但绝对必要,因此只有在您事先了解这些主题的情况下才请跳过此部分。
表达式的通用概念是什么?条件表达式又有什么不同?
表达式通常代表一个委托或方法。表达式本身不是委托或方法。它代表一个委托或方法,也就是说,表达式定义了委托的结构。在 .NET 平台中,我们使用 Expression
类来定义表达式。但是,在定义其委托的体之前,有必要定义它将要表示的委托的签名。该签名通过一个名为 TDelegate
的泛型类型参数传递给 Expression
类。因此,表达式类的形式是 Expression<TDelegate>
。
考虑到这一点,很明显,条件表达式代表一个委托,该委托接受任意类型 T
的对象作为输入并返回一个布尔值。因此,条件表达式的委托将是 Func<T, bool>
类型,从而条件表达式的类型是 Expression<Func<T, bool>>
。
ExpressionVisitor 的工作原理
我们通常使用 lambda 表达式来定义表达式。Lambda 表达式由多个不同的表达式组合而成。考虑这个示例 lambda:
p => p.Price < 1000 && p.Name.StartsWith("a-string") && !p.OutOfStock
下图标记了它的不同部分:
正如您所见,这个表达式是一些其他表达式和运算符的组合。
现在让我们看看 ExpressionVisitor
如何处理像上面这样的表达式。此类实现了访问者模式。它的主要方法,或入口点,称为 Visit
,是一个调度程序,它调用多个其他专用方法。当一个表达式被传递到 Visit
方法时,表达式树会被遍历,并且根据每个节点的类型,会调用一个专门的方法来访问(检查和修改)该节点及其子节点(如果有)。在每个方法内部,如果表达式被修改,将被返回修改后的副本;否则,返回原始表达式。请牢记,表达式是不可变的,任何修改都会导致创建一个新的实例并返回。
在 Microsoft 的 .NET framework 4.8 的在线文档中,记录了 35 个特殊的访问方法。这里列出了一些在我们的解决方案中使用的有趣的方法:
VisitConstant
访问ConstantExpression
。VisitMember
访问MemberExpression
的子节点。VisitBinary
访问BinaryExpression
的子节点。VisitUnary
访问UnaryExpression
的子节点。VisitMethodCall
访问MethodCallExpression
的子节点。VisitNew
访问NewExpression
的子节点。
所有这些 35 种 visit
方法的变体都是虚拟的,任何继承自 ExpressionVisitor
的类都应该重写必要的那些并实现自己的逻辑。这就是自定义访问者是如何构建的。
对于那些希望充分理解我们的解决方案如何工作的读者,至少需要熟悉以下主题:
- 表达式树 (1) & (2)
- 我们想要转换的 lambda 表达式背后的通用概念
- 树遍历(中序、前序和后序)
- 用于迭代树的算法
- 访问者设计模式
- 用于解析表达式树的设计模式
- ExpressionVisitor 类
- Microsoft .NET 平台提供的一个类,它使用访问者设计模式来公开用于检查、修改和转换表达式树的方法。我们将使用这些方法来检查树中感兴趣的每个节点并从中提取所需数据。
- 逆波兰表示法 (RPN)
- 在逆波兰表示法中,运算符后跟它们的运算对象;例如,要将 3 和 4 相加,应写成“3 4 +”,而不是“3 + 4”。
全局概览
如下图所示,我们有一个 FilterBuilder
类,它接受类型为 Expression<Func<T, bool>>
的表达式作为输入。此类是解决方案的核心部分。在第一步,FilterBuilder
检查输入表达式并输出一个 FilterDescriptor
s(IEnumerable<FilterDescriptor>
)集合。在下一步,一个转换器将此 FilterDescriptor
s 集合转换为所需的格式,例如,用于 HTTP 请求的查询字符串键值对,或用作 SQL WHERE 子句的字符串。对于每种类型的转换,都需要一个单独的转换器。
这里可能会出现一个问题:为什么我们不直接将输入表达式转换为查询字符串?有必要承担生成 FilterDescriptor
s 的负担吗?可以跳过这个额外的步骤吗?答案是,如果您只需要生成查询字符串而不再需要其他内容,并且您不是在寻找一个通用的解决方案,那么您可以这样做。但是,这样,您最终会得到一个非常特定的 ExpressionVisitor
,它只能用于一种输出类型。为了这个目标,这里有一篇很好的文章写在这里。然而,本文试图做的恰恰相反:提出一个更通用的解决方案。
解决方案
基础
我们的解决方案的核心是 FilterBuilder
类,它继承自 ExpressionVisitor
。该类的构造函数接受类型为 Expresion<Func<T, bool>>
的表达式。该类有一个名为 Build
的 public
方法,该方法返回一个 FilterDescriptor
对象集合。FiterDescriptor
定义如下:
public class FilterDescriptor
{
public FilterDescriptor()
{
CompositionOperator = FilterOperator.And;
}
private FilterOperator _compositionOperator;
public FilterOperator CompositionOperator
{
get => _compositionOperator;
set
{
if (value != FilterOperator.And && value != FilterOperator.Or)
throw new ArgumentOutOfRangeException();
_compositionOperator = value;
}
}
public string FieldName { get; set; }
public object Value { get; set; }
public FilterOperator Operator { get; set; }
// For demo purposes
public override string ToString()
{
return
$"{CompositionOperator} {FieldName ?? "FieldName"} {Operator} {Value ?? "Value"}";
}
}
此类 FilterOperator
属性的类型是一个枚举。此属性指定筛选器的运算符。
public enum FilterOperator
{
NOT_SET,
// Logical
And,
Or,
Not,
// Comparison
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
// String
StartsWith,
Contains,
EndsWith,
NotStartsWith,
NotContains,
NotEndsWith
}
表达式节点不直接转换为 FilterDescriptor
对象。相反,每个重写的方法,在访问表达式节点时,会创建一个名为token的对象并将其添加到私有列表中。列表中的 tokens 根据 逆波兰表示法 (RPN) 排列。什么是 token?Token 封装了构建 FilterDescriptor
所需的节点数据。Tokens 由继承自抽象 Token
类的类定义。
public abstract class Token {}
public class BinaryOperatorToken : Token
{
public FilterOperator Operator { get; set; }
public BinaryOperatorToken(FilterOperator op)
{
Operator = op;
}
public override string ToString()
{
return "Binary operator token:\t" + Operator.ToString();
}
}
public class ConstantToken : Token
{
public object Value { get; set; }
public ConstantToken(object value)
{
Value = value;
}
public override string ToString()
{
return "Constant token:\t\t" + Value.ToString();
}
}
public class MemberToken : Token
{
public Type Type { get; set; }
public string MemberName { get; set; }
public MemberToken(string memberName, Type type)
{
MemberName = memberName;
Type = type;
}
public override string ToString()
{
return "Member token:\t\t" + MemberName;
}
}
public class MethodCallToken : Token
{
public string MethodName { get; set; }
public MethodCallToken(string methodName)
{
MethodName = methodName;
}
public override string ToString()
{
return "Method call token:\t" + MethodName;
}
}
public class ParameterToken : Token
{
public string ParameterName { get; set; }
public Type Type { get; set; }
public ParameterToken(string name, Type type)
{
ParameterName = name;
Type = type;
}
public override string ToString()
{
return "Parameter token:\t\t" + ParameterName;
}
}
public class UnaryOperatorToken : Token
{
public FilterOperator Operator { get; set; }
public UnaryOperatorToken(FilterOperator op)
{
Operator = op;
}
public override string ToString()
{
return "Unary operator token:\t\t" + Operator.ToString();
}
}
在遍历完表达式的所有节点并创建其等效 tokens 后,可以创建 FilterDescriptor
s。这将通过调用名为 Build
的方法来完成。
如前所述,“ExpressionVisitor 的工作原理”部分,表达式的每个部分都包含多个子表达式。例如,p.Price < 1000
是一个二元表达式,它由三个部分组成:
p.Price
(成员表达式)<
(“小于”二元运算符)1000
(常量表达式)
当访问此 3 部分二元表达式时,它将生成三个不同的 tokens:
- 由
VisitMember
方法为p.Price
生成的MemberToken
。 - 由
VisitBinary
方法为<
生成的BinaryOperatorToken
。 - 由
VisitConstant
方法为1000
生成的ConstantToken
。
当调用 Builder
方法时,它首先创建一个 Stack<FilterDescriptor>
对象。然后遍历 tokens 列表,根据循环中当前 token 的类型,将描述符推送到堆栈或从堆栈弹出。通过这种方式,上面示例中的三个不同 tokens 被组合在一起以构建单个 FilterDescriptor
。
public IEnumerable<FilterDescriptor> Build()
{
var filters = new Stack<FilterDescriptor>();
for (var i = 0; i < _tokens.Count; i++)
{
var token = _tokens[i];
switch (token)
{
case ParameterToken p:
var f = getFilter();
f.FieldName = p.ParameterName;
filters.Push(f);
break;
case BinaryOperatorToken b:
var f1 = getFilter();
switch (b.Operator)
{
case FilterOperator.And:
case FilterOperator.Or:
var ff = filters.Pop();
ff.CompositionOperator = b.Operator;
filters.Push(ff);
break;
case FilterOperator.Equal:
case FilterOperator.NotEqual:
case FilterOperator.LessThan:
case FilterOperator.LessThanOrEqual:
case FilterOperator.GreaterThan:
case FilterOperator.GreaterThanOrEqual:
f1.Operator = b.Operator;
filters.Push(f1);
break;
}
break;
case ConstantToken c:
var f2 = getFilter();
f2.Value = c.Value;
filters.Push(f2);
break;
case MemberToken m:
var f3 = getFilter();
f3.FieldName = m.MemberName;
filters.Push(f3);
break;
case UnaryOperatorToken u:
var f4 = getFilter();
f4.Operator = u.Operator;
f4.Value = true;
filters.Push(f4);
break;
case MethodCallToken mc:
var f5 = getFilter();
f5.Operator = _methodCallMap[mc.MethodName];
filters.Push(f5);
break;
}
}
var output = new Stack<FilterDescriptor>();
while (filters.Any())
{
output.Push(filters.Pop());
}
return output;
FilterDescriptor getFilter()
{
if (filters.Any())
{
var f = filters.First();
var incomplete = f.Operator == default ||
f.CompositionOperator == default ||
f.FieldName == default ||
f.Value == default;
if (incomplete)
return filters.Pop();
return new FilterDescriptor();
}
return new FilterDescriptor();
}
}
当 Build
方法返回时,所有描述符都已准备好转换为所需的任何格式。
必要的表达式修改
此处对原始表达式进行了三处修改,这在简化事物方面非常有帮助。这三项更改是我自己为了使代码更简洁、更实用而提出的解决方案。它们在理论上不是必需的,并且您可以进一步开发此示例以其他方式解决问题,并保持原始表达式不变。
修改布尔 MemberAccess 表达式
每个条件都包含三项:一个参数,它的值以及一个将参数与该值关联的运算符。现在考虑这个表达式:p.OutOfStock
,其中 OutOfStock
是对象 p
的一个布尔属性。乍一看,它缺少两部分:运算符和布尔值;但实际上,它是这个表达式的简写形式:p.OutOfStock == true
。另一方面,本文中的算法期望这三个部分都存在才能按预期工作。据我所知,如果没有显式声明运算符和布尔值,直接使用这种形式的表达式会增加解决方案的复杂性。为此,我们分两次访问表达式。对于第一次访问,使用一个名为 BooleanVisitor
的独立类,它也继承自 ExpressionVisitor
。它只重写 VisitMember
方法。该类私有嵌套在 FilterBuilder
中。
private class BooleanVisitor : ExpressionVisitor
{
protected override Expression VisitMember(MemberExpression node)
{
if (node.Type == typeof(bool))
{
return Expression.MakeBinary
(ExpressionType.Equal, node, Expression.Constant(true));
}
return base.VisitMember(node);
}
}
此重写方法向布尔成员访问表达式添加了两个缺失的部分,并返回修改后的副本。第二次访问需要在之后进行。这在 FilterBuilder
的构造函数中完成。
// ctor of the FilterBuilder
public FilterBuilder(Expression expression)
{
var fixer = new BooleanVisitor();
var fixedExpression = fixer.Visit(expression);
base.Visit(fixedExpression);
}
修改被否定的比较运算符
有时,变量与条件中的值之间的关系包含一个比较运算符和一个否定运算符。例如 !(p.Price > 30000)
。在这种情况下,用单个等效运算符替换此组合会使事情变得更简单。例如,用 <=
(小于等于)运算符代替“!”(非)和“>”(大于)运算符的组合。这对字符串比较运算符也同样适用。任何否定运算符和字符串比较运算符的组合都将被替换为 FilterOperator
枚举中定义的单个等效运算符。
修改 DateTime 值
在这里需要注意两点。第一,DateTime
值在访问表达式树时需要特别注意,因为 DateTime
值在表达式中可能以多种形式出现。本解决方案涵盖的其中一些形式是:
- 一个简单的
MemberAccess
表达式:DateTime.Now
或DateTime.Date
- 嵌套的
MemberAccess
表达式:DateTime.Now.Date
- 一个
NewExpression
:new DateTime(1989, 3, 25)
- 一个
NewExpression
后跟一个MemberAccess
表达式:new DateTime(1989, 3, 25).Date
当 DateTime
值作为 MemberAccess
表达式出现时,应在 VisitMember
方法中处理。当它作为 NewExpression
出现时,应在 VisitNew
方法中处理。
第二,可以将 DateTime
值以多种形式传输。例如,它可以转换为 string
并任意格式化;或者它可以转换为长整数(Ticks)并作为数字发送。选择特定数据类型和格式是业务需求或技术限制的问题。无论如何,在此,出于简洁性以及跨平台独立性的考虑,选择了 DateTime
结构 的 Ticks 属性。
出于这两个原因,我们的表达式访问者会将 DateTime
结构 的实例替换为其 Ticks
等效项。这意味着我们必须在表达式访问者代码运行时获取 DateTime
值的 Ticks 属性 的值。因此,包含 DateTime
值的表达式应编译为方法并按如下代码运行:
protected override Expression VisitMember(MemberExpression node)
{
if (node.Type == typeof(DateTime))
{
if (node.Expression == null) // Simple MemberAccess like DateTime.Now
{
var lambda = Expression.Lambda<Func<DateTime>>(node);
var dateTime = lambda.Compile()();
base.Visit(Expression.Constant(dateTime.Ticks));
return node;
}
else
{
switch (node.Expression.NodeType)
{
case ExpressionType.New:
var lambda = Expression.Lambda<Func<DateTime>>(node.Expression);
var dateTime = lambda.Compile()();
base.Visit(Expression.Constant(dateTime.Ticks));
return node;
case ExpressionType.MemberAccess: // Nested MemberAccess
if (node.Member.Name != ((MemberExpression)node.Expression).Member.Name)
{
var lambda2 = Expression.Lambda<Func<DateTime>>(node);
var dateTime2 = lambda2.Compile()();
base.Visit(Expression.Constant(dateTime2.Ticks));
return node;
}
break;
}
}
}
_tokens.Add(new MemberToken(node.Expression + "." + node.Member.Name, node.Type));
return node;
}
protected override Expression VisitNew(NewExpression node)
{
if (node.Type == typeof(DateTime))
{
var lambda = Expression.Lambda<Func<DateTime>>(node);
var dateTime = lambda.Compile()();
base.Visit(Expression.Constant(dateTime.Ticks));
return node;
}
return base.VisitNew(node);
}
转换 FilterDescriptors
如前所述,当 Build
方法返回时,一个 FilterDescriptor
s 集合已准备好被馈送到任何类或方法,以转换为任何所需的格式。在查询字符串的情况下,此方法可以是一个扩展方法,也可以是一个独立的类,具体取决于程序员的偏好。请注意,每个服务器程序都将期望一组预定义的键值对。例如,假设有一个服务器将查找过滤器在单独的类似数组的键值对中的不同参数。以下扩展方法可以完成此任务。
public static class FilterBuilderExtensions
{
public static string GetQueryString(this IList<FilterDescriptor> filters)
{
var sb = new StringBuilder();
for (var i = 0; i < filters.Count; i++)
{
sb.Append(
$"filterField[{i}]={filters[i].FieldName}&" +
$"filterOp[{i}]={filters[i].Operator}&" +
$"filterVal[{i}]={filters[i].Value}&" +
$"filterComp[{i}]={filters[i].CompositionOperator}");
if (i < filters.Count - 1)
sb.Append("&");
}
return sb.ToString();
}
}
示例用法
这个简单的控制台程序演示了如何使用 FilterBuilder
。
FilterDescriptor
和所有 token 类的 ToString
方法都被重写,以便在控制台中检查它们的属性。
class Program
{
static void Main(string[] args)
{
Expression<Func<Product, bool>> exp = p =>
p.Id == 1009 &&
!p.OutOfStock &&
!(p.Price > 30000) &&
!p.Name.Contains("BMW") &&
p.ProductionDate > new DateTime(1999, 6, 20).Date;
var visitor = new FilterBuilder(exp);
var filters = visitor.Build().ToList();
Console.WriteLine("Tokens");
Console.WriteLine("------\n");
foreach (var t in visitor.Tokens)
{
Console.WriteLine(t);
}
Console.WriteLine("\nFilter Descriptors");
Console.WriteLine("------------------\n");
foreach (var f in filters)
{
Console.WriteLine(f);
}
Console.WriteLine($"\nQuery string");
Console.WriteLine("------------\n");
Console.WriteLine(filters.GetQueryString());
Console.ReadLine();
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public DateTime ProductionDate { get; set; }
public bool OutOfStock { get; set; } = false;
}
输出
省略的功能
肯定有很多潜在的改进可以使此解决方案更加健壮,但出于简洁性的考虑,它们在本文中被故意省略了。一项必要的功能是支持表达式中的括号,通过一个包装 FilterDescriptor
s 集合的新类来实现。此类功能需要更多的时间和精力,可能会在稍后进行涵盖。然而,我希望读者能够掌握此处介绍的核心概念,并在这项工作的基础上开发出更好的解决方案。
该解决方案的完整源代码可在本文附带的 ZIP 文件中找到。
历史
- 2020年3月16日:初始版本