表达式树

在日常的开发中,我们一般都直接编写_context.Books.Where(b=>b.Price>20)这样的代码进行数据的查询。

如果需要动态构造查询条件,我们也可以采用分步构造IQueryable的方式实现。

但是在编写框架等需要更灵活地创建查询条件的场景下,我们就需要使用动态构建表达式树的技术。

1. 什么是表达式树

表达式树(Expression Tree)是树形数据结构来表示代码逻辑运算的技术,它让我们可以在运行时访问逻辑运算的结构。

表达式树在.NET中对应Expression<TDelegate>类型。

我们既可以让C#编译器帮我们从Lambda表达式生成表达式树,也可以在运行时通过API动态创建表达式树。

我们先来看看如何从Lambda表达式生成表达式树,代码如下:

C#
Expression<Func<Book,bool>> e1 = b=>b.Price > 5

编译器会把b=>b.Price>5这个表达式构建成Expression对象(表达式树对象),然后我们就可以使用这个表达式树对象进行数据查询了。

当然,如果只是硬编码b.Price>5这个查询逻辑,我们一般不需要显式地编写上面的代码,一般编写如下代码:_context.Books.Where(b=>b.Price>5)。

我们把鼠标指针放到Where方法上悬停,Visual Studio就会给出以下的提示:

可以看到,编译器自动把我们编写的Lambda表达式编译为Expression类型,而不是编译为普通的委托类型。

2. Expression和Func的区别

熟悉委托的人可能会疑惑,Expression<Func<Book,bool>>看起来和委托没区别。

我们可以把Expression<Func<Book,bool>>换成Func<Book,bool>,如以下所示:

C#
 Func<Book, bool> e = n => n.Price > 5;

 _context.Books.Where(e).ToList();

上面的代码可以正常编译、运行,但是上面代码生成的SQL语句如下:

SQL
 SELECT t.Id, t.AuthorName, t.IsDelete, t.Price, t.PubTime, t.Title
 FROM T_Books AS t
 WHERE t.IsDelete

我们发现查询生成的SQL语句是没有b.Price > 5 这个逻辑的,说明这个版本是通过客户端评估完成的。

因此,为了能够正常地使用EF Core,我们一定要使用Expression<TDelegate>类型。

那么,Expression和Func有什么区别呢?

Expression对象存储了运算逻辑,它把运算逻辑保存成AST(abstract syntax tree,抽象语法树),我们可以在运行时动态分析运算逻辑。

我们编写代码分别输出表达同样逻辑的Expression对象和Func对象,如以下代码所示:

C#
 Func<Book, bool> func = b => b.Price > 5 || b.AuthorName!.Contains("ICHI");

 Expression<Func<Book,bool>> expression = b=>b.Price > 5||b.AuthorName.Contains("ICHI");

 await Console.Out.WriteLineAsync(func.ToString());

 await Console.Out.WriteLineAsync(expression.ToString());

程序运行结果:

我们可以看到,Func输出结果中,只有参数、返回值类型,没有内部的运算逻辑。

而Expression的输出结果中,则有内部的运算逻辑。

这证明了Expression对象存储了运算逻辑。

Expression类似于源代码,而Func类似于编译后的二进制程序。

我们可以调用Compile方法把Expression对象编译成Func对象,但是无法正常地把Func对象转换为Expression对象。

3. 可视化查看表达式树

我们知道,Expression是一颗表示运算逻辑的抽象语法树。

我们除了可以在控制台输出Expression对象以查看它的结构之外,还可以用更结构化的方式查看表达式树。

对于上面小节的代码,我们可以在Visual Studio中调试程序,然后在【快速监视】窗口中查看变量expression的值,展开【Raw View】(原始视图),内容如图:

可以看到,整颗表达式树是一个”或”(OrElse)类型的节点,左(Left)节点是b.Price>5表达式,右(Right)节点是b.AuthorName.Contains(“ICHI”)。

而b.Price>5这个表达式又是一个”大于”(GreaterThan)类型的节点,左节点是b.Price,右节点是5。

熟悉编译原理的朋友会发现,这就是b.Price>5||b.AuthorName.Contains(“ICHI”)这个表达式生成的抽象语法树。

