目录
介绍
构建一种机制来将用代码表示的业务条件转换为可由解决方案的其他层(数据库或Web服务)使用的格式,这是比较常见的,尤其是在解决方案的基础结构层。以下两种常见情况中的任何一种都是这种情况的示例:
- 假设我们想将过滤条件从C#客户端内部传递到HTTP服务。这些条件可以在查询字符串集合中发送,但是通过字符串连接手动构造查询字符串,不仅看起来不干净,而且很可能难以调试和维护。
- 有时,我们可能需要在不使用ORM工具的情况下将过滤条件转换为SQL WHERE子句。同样,通过手动字符串操作为数据库查询构造SQL WHERE子句似乎容易出错并且难以维护。
作为一种优雅的工具,“lambda表达式”提供了描述过滤条件的简洁方便的方法,但是使用这些表达式并不是很容易。幸运的是,System.Linq.Expressions命名空间中的ExpressionVisitor类是检查、修改和翻译lambda表达式的出色工具。
在本文中,我们主要使用ExpressionVisitor类为上述第一种情况提出解决方案。
背景
在深入探讨细节之前,让我们对表达式的一般概念进行非常简单的介绍,然后将条件表达式作为一种更特殊的类型,最后对ExpressionVisitor类进行非常简短的描述。这将非常简短,但绝对必要,因此,仅在事先了解这些主题的情况下,才跳过此部分。
一般而言,什么是表达式?条件表达式与它们有什么不同?
表达式通常表示委托或方法。表达式本身不是委托或方法。它表示委托或方法,即,表达式定义了委托的结构。在.NET平台中,我们使用Expression类来定义表达式。但是,在定义其委托的主体之前,必须定义将要表示的委托的签名。该签名通过名为TDelegate的泛型类型参数提供给Expression类。因此,表达式类的形式为Expression <TDelegate>。
考虑到这一点,很明显,条件表达式表示一个委托,该委托将任意类型的对象T作为输入并返回布尔值。结果,条件表达式的委托将是类型Func<T, bool>,因此Expression<Func<T, bool>>是条件表达式的类型。
ExpressionVistor如何工作
我们通常使用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方法的所有变体都是virtual的,从ExpressionVisitor继承的任何类都应覆盖必需的类并实现自己的逻辑。这就是自定义访问者的构建方式。
对于那些可能希望对我们的解决方案的工作方式有很好的了解的读者,至少需要对以下主题有最少的了解。
- 表达式树(1)和(2)
- 我们要翻译的lambda表达式背后的一般概念
- 树遍历(顺序,前序和后序)
- 用于迭代树的算法
- 访问者设计模式
- 一种用于解析表达式树的设计模式
- ExpressionVisitor类
- Microsoft .NET平台提供的类,它使用访问者设计模式来公开检查、修改和翻译表达式树的方法。我们将使用这些方法来检查树中感兴趣的每个节点,并从中提取所需的数据。
- 逆波兰式(RPN)
- 在逆波兰式中,运算符遵循其操作数;例如,将3和4相加,便会写成“ 3 4 +”而不是“ 3 + 4”。
整体情况
如下图所示,我们有一使用类型表达式Expression<Func<T, bool>>作为输入的FilterBuilder类。此类是解决方案的主要部分。在第一步,FilterBuilder检查输入表达式并输出FilterDescriptor(IEnumerable<FilterDescriptor>)的集合。在下一步中,转换器将这个FilterDescriptors集合转换为所需的形式,例如,要在HTTP请求中使用的查询字符串键值对,或要用作SQL WHERE子句的字符串。对于每种类型的转换,都需要一个单独的转换器。
这里可能会出现一个问题:为什么不将输入表达式直接转换为查询字符串?是否有必要承担产生FilterDescriptors的负担?可以跳过此额外步骤吗?答案是,如果您所需要的只是生成查询字符串,而不是更多,而且如果您不是在寻找通用解决方案,那么您可以自由地这样做。但是,通过这种方式,您最终将获得非常特定的ExpressionVisitor,仅适用于一种类型的输出。但是,本文试图做的恰恰相反:提出一个更通用的解决方案。
解决方案
基础
该解决方案的核心是继承自ExpressionVisitor的FilterBuilder类。此类的构造函数采用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的对象并将其添加到私有列表中。该列表中的令牌是根据逆波兰式(RPN)排列的。什么是令牌?令牌封装了构建FilterDescriptor所需的节点数据。令牌由继承自抽象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();
}
}
遍历表达式的所有节点并创建它们的等效标记后,FilterDescriptor就可以创建。这将通过调用Build名为的方法来完成。
如前面“ExpressionVisitor的工作原理”部分所述,表达式的每个部分都包含多个子表达式。例如,p.Price < 1000是一个由三部分组成的二进制表达式:
- p.Price (成员表达)
- < (“小于”二进制运算符)
- 1000 (恒定表达)
当访问时,此三部分二进制表达式将产生三个不同的标记:
- VisitMember方法用于p.Price的MemberToken
- TokenVisitBinary方法用于<的BinaryOperator
- VisitConstant方法用于1000的ConstantToken
调用Builder方法时,它首先创建一个Stack<FilterDescriptor>对象。然后遍历令牌列表,并基于循环中当前令牌的类型,将描述符推入和弹出堆栈。这样,将不同的令牌(如上例中的三个令牌)组合在一起以构建单个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)。在这种情况下,用单个等效运算符替换此组合将使事情变得更简单。例如,可以使用<=(小于或等于)运算符代替!(not)和>(大于)运算符的组合。字符串比较运算符也是如此。否定运算符和字符串比较运算符的任何组合都将被定义为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属性是因为简单,而且因为它可以独立于平台。
由于这两个原因,我们的表达式访问者用其Ticks替换了DateTime结构的实例。这意味着在运行表达式访问者代码时,我们必须获取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和所有令牌类的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 集合的新类在表达式中支持括号。这样的功能需要更多的时间和精力,以后可能会涉及到。但是,我希望读者能够掌握这里介绍的核心概念,并在此基础上开发更好的解决方案。
本文所附的ZIP文件中提供了该解决方案的完整源代码。