ASP.NET 运算式树(Expression tree)的原理及使用

Posted soraxtube.com

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了ASP.NET 运算式树(Expression tree)的原理及使用相关的知识,希望对你有一定的参考价值。

前言

运算式树(Expression tree)是二叉树数据结构。
目的是实现方便的叠加各种查询条件,无限制的拼接成一个查询条件。提高复杂查询逻辑的编码效率。

一、Lambda表达式

Lambda表达式分为运算式Lambda和语句式Lambda
下面用两种lambda实现同样功能的委托。

(1)运算式Lambda(Expression lambda)

也翻译成陈述式lambda、表达式 lambda。

Func<int, int> 运算式Lambda = 
(t => t + 100);

int number = 运算式Lambda(6);
//number = 106

(2)语句式Lambda(Statement lambda)

也翻译成语句 lambda。

Func<int, int> 语句式Lambda = 
t =>
{
    return t + 100;
};

int number = 语句式Lambda(6);
//number = 106

运算式(Expression lambda)的主体为运算表达式,语句式(Statement lambda)的主体为语句块(特征是有大括号)。

运算式Lambda(Expression lambda)是可以被包装成运算式树(Expression tree)的。

二、运算式树(Expression tree)

运算式树可以理解为运算式组成的二叉树

Expression<Func<int, int>> lambdaExpression = (t => t + 100);

对应的二叉树为:

通过IDE快速监视,关注这个表达式树的几个主要属性

属性名称 含义
Body 整个树的表达式(展开后是根节点的属性)
NodeType 当前结点类型
Parameters 入参集合
ReturnType 返回值类型

(1)运算式树的结点类型(NodeType)

运算式树常见的结点类型:

结点类型 含义
Parameter 变量结点
Constant 常量结点
Add、Subtract 加法、减法等四则运算结点
And、Or 与、或等逻辑运算节点
Call 调用函数的节点

更多的结点类型:

https://docs.microsoft.com/zh-cn/dotnet/api/system.linq.expressions.expressiontype?view=net-5.0

(2)分析表达式树对象

结合对应二叉树的图看:

根节点(Body)


NodeType:当前节点的类型为Add(加运算)
Type:数据类型(Int32)
Left:当前节点的左子树(展开后是左子树根节点属性)
Right:当前节点的右子树(展开后是右子树根节点属性)

节点的左子树(Left)

可以看到左子结节点的属性:

NodeType:当前节点的类型为“Parameter”(变量结点)。
Name:变量名称为"t"。
Type:数据类型为"Int32"。
该节点没有Left、Right,说明它是二叉树的叶子节点。

节点的右子树(Right)

可以看到右子结点的属性:

NodeType:当前节点的类型为“Constant”(常量结点)
Value:值为100。
数据类型为"Int32"。

安装ExpressionTreeVisualizer插件后看的更直观

https://github.com/zspitz/ExpressionTreeVisualizer/releases

三、自己拼装表达式树

(1)使用叶节点拼装(四则运算)

还以 t + 100 这个简单的加法运算举例,创建表达式树的代码如下:

//创建 t + 100 的表达式树
//创建变量节点t
ParameterExpression parax = Expression.Parameter(typeof(int), "t");
//创建常量节点100
ConstantExpression consty = Expression.Constant(100, typeof(int));
//创建lambda表达式树
LambdaExpression lambdaExp = Expression.Lambda(
  Expression.Add(
    parax,
    consty
  ),
  new List<ParameterExpression>() { parax }
);
//将表达式树编译成委托再执行
var lambdaExpValue = lambdaExp.Compile().DynamicInvoke(1);
//lambdaExpValue = 101;

(2)使用叶节点拼装(逻辑运算)

实际应用中没有场景去用到运算表达式,都是拼装逻辑运算的表达式树,作为参数传给Where()方法。
创建一个学生IQueryable做模拟数据源

//学生类,属性有年龄和姓名
Stu stu1 = new Stu()
{
    Age = 10,
    Name = "曹操"
};
Stu stu2 = new Stu()
{
    Age = 20,
    Name = "刘备"
};
Stu stu3 = new Stu()
{
    Age = 20,
    Name = "孙策"
};
//学生IQueryable
IQueryable<Stu> StuQ= new List<Stu> { stu1, stu2, stu3 }.AsQueryable();

分别查询两个结果集。

List<Stu> StuListR1 = StuQ.Where(t => t.Age == 20).ToList();
List<Stu> StuListR2 = StuQ.Where(t => t.Name.Contains("孙")).ToList();

可以看到Where()扩展方法,参数类型是Expression<Func<Stu, bool>>

进一步分别将表达式树提取出来,获得两个Expression<Func<Stu, bool>>作为参数传递给Where()方法。
lambda1 和 lambda2 如下

Expression<Func<Stu, bool>> lambda1 = (t => t.Age == 20);
Expression<Func<Stu, bool>> lambda2 = (t => t.Name.Contains("孙"));