Visual Studio内置了一个名为DebugView的功能支持可视化地查看表达式树,但是生成的结构对我们的开发帮助并不大。

我们可以借助开源的调试查看器来查看表达式树。

这里推荐使用Expression Tree Visualizer,它可以直接在Visual Studio中查看Expression变量。

4. 通过代码查看表达式树

上面小节中,我们提到的Expression Tree Visualizer提供了在调试时查看表达式树的方式。

不过这个插件的安装比较麻烦,而且插件运行的时候要借用项目的CLR,如果插件的CLR和项目的CLR不一致,运行会失败。

因此,推荐通过代码输出表达式树。

Expression Tree Visualizer的可视化展示表达式树的内核被发布为了一个单独的NuGet包 ExpressionTreeToString。

这个NuGet包提供了把表达式树转换为可读性强的字符串格式的方法。

ToString有非常多的重载方法,这些重载方法可以控制输出的格式,生成源代码时使用的编程语言等。

下面先来演示ToString(“Object notation”,”C#”)这个用法,它用于以C#来输出每个节点的类型及节点的属性值,如以下代码所示:

C#
   Expression<Func<Book, bool>> e = b => b.AuthorName.Contains("ICHI") || b.Price > 30;

   Console.WriteLine(e.ToString("Object notation","C#"));

程序运行结果如图:

从上面的程序运行结果可以看出,Lambda表达式的参数b是一个ParameterExpression节点。

整颗表达式树的根节点是一个NodeType属性为Lambda的Expression节点。

根节点的Body为表达式树的主体,是NodeType属性为OrElse的BinaryExpression,也就是对应Lambda表达式中的”||”运算符。

OrElse节点的左节点是MethodCallExpression类型的节点,对应的是b.AuthorName.Contains(“ICHI”);

OrElse节点的右节点是BinaryExpression类型的节点,对应的是b.Price>30。

再下一级节点以此类推。

可以看到,表达式树的不同类型的节点对应不同的类型,这些类型都直接或者间接继承自Expression。

比如常量节点的类型是ConstantExpression、二元运算符节点的类型是BinaryExpression、类成员访问操作节点的类型是MemberExpression、方法调用操作节点的类型是MethodCallExpression。

5. 通过代码动态构建表达式树

目前我们编写的代码都是让C#编译器把Lambda表达式转换为表达式树,这些表达式都是硬编码的。

如果能通过代码动态构建表达式树,我们就能更好地发挥表达式树的作用。

ParameterExperssion、BinaryExpression、MethodCallExpression、ConstantExpression等类几乎都没有提供构造方法,而且所有属性也几乎都是只读的。

因此我们一般调用Expression类的Parameter、MakeBinary、Call、Constant等静态方法来生成这些类的实例,这些静态方法被称作创建表达式树的工厂方法。

有如下的表达式:

C#
Expression<Func<Book,bool>> e = b=>b.price>5

我们来编写代码生成和上面表达式转换的表达式树一样的表达式树,如下所示:

C#
   ParameterExpression paramb = Expression.Parameter(typeof(Book), "b");

   MemberExpression expLeft = Expression.MakeMemberAccess(paramb,typeof(Book).GetProperty("Price"));

   ConstantExpression exprRight = Expression.Constant(5.0, typeof(double));

   BinaryExpression exprBody = Expression.MakeBinary(ExpressionType.GreaterThan, expLeft, exprRight);

   Expression<Func<Book, bool>> expr1 = Expression.Lambda<Func<Book, bool>>(exprBody, paramb);


   _context.Books.Where(expr1).ToList();

   Console.WriteLine(expr1.ToString());

上面第一行代码中,我们使用Parameter方法创建了b这个参数节点;

第三行代码中,我们使用MackMemberAccess方法创建了访问b的Price属性操作的节点;

第五行代码中,我们使用Constant创建对应5这个常量的节点,由于这个常量需要是double类型的,因此Constant的第一个参数要写成5.0,如果写成5,运行时程序会抛出”Argument types do not match”异常;

第七行代码中,我们使用MakeBinary方法创建了对应”大于”符号的二元运算符节点,并且把exprLeft和exprRight分别设置为”大于”节点的左节点和右节点;

第九行代码中,我们使用Lambda方法把exprBody放置到一个表达式树节点中,Lambda方法主要用来设定表达式的参数和返回值类型;

第十二行diamagnetic中,我们使用动态生成的表达式树查询数据。

程序运行结果如图所示:

Expression类常见的工厂方法的说明:
工厂方法说明
BinaryExpression Add(Expression left ,Expression right)加法运算,比如a+b
BinaryExpression AndAlso(Expression left ,Expression right)逻辑与运算,比如a&&b
IndexExpression ArrayAccess(Expression array,IEnumerable<Expression> indexes)数组元素访问,比如items[5]
MethodCallExpression Call(…)方法访问,比如s.Contains(“ichi”)
ConditionalExpression Condition(Expression test,Expression ifTrue,Expression ifFalse)三元条件运算符,比如a==1?”ichi”:”itachi”
ConstantExpression Constant(Object value)常量表达式,比如666
UnaryExpression Convert(Expression expression,Type type)类型转换,比如(int)count
BinaryExpression GreaterThan(Expression left,Expression right)大于运算符,比如a>3
BinaryExpression GreaterThanOrEqual(Expression left,Expression right)小于或等于运算符,比如a>=3
BinaryExpression LessThan(Expression left ,Expression right)小于运算符,比如a<3
BinaryExpression LessThanOrEqual(Expression left ,Expression right))小于或等于运算符,比如a<=3
BinaryExpression MakeBinary(ExpressionType binaryType,Expression left ,Expression right))创建二元运算,通过binaryType参数指定运算符,比如a+5、6*a
BinaryExpression NotEqual(Expression left ,Expression right))不等于运算,比如a!=b
BinaryExpression OrElse(Expression left ,Expression right))逻辑或运算,比如a||b
ParameterExpression Parameter(Type type,string name)表达式的参数
Expression类常见的工厂方法的说明
6. 让动态构建表达式树更简单