List<Stu> StuListR1 = StuQ.Where(lambda1).ToList();
List<Stu> StuListR2 = StuQ.Where(lambda2).ToList();

如果我们要获得一个年龄为10岁并且姓名包含孙的查询结果。表达式树lambda3如下。
出现了很多新的节点类型,按照树形图捋一下

Expression<Func<Stu, bool>> lambda3 = (t => t.Age == 20 && t.Name.Contains("孙"));


(3)使用表达式树拼装

我们已经有了lambda1和lambda2,
接下来尝试,将它们拼装成同时满足两个条件的lambda3,就会遇到一个坑

            Expression<Func<Stu, bool>> lambda1 = (t => t.Age == 20);
            Expression<Func<Stu, bool>> lambda2 = (t => t.Name.Contains("孙"));
            Expression<Func<Stu, bool>> lambda3 = (Expression<Func<Stu, bool>>)Expression.Lambda(
                Expression.And(
                    lambda1.Body,
                    lambda2.Body
                ),
                new List<ParameterExpression>() {
                    Expression.Parameter(typeof(Stu))
                }
            );
            //这句话会报错
            List<Stu> StuListR4 = StuQ.Where(lambda3).ToList();

这样拼接,将报错“变量t未定义”。
拼接Lambda的坑就是:lambda1和lambda2拼接后,这两表达式的变量即使同名也不会自动关联上。
编译器认为lambda1的变量t和lambda2的变量t其实是两个不相关的参数,最终生成的表达式应该是有两个参数。
(其实这里给的参数是lambda3的变量,和lambda1、lambda2的变量t都没关联上。)
正确的表达式树是:

            Expression<Func<Stu, Stu, bool>> lambda3 = (Expression<Func<Stu, Stu, bool>>)Expression.Lambda(
                Expression.And(
                    lambda1.Body,
                    lambda2.Body
                ),
                new List<ParameterExpression>() {
                    lambda1.Parameters[0],
                    lambda2.Parameters[1]
                }
            );

其类型是“Expression<Func<Stu, Stu, bool>>”(两个Stu),
和Where()函数需要的入参类型“Expression<Func<Stu, bool>>”(一个Stu)对不上。

我们希望获得Expression<Func<Stu, bool>>类型的lambda3,才成传递给Where()。

为了填上这个坑,需要进行节点替换操作。
让最终的表达树用同一个参数。(将lambda1和lambda2中的参数节点,都替换成我们赋给lambda3的参数节点)。

参考文档:

https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/expression-trees/how-to-modify-expression-trees

https://stackoverflow.com/questions/30556911/variable-of-type-referenced-from-scope-but-it-is-not-defined

四、封装节点替换与拼装表达式树的函数

作用:将两个表达式树合并成一个树,并替换所有参数节点为同一个参数。
输入:两个bool返回值的表达式树。
输出:拼接后的树。根节点类型是Add,返回值类型为bool。

/*-------------------------------------------------------------------------
 *      ___
     />    フ
     |   _  _|
     /`  ミ_xノ
     /  -WuTian-|
    /  ヽ    ノ
    │  | |  |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
 * 版本号:v1.0
 *  -------------------------------------------------------------------------*/

    public static class ExpressionExtension
    {
        /// <summary>
        /// Expression的泛型扩展(拼接表达式并替换参数)
        /// </summary>
        /// <typeparam name="TSource">泛型Expression</typeparam>
        /// <param name="a">源Expression</param>
        /// <param name="b">拼接的Expression</param>
        /// <returns></returns>
        public static Expression<Func<TSource, bool>> And<TSource>(this Expression<Func<TSource, bool>> a, Expression<Func<TSource, bool>> b)
        {
            //建一个最终使用的参数节点
            ParameterExpression replacePara = Expression.Parameter(typeof(TSource), "myPara");

            var exprBody = Expression.And(a.Body, b.Body);
            exprBody = (BinaryExpression)new ParameterReplacer(replacePara).Visit(exprBody);

            return Expression.Lambda<Func<TSource, bool>>(exprBody, replacePara);
        }
    }

    /// <summary>
    /// 继承:ExpressionVisitor
    /// </summary>
    public class ParameterReplacer : ExpressionVisitor
    {
        private readonly ParameterExpression replacePara;

        internal ParameterReplacer(ParameterExpression _replacePara)
        {
            replacePara = _replacePara;
        }

        protected override Expression VisitParameter(ParameterExpression expression)
        {
            return base.VisitParameter(replacePara);
        }
    }

使用封装好的函数,用Lambda1、Lambda2去拼装Expression<Func<Stu, bool>>类型的Lambda3

            Expression<Func<Stu, bool>> lambda1 = (t => t.Age == 20);

            Expression<Func<Stu, bool>> lambda2 = (t => t.Name.Contains("孙"));

            Expression<Func<Stu, bool>> lambda3 = lambda1.And(lambda2);
       
            List<Stu> stuR = StuQ.Where(lambda3).ToList();