通过上面小节我们可以看到,通过代码来动态构建表达式树要求开发人员精通表达式树的结构,甚至还需要了解CLR底层的机制。

幸运的是,我们可以用ExpressionTreeToString来帮助我们简化动态构建表达式树代码的编写.

ExpressionTreeToString提供的ToString(“Object notation”,”C#”)方法只是输出一个用C#语法描述表达式树的结构及每个节点的字符串,但是这个字符串并不是可以直接运行的C#代码。

我们可以把ToString方法的第一个参数改为”Factory methods”,也就是让程序输出类似用工厂方法生成表达式树的代码,如以下代码所示:

C#
 Expression<Func<Book, bool>> e = b => b.AuthorName.Contains("ICHI") || b.Price > 30;

  Console.WriteLine(e.ToString("Factory methods","C#"));

程序运行结构如图:

上面的程序输出的所有代码都是对于工厂方法的调用,不过调用工厂方法的时候省略了Expression类。

我们可以用C#的using static 方法来静态引入Expression类,这样上面的代码就几乎可以直接放到C#代码中编译通过了,如以下代码所示:

C#
using ExpressionTreeToString;
using static System.Linq.Expressions.Expression;

  using BookDbContext _context = new();

  var b = Parameter(typeof(Book), "b");

  var expr1 = Lambda<Func<Book, bool>>(OrElse(
      Call(MakeMemberAccess(b, typeof(Book).GetProperty("AuthorName")), typeof(string).GetMethod("Contains", new[] { typeof(string) }), Constant("ICHI")),
      GreaterThan(MakeMemberAccess(b, typeof(Book).GetProperty("Price")), Constant(30.0))),b);

  _context.Books.Where(expr1).ToList();


  Console.WriteLine(expr1.ToString("Object notation","C#"));

由于目前的ExpressionTreeToSrting版本没有考虑类型转换的问题,因此生成C#代码中构建30这个常量的表达式用的是Constant(30)。

但是这样编写,运行的时候会报错”The binary operator GreaterThan is not defined for the types ‘System.Double’ and ‘System.Int32′”,因为double类型的Price不能直接和int类型的30进行比较,所以我们把Constant(30)改成了Constant(30.0)。

除了这一点,别的代码都是我们直接照搬ToString(“Factory methods”,”C#”)返回的代码。

可以看到,使用ExpressionTreeToString,我们可以直接生成拿来就用的动态构建表达式树的代码。

虽然有时候ExpressionTreeToString生成的代码需要微调才能正常运行,但是这也让我们编写动态构建表达式树的代码变简单了很多。

7. 让构建”动态”起来

到目前为止,我们虽然通过代码动态构建出了表达式树,但是构建出来的仍然是固定逻辑的表达式树。

既然逻辑是固定的,我们为什么不直接写硬编码的Lambda表达式呢?

动态构建表达式树非常有价值的地方在于,运行时根据条件的不同生成不同的表达式树。

下面我们将编写一个方法,这个方法用来查询与指定的属性和指定值相等的数据。

方法的声明如下:

C#
static IEnumerable<Book>QueryBooks(string propName,object value)

其中propName参数为要查询的属性的名字,value为待比较的值。

如果我们调用QueryBooks(“Price”,18.0),则表示查询价格等于18元的书,而如果我们调用QueryBooks(“AuthorName”,”ICHI”),则表示查询作者名字为”ICHI”的书。

我们先硬编码一个供参考的表达式,然后用ExpressionTreeToString输出它的结构,以便于编写代码时参考。

C#
 Expression<Func<Book, bool>> expr1 = n => n.Price == 5;
 Expression<Func<Book, bool>> expr2 = n => n.Title == "ICHI";

 Console.WriteLine(expr1.ToString("Factory methods","C#"));
 Console.WriteLine(expr2.ToString("Factory methods", "C#"));

参考上面生成的工厂方法格式的结构,我们编写了如下代码:

C#
    IEnumerable<Book> QueryBooks(string propName,Object value)
    {

        Type type = typeof(Book);

        PropertyInfo? propertyInfo = type.GetProperty(propName);

        Type propType = propertyInfo.PropertyType;

        var b = Parameter(typeof(Book), "b");

        Expression<Func<Book, bool>> expr;

        //如果是int,double等基本数据类型
        if (propType.IsPrimitive)
        {
            expr = Lambda<Func<Book, bool>>(Equal(MakeMemberAccess(b,typeof(Book).GetProperty(propName)),Constant(value)),b);
        }
        else
        {
            expr = Lambda<Func<Book, bool>>(MakeBinary(ExpressionType.Equal,MakeMemberAccess(b,typeof(Book).GetProperty(propName)),Constant(value), false,typeof(string).GetMethod("op_Equality")),b);
            
        }

        BookDbContext _context = new();

        return _context.Books.Where(expr).ToArray();

    }

上面的代码首先通过反射获得propName参数值代表的属性对象,然后获得属性的类型。

我们根据属性是否为基本数据类型来调用不同的代码端生成不同的表达式树,生成的代码基本上都是照搬ExpressionTreeToString生成的工厂方法代码。

只不过我们把通用的b参数的Parameter调用抽取出来,然后把代码中硬编码的”Title”替换成了参数propName,把硬编码的5、”ICHI”替换成了参数value。

接下来我们编写测试代码:

C#
    static void Main(string[] args)
    {

        QueryBooks("Price", 18.0);

        QueryBooks("AuthorName", "ICHI");

        QueryBooks("Title", "ICHI");

    }

程序运行结果如图:

从程序运行结果可以看到,程序的行为和我们期望的一致。

ExpressionTreeToString让我们不需要是表达式树方面的专家,也能迅速写出专家级动态构建表达式树的代码。

8. 不用Emit实现Select的动态化

我们知道,可以在Select中使用匿名类来获取部分列,这样可以提升数据查询的效率,降低内存占用。

在日常开发的时候,我们可以编写Select(b=>new{b.Id,b.Name})这样的代码来让编译器帮我们生成一个包含Id、Name两个属性的匿名类。

但是,如果想在运行时动态设定Select查询出来的属性,就需要使用Emit技术在运行时动态地创建一个类,这个难度是非常大的。

这里介绍一种在运行时动态设定Select查询出来的属性的更简单方法。

其实,我们不仅可以在Select参数中传递一个类对象,也可以传递一个数组,然后把要查询的多个属性的值作为数组的元素。

比如下面的代码就是与Select(b=>new{b.Id,b.Name})等效的代码:Select(b=>new object[]{b.Id,b.Title})。

当然,查询结果中每一行的记录都是数组类型的,数组的每0个元素为Id属性的值,第1个元素为Title属性的值。

有了这个知识,我们就可以把列对应的属性的访问表达式放到一个Expression数组中。

使用Expression.NewArrayInit构建一个代表数组的NewArrayExpression对象,然后我们就可以用这个NewArrayExpression对象来供Select调用执行了,如以下代码所示。

C#
   static IEnumerable<object[]> Query<TEntitys>(string[] propNames)where TEntitys : class
   {

       ParameterExpression exParameter = Expression.Parameter(typeof(TEntitys));

       List<Expression> exProps = [];

       foreach (string proName in propNames)
       {
           
           Expression exProp = 
               Expression.Convert(Expression.MakeMemberAccess(exParameter, typeof(TEntitys).GetProperty(proName)), typeof(object));

           exProps.Add(exProp);

       }

       Expression[] init = exProps.ToArray();

       NewArrayExpression newArrayExp = Expression.NewArrayInit(typeof(object),init);

       var selectExpression = Expression.Lambda<Func<TEntitys, object[]>>(newArrayExp, exParameter);

       using BookDbContext _context = new();

       IQueryable<object[]> selectQueryable = _context.Set<TEntitys>().Select(selectExpression);

       return selectQueryable.ToArray();

   }

Query方法返回的是IEnumerable<object[]>类型,其中每一个元素对应查询出来的一条记录。

如以下代码所示,调用Query方法来查询Id、PubTime、Titel这三个属性的值。

C#
 var items = Query<Book>(["Id", "PubTime", "Title"]);

 foreach (object[] item in items)
 {

     long id = (long)item[0];

     DateTime pubTime = (DateTime)item[1];

     string Title = (string)item[2];

     Console.WriteLine($"{id},{pubTime},{Title}");

 }
9. 避免动态构建表达式树

动态构建表达式树的代码异常复杂,这些代码可读性差、可维护性差。

因此在进行项目开发的时候,如果我们能用分布构建IQueryable等方式的话,就要尽量避免动态构建表达式树。

比如我们想要编写如下的方法:Book[]QueryBooks(string title,double? lowerPrice, double? upperPrice, int orderByType)。

这个方法用于根据指定的参数条件进行数据的查询,方法的参数说明如下:

如果title不为空,则进行标题的匹配;

如果lowerPrice不为空,则还要匹配价格不小于lowerPrice的书;

如果upperPrice不为空,则还要匹配价格不大于upperPrice的书;

orderByType为排序规则,取值为1代表按照价格降序排列,取值为2代表按照价格升序排列。

可以通过动态构建表达式树来实现这个方法,但是编写的代码会非常复杂。

我们可以用分布构建IQueryable的方式来实现,如以下代码所示:

C#
     static Book[] QueryBooks(string title,double lowerPrice,double upperPrice,int orderByType)
     {
         
         using BookDbContext _context = new();

         IQueryable<Book> source = _context.Books;

         if (!string.IsNullOrEmpty(title)) source = source.Where(b => b.Title.Contains(title));

         if(lowerPrice!=null) source = source.Where(b=>b.Price>= lowerPrice);

         if(upperPrice!=null) source = source.Where(b=>b.Price<= upperPrice);

         if(orderByType == 1)
         {
             source = source.OrderByDescending(b => b.Price);
         }
         else if(orderByType ==2)
         {
             source = source.OrderBy(b => b.Price);
         }

         return source.ToArray();

     }

一般只有在编写不特定于某个实体类的通用框架的时候,由于无法在编译期确定要操作的类名、属性等,才需要编写动态构建表达式树的代码,否则为了提高代码的可读性和可维护性,我们要尽量避免动态构建表达式树。

或者大家也可以了解一下System.Linq.Dynamic.Core这个开源项目,它允许开发人员使用字符串格式的语法来进行数据操作。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部