可以看到拼装后的表达式树中的参数节点都已经替换成了同一个参数(myPara):

到此为止已经成功执行查询到结果了:

更精简的写法:

            Expression<Func<Stu, bool>> lambdaExpression = (t => true);
            lambdaExpression = lambdaExpression.And(t => t.Age == 20);
            lambdaExpression = lambdaExpression.And(t => t.Name.Contains("孙"));
            List<Stu> stuR = StuQ.Where(lambdaExpression).ToList();

五、食用方式

实际开发中通过这种方法,将接口与业务层解耦。
接口负责只将查询条件拼成条件表达式树。业务层只负责执行查询,将涉及到的表的Iqueryable进行关联,投影(=>select)出DTO模型的字段,通过表达式树进行条件查询。

(1)直接使用封装的函数

现在有学生、学校两张表。
页面查询条件:
姓名、性别、学费范围
页面要显示:
姓名、年龄、性别、学校、学费

创建学生、学校两张表及对应的ORM模型:

using Chloe.Annotations;
namespace EasyCore.Entity.DB_Entity
{
    /// <summary>
    /// ORM模型:STU表
    /// </summary>
    [Table("STU")]
    public class Db_Stu
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public int Age { get; set; }
        public string Gender { get; set; }
        public string School { get; set; }
    }
}
using Chloe.Annotations;
namespace EasyCore.Entity.DB_Entity
{
    /// <summary>
    /// ORM模型:SCHOOL表
    /// </summary>
    [Table("SCHOOL")]
    public class Db_School
    {
        public string School { get; set; }

        public decimal Price { get; set; }
    }
}

创建一个DTO模型:

namespace EasyCore.Model
{
    /// <summary>
    /// DTO模型
    /// </summary>
    public class Dto_StuPrice
    {
        public string Name { get; set; }
        public int Age { get; set; }
        public string Gender { get; set; }
        public decimal Price { get; set; }
    }
}

创建接口的参数模型:

    public class StuPriceParaModel
    {
        public string Name { get; set; }
        public string Gender { get; set; }
        public decimal? MaxPrice { get; set; }
        public decimal? MinPrice { get; set; }
    }

根据需求开始编写接口及业务层:
接口:

        public ActionResult SearchStuPrice(StuPriceParaModel paraModel)
        {
            //使用参数创建条件表达式树
            Expression<Func<Dto_StuPrice, bool>> lambda = (t => true);
            if (paraModel.Gender != null)
                lambda = lambda.And(a => a.Gender == paraModel.Gender);
            if (paraModel.Name != null)
                lambda = lambda.And(a => a.Name.Contains(paraModel.Name));
            if (paraModel.MaxPrice != null)
                lambda = lambda.And(a => a.Price <= paraModel.MaxPrice);
            if (paraModel.MinPrice != null)
                lambda = lambda.And(a => a.Price >= paraModel.MinPrice);

            //调用业务层,把条件表达式树作为参数传进去
            List<Dto_StuPrice> dto_StuPrices = demoService.SreachStuPrice(lambda);

            //返回数据
            return JsonResult(dto_StuPrices);
        }

业务层:只负责获得拼装好的lambdaExpression执行查询,返回查询结果

        public List<Dto_StuPrice> SreachStuPrice(Expression<Func<Dto_StuPrice, bool>> lambda)
        {
            //两张表的IQueryable
            IQuery<Db_Stu> dB_StuQ = DbContext.Query<Db_Stu>();
            IQuery<Db_School> dB_School = DbContext.Query<Db_School>();

            //创建DTO模型的IQueryable
            IQuery<Dto_StuPrice> dto_StuPriceQ =
                dB_StuQ.LeftJoin(dB_School, (x, y) => x.School == y.School)
                .Select
                (
                (x, y) => new Dto_StuPrice
                {
                    Name = x.Name,
                    Age = x.Age,
                    Gender = x.Gender,
                    Price = y.Price
                });

            //用条件表达式树,做条件查询
            dto_StuPriceQ = dto_StuPriceQ.Where(lambda);

            //延迟查询
            List<Dto_StuPrice> dto_StuList = dto_StuPriceQ.ToList();

            return dto_StuList;
        }

这样一来,将接口与业务层解除耦合。
对于查询条件的修改,只需要修改接口,不需要去动其他代码。

以上是关于ASP.NET 运算式树(Expression tree)的原理及使用的主要内容,如果未能解决你的问题,请参考以下文章

Microsoft Expression Studio - 使用 ASP.NET 的 Web / Superpreview

说说编译器是否会对已知结果的运算式做出优化?(老物)

软件工程概论作业三

c_cpp 度度熊与运算式1

Python实现1-9数组形成的结果为100的所有运算式

关于Expression表达式树的拼接