先说一个我印象挺深的场景。当时做一个内部管理系统前端传过一个排序字段名后端要根据这个字符串动态排序。我一开始写了一大堆switch判断字段后来觉得不行改用反射拿属性。能跑但代码看着别扭性能也不理想。后来接触了 C# 的表达式树我才意识到这类代码即数据的需求就该用它来解决。表达式树是 C# 里把代码当作数据来处理的高级特性它能把一段 Lambda 表达式拆成一棵结构清晰的树谁是参数、谁是常量、谁是运算、谁是方法调用一目了然。你可以检查这棵树可以重写它也可以把它编译成可执行的委托。这篇文章就把我自己在用表达式树过程中总结的用法、场景和踩过的坑完整写出来希望能给正需要它的人节省点时间。1. 表达式树到底解决了什么问题1.1 从黑盒委托到白盒代码很多人写了多年 C#每天在用 Lambda但没认真想过一个问题普通委托FuncT、ActionT一旦编译成 IL对你来说就是一个黑盒。你只知道调用它能得到结果但你没法在运行时问它一句你内部是怎么算出来的。表达式树把这个黑盒打开了。你用ExpressionFuncint, bool声明的代码不会被立刻编译成 IL而是被构造成一棵内存中的树状数据结构。这棵树上的每个节点都是一个对象比如ParameterExpression表示参数ConstantExpression表示常量BinaryExpression表示加法、大于等二元运算。程序可以像看图纸一样查看这段代码的内部结构。我用一个生活化一点的类比来解释普通委托就像一本已经印刷好、装订成册的成品书你只能翻开、阅读没法知道作者当时是怎么构思的表达式树则是这本书的写作大纲、段落草稿和素材清单你能看到每一句话是怎么搭出来的甚至可以直接改掉其中一段再重新装订出版。这个代码可审查、可修改的能力是表达式树区别于普通委托最根本的地方。1.2 代码当作数据到底意味着什么把代码当作数据听起来抽象落到实际开发里就是三个具体能力审查、改写、转译。审查是指可以在运行时不执行代码、直接分析代码结构。比如你拿到一个表达式x x.Age 18 x.Score 60你可以遍历这棵树问它你这个条件判断里用到了哪些属性是不是有属性访问比较有没有危险的调用这些在运行时都能知道。改写是指可以对这棵树进行修改后再编译成新的委托。比如我写过一个工具把所有大于等于的操作统一改成大于 59的写法用ExpressionVisitor遍历一遍节点替换一下最后Compile()出来就是全新的函数。注意这里不是把字符串替换而是真的把内存里的对象节点换掉逻辑严密。转译是指把表达式树翻译成另一种语言或另一种结构。最典型的就是 LINQ to Entities 和 LINQ to SQLEF Core 拿到表达式树之后翻译成 SQL 字符串再发给数据库执行。你写的Where(u u.Age 18)并不是在内存里过滤数据而是被翻译成了WHERE [u].[Age] 18。这就是代码即数据最强大的落地场景。打个比方导航软件不是直接把你送到目的地而是先根据起点和终点规划出一条路线这条路线就是一份数据。你可以在这份数据上检查是否经过拥堵路段也可以修改路线绕开某个路口最后再按这条路线开车。表达式树就是这段路线数据委托才是正在开的车。1.3 表达式树和反射的边界很多人容易把表达式树和反射混在一起因为两者都能在运行时操作类型和成员。但它们的能力边界非常清晰反射看到的是类型结构比如类有哪些属性、方法、特性表达式树看到的是代码逻辑比如这段代码怎么比较、怎么调用、怎么构造对象。反射另一个重要能力是动态调用但动态调用通常只是拿到MethodInfo后Invoke而表达式树可以直接创建一段全新的逻辑并编译成委托这是反射做不到的。举个直观的例子。你想调用某个对象的私有方法反射可以做到拿MethodInfo再Invoke。但你想在运行时动态生成一个x x.A * x.B x.C这样的函数反射是做不到的表达式树可以。更极端一点你想根据外部配置生成一段新的业务规则比如当金额大于某值时走审核流程表达式树可以用Expression类从零搭建出来然后编译执行。另一个实际感受反射在调用编译后的代码时开销通常比直接调用高一个量级尤其涉及值类型装拆箱时更明显。表达式树虽然在构建时也有开销但Compile()一次之后后续调用就是纯委托调用性能接近手写代码。所以高频路径上能用表达式树替代反射的地方我都会尽量替换。2. 表达式树的核心解剖关键节点与构造方式2.1 常见的表达式节点类型表达式树由各种节点组成入门阶段你最常接触的是下面这些节点类型。节点类型作用典型用法ParameterExpression表示参数比如x 中的xExpression.Parameter(typeof(int), x)ConstantExpression表示常量比如x 5中的5Expression.Constant(5)BinaryExpression表示二元运算比如加减乘除、比较、逻辑与或Expression.Add、Expression.GreaterThan、Expression.AndAlsoMethodCallExpression表示方法调用比如s.Contains(a)Expression.Call(instance, methodInfo, args)MemberExpression表示属性或字段访问比如u.AgeExpression.Property(param, Age)NewExpression表示构造函数调用常用于匿名对象拼接Expression.New(constructorInfo)ConditionalExpression表示条件表达式即三元运算符Expression.Condition(test, ifTrue, ifFalse)记住这些节点的关键点是一棵表达式树一定有一个根节点通常是LambdaExpression它的子类型是ExpressionTDelegate。根节点往下内部节点是各种非叶子表达式叶子节点一般是ParameterExpression、ConstantExpression。你可以把整棵树想象成一张家谱每个节点都知道自己的父亲是谁——Parent属性不直接暴露但通过Visit遍历时能感知到层级关系。2.2 从零构造一棵表达式树先看最简单的手工构造流程。假设你想构造出x x 5用表达式树 API 写出来是这样// 1. 定义参数 x var x Expression.Parameter(typeof(int), x); // 2. 定义常量 5 var five Expression.Constant(5); // 3. 创建 x 5 var greaterThan Expression.GreaterThan(x, five); // 4. 换成 Lambda 表达式树 var expr Expression.LambdaFuncint, bool(greaterThan, x); // 5. 编译成委托 var func expr.Compile(); Console.WriteLine(func(7)); // True Console.WriteLine(func(3)); // False这个流程本身不难难的是理解每一步的意义。Expression.Parameter创建的是形式参数它不是具体的值而是一个占位符。Expression.GreaterThan会生成一个BinaryExpression它的两个子节点分别指向刚才的x和five。Expression.LambdaTDelegate把一棵表达式挂到某个委托签名上最后Compile()将其转换为真正的委托代码。值得提醒的是很多人在构造时容易漏掉Expression.Convert。比如属性类型是int而你需要的委托签名是FuncT, object直接拿Expression.Property的结果去拼Lambda会报类型不匹配。解决办法就是套一层Expression.Convert(property, typeof(object))把值转成object再塞进委托。这在动态排序里尤其常见一会儿我会写到。2.3 拆开看一棵表达式树的内部结构我习惯在调试时把表达式树的形状打印出来。写一个递归方法很简单static void PrintTree(Expression node, int depth 0) { var indent new string( , depth * 2); Console.WriteLine(${indent}{node.NodeType}: {node}); switch (node) { case BinaryExpression binary: PrintTree(binary.Left, depth 1); PrintTree(binary.Right, depth 1); break; case LambdaExpression lambda: PrintTree(lambda.Body, depth 1); break; case MethodCallExpression call: if (call.Object ! null) PrintTree(call.Object, depth 1); foreach (var arg in call.Arguments) PrintTree(arg, depth 1); break; } }拿ExpressionFuncint, int expr x x * 2 3;来打印输出大致是Lambda: x ((x * 2) 3) Add Multiply Parameter: x Constant: 2 Constant: 3注意这里加法的左右顺序x * 2是左子节点3是右子节点。如果你打印一个x 3 x * 2树的形状会不一样但最终求值结果一样。这告诉我们表达式树的形状忠实地反映了代码的语法结构括号和优先级已经在树里固化了不需要像解析字符串那样手动处理优先级。3. 动手写一个表达式树小引擎3.1 动态排序器让字符串字段名变成真代码回到开头那个动态排序需求。我要实现的是输入一个属性名字符串输出一个可以直接传给OrderBy/OrderByDescending的表达式树。核心方法长这样public static ExpressionFuncT, object BuildSortExpressionT(string propertyName) { var param Expression.Parameter(typeof(T), x); var property Expression.Property(param, propertyName); var converted Expression.Convert(property, typeof(object)); return Expression.LambdaFuncT, object(converted, param); }调用端这样用var query users.AsQueryable(); var sortExpr BuildSortExpressionUser(Age); query query.OrderByDescending(sortExpr);这里的两个关键点是Expression.Property接收的字符串必须是类型上真实存在的公共属性名否则会在运行时抛出ArgumentException而不是编译期报错Expression.Convert是为了把属性值装箱成object这样才能统一塞进FuncT, object签名里。如果属性本身是string这种引用类型Convert也不是不能省但为了代码通用我这里统一都加了。性能上这个方法每次都重新构建表达式树高频排序时肯定不行。优化方案是加缓存以(typeof(T), propertyName)作为 key 缓存编译结果。我在后面会单独讲缓存策略这里先记住结论构建一次、缓存、重复使用就能把动态排序性能拉到和手写 Lambda 几乎无差别的水平。3.2 查询条件拼接把可变参数变成查询表达式做后台系统的时候最常遇到的需求是动态拼接查询条件。前端传进来一堆筛选条件后端要根据这些条件过滤数据条件数量、字段名都是不确定的。每写一个分支去判断有没有这个条件代码就会膨胀得很难看。我用表达式树的方式做一个通用的条件构造器思路是把每个条件转成一个小表达式然后通过Expression.AndAlso即 C# 中的拼接起来最后用一个统一的ParameterExpression替换所有表达式里的参数。public static ExpressionFuncT, bool CombineFiltersT( IEnumerableExpressionFuncT, bool predicates) { var param Expression.Parameter(typeof(T), x); Expression? body null; foreach (var predicate in predicates) { // 把原表达式的参数替换成统一的 param var replaced new ParameterReplaceVisitor(predicate.Parameters[0], param) .Visit(predicate.Body); body body null ? replaced : Expression.AndAlso(body, replaced); } if (body null) return x true; return Expression.LambdaFuncT, bool(body, param); }其中替换参数的核心依赖ExpressionVisitor的一个子类class ParameterReplaceVisitor : ExpressionVisitor { private readonly ParameterExpression _oldParam; private readonly ParameterExpression _newParam; public ParameterReplaceVisitor(ParameterExpression oldParam, ParameterExpression newParam) { _oldParam oldParam; _newParam newParam; } protected override Expression VisitParameter(ParameterExpression node) { return node _oldParam ? _newParam : base.VisitParameter(node); } }为什么要做参数替换因为多个ExpressionFuncT, bool可能各自声明了不同的参数名比如u u.Age 18、x x.Score 60。直接拼AndAlso会创建两个独立的参数节点翻译或编译时会出现参数来源不一致的异常。统一替换成同一个ParameterExpression实例才能保证表达式树的参数引用完全一致。3.3 用 ExpressionVisitor 重写表达式树ExpressionVisitor是表达式树里我最常用的类它是专为重写表达式树设计的基类。你不需要自己去遍历树只要重写对应节点类型的Visit方法就能精准地拦截和替换。我做过一个小规则引擎要把大于等于 18 岁这种条件统一改写成大于 17 岁。当然实际场景更复杂比如把某个函数调用替换成另一个函数这里演示一下思路class RewriteAgeVisitor : ExpressionVisitor { protected override Expression VisitBinary(BinaryExpression node) { // 只拦截成员访问 Age 的二元比较 if (node.NodeType ExpressionType.GreaterThanOrEqual node.Left is MemberExpression member member.Member.Name Age) { // 改成 Age 17 return Expression.GreaterThan( member, Expression.Constant(17, typeof(int))); } return base.VisitBinary(node); } }使用方式ExpressionFuncUser, bool expr u u.Age 18; var rewritten (ExpressionFuncUser, bool)new RewriteAgeVisitor().Visit(expr);这里有一个容易忽略的细节Visit返回的是Expression但原始类型被保留下来了你要用强转把结果转回ExpressionFuncUser, bool。因为表达式树的不可变性Visit不会修改原树而是返回一棵新树。如果你没有任何节点被修改它返回的也可能是原对象的引用这点在比较引用时要注意。4. 实战落地从脱敏到权限过滤4.1 字段脱敏拦截手机号输出在给第三方提供数据的时候经常遇到字段脱敏的需求手机号中间四位要打码身份证部分隐藏。如果每个接口都写一遍脱敏逻辑太容易漏。用表达式树可以做一个全局的字段访问拦截器。思路是在VisitMember里识别属性名如果是Mobile就把它替换成一段调用字符串处理方法的表达式class MaskingVisitor : ExpressionVisitor { protected override Expression VisitMember(MemberExpression node) { if (node.Member.Name Mobile) { // node 表示 u.Mobile我们要把它替换成 u.Mobile.Substring(0, 3) **** u.Mobile.Substring(7) var replaceMethod typeof(string).GetMethod(Substring, new[] { typeof(int), typeof(int) }); var start Expression.Call(node, replaceMethod, Expression.Constant(0), Expression.Constant(3)); var end Expression.Call(node, replaceMethod, Expression.Constant(7), Expression.Constant(4)); var concat typeof(string).GetMethod(Concat, new[] { typeof(string), typeof(string), typeof(string) }); return Expression.Call(concat, start, Expression.Constant(****), end); } return base.VisitMember(node); } }这样只要把项目里返回给外部的实体统一经过一次表达式树脱敏转换所有Mobile字段都会自动变形成138****1234的格式。不需要改实体定义也不需要调用方感知这就是表达式树能实现真正意义上的横切逻辑的一个例子。4.2 动态权限过滤器给查询加一堵墙多租户系统里有一个非常痛的难题不同角色看到的数据范围不同。比如普通用户只能看到自己创建的记录管理员能看到部门所有记录超级管理员能看到全公司记录。如果所有查询都手写Where(u u.TenantId currentTenantId)很容易漏。用表达式树做一个权限过滤器思路和 3.2 类似在用户原有查询条件前面动态拼接一个权限条件。比如public static ExpressionFuncT, bool ApplyTenantFilterT( ExpressionFuncT, bool userExpr, int tenantId) { var param Expression.Parameter(typeof(T), x); var tenantProperty Expression.Property(param, TenantId); var tenantCondition Expression.Equal( tenantProperty, Expression.Constant(tenantId)); var replaced new ParameterReplaceVisitor(userExpr.Parameters[0], param) .Visit(userExpr.Body); var combined Expression.AndAlso(tenantCondition, replaced); return Expression.LambdaFuncT, bool(combined, param); }这里我踩过坑如果直接userExpr.Body和tenantCondition拼两个表达式各用各的参数节点EF Core 翻译时大概率报错。要做就必须统一参数。另外TenantId属性如果不存在Expression.Property会直接抛异常所以最好在构建前用typeof(T).GetProperty(TenantId)做一次校验提前发现问题别把异常留到数据库查询时。4.3 性能表达式树编译不能成为瓶颈表达式树最容易被诟病的是性能。构造树、Compile()都有开销尤其Compile()内部要生成动态方法走的是轻量级代码生成第一次被调用时可能触发 JIT。但只要你用对了模式性能完全可控。经验做法是把构建好并编译好的委托缓存起来用静态只读字段存private static readonly ExpressionFuncOrder, bool ActiveOrderExpr o o.Status 1 o.Amount 0; private static readonly FuncOrder, bool ActiveOrderFunc ActiveOrderExpr.Compile();访问器方法只调用ActiveOrderFunc不重复构建表达式树。实测中这种缓存后的调用开销和直接写死o o.Status 1 o.Amount 0几乎没有差别因为 CLR 会把委托调用内联到接近零开销的程度。真正要避免的是每次请求进来都Expression.Parameter、Expression.Lambda、Compile一条龙。尤其在 Web 高并发场景这会导致额外的内存分配和 JIT 压力。记住这个原则表达式树是一次构建、多次使用的中间表示不要把构建过程放在热路径上。5. EF Core 为什么离不开表达式树5.1 查询翻译的基本流程很多人只知道 EF Core 能把 LINQ 查询翻译成 SQL但没搞懂凭什么能翻译。核心秘密就在表达式树。IQueryableT对象本身不是一个数据集合它内部保存了两样东西一个Expression对查询的描述和一个IQueryProvider执行器。当你写db.Users.Where(u u.Age 18)时并没有立即执行而是构造了一棵更大的表达式树把Where调用和里面的u u.Age 18都记录下来。等到你去遍历结果、ToList()、FirstOrDefault()的时候查询才真正开始执行。执行阶段IQueryProvider会对这棵表达式树做翻译先把它拆成一颗颗节点再调用数据库特定的 SQL 生成器把方法调用、属性访问、常量、二元运算映射成 SQL 片段。Where变成WHEREOrderBy变成ORDER BYTake(10)变成TOP 10或OFFSET ... FETCH。这个过程本身就是表达式树的转译能力在生产环境中的一次大规模实践。5.2 为什么你的自定义方法不能被翻译经常能看到这样的提问我写了Where(u IsAdult(u.Age))为什么 EF Core 报错说不能翻译。答案是EF Core 的 SQL 翻译器认识Expression类的各种节点但它不认识你自定义的方法。IsAdult是一个MethodCallExpression节点本身没有对应的 SQL 映射翻译器也不愿意去执行你的 C# 方法因为那会在数据库端引入不可控逻辑。一旦碰到无法翻译的节点EF Core 通常有两种处理方式一是抛异常告诉你不能翻译为 SQL二是悄悄把剩余逻辑放到客户端内存里执行最典型的是AsEnumerable()之后的部分。后一种方式容易引起全表加载性能隐患极大我建议只要发现一个查询在Where或OrderBy里用了自定义方法就尽早想办法改写。正确的做法是把自定义方法拆解成表达式树能理解的基本操作。IsAdult(u.Age)如果可以写成u.Age 18那就直接写后者如果逻辑比较复杂可以用三元运算符或者、||组合实现。总之你想让 EF Core 翻译就得给它吃它认识的食物。5.3 能翻译和不能翻译的操作速查表我从实际项目里总结了一张速查表列出了常见的可翻译和不可翻译操作。可翻译操作说明Where、Select、OrderBy、GroupBy、Join标准查询运算符映射到 SQL 子句Contains、StartsWith、EndsWith映射成 LIKE 或 IN字符串Length、ToUpper、ToLower、Trim常用字符串函数Math类的一部分静态方法EF Core 专门做了映射比如Math.Abs、Math.Ceiling可空类型的.HasValue、.Value映射为IS NULL判断比较运算符、逻辑运算符几乎都能翻译不可翻译操作说明自定义静态或实例方法无 SQL 映射Random.Next无法保证确定性访问当前对象的私有字段/实例字段反射字段通常不参与翻译DateTime.Now之外的本地时间计算服务器和数据库时区差异大建议显式传递值使用时还有个小技巧如果遇到DateTime.Now参与查询先把var now DateTime.Now放到查询外部作为局部变量然后在表达式里引用这个局部变量这样可以强制 EF Core 把它当作参数和 SQL Parameter 绑定执行计划也能复用。6. 常见问题与避坑技巧实录6.1 闭包变量被当成常量的坑用闭包变量构建表达式树稍不注意就出现诡异问题。看这段代码int minAge 18; ExpressionFuncUser, bool expr u u.Age minAge; minAge 25; var func expr.Compile(); Console.WriteLine(func(user)); // 这里用的 minAge 是多少答案是Compile之后的委托里minAge已经被固定为 18 了之后你改成 25 不影响已经编译好的委托。因为表达式树捕获闭包变量时实际上是把闭包对象作为ConstantExpression的一个实例值嵌入树中后续修改外部变量其实修改的是闭包对象的字段但树的节点内容已经定格。如果你需要表达式树反映外部变量的最新值就不要把变量直接塞进表达式而是应该把它当作普通参数传进去ExpressionFuncint, bool expr (min) min 18; var result expr.Compile()(minAge);或者保持参数化查询在 EF Core 场景里把外部变量转成表达式参数能更好利用 SQL 参数缓存。这个不为多数人所知的细节我建议你记下来调试时很省时间。6.2 调试表达式树的实用工具表达式树调试起来比普通代码难因为它不是文本而是结构化的对象图。我最常使用的调试方式是两种一是利用ToString()它会输出一个近似于 C# 源码的字符串比如u (u.Age 18)二是写一个递归打印方法像第 2 章那样一层一层输出NodeType和内容。比ToString()更好用一点的是DebugView属性。这个属性是 .NET 专门给调试器准备的设计为只读的调试视图。你可以在调试器快速监视窗口里输入expr.DebugView它会显示一棵带缩进、很接近源码的树结构比ToString()输出更精细连闭包变量的处理都能看到一点点痕迹。注意它是调试专用的不要在业务代码里依赖它做解析内部结构随时可能调整。还有一个我常用的辅助手段在Test环境里直接把表达式树序列化成可读文本比如写一个ExpressionDumper工具。由于表达式树本身是对象图你要避免循环引用导致序列化栈溢出建议只遍历NodeType、Type和关键子节点别想着把每个MemberInfo都倒出来信息太多反而难读。6.3 高频调用时的 GC 压力与缓存策略动态构建表达式树意味着会产生大量临时对象每个ParameterExpression、ConstantExpression、BinaryExpression都是一个对象频繁创建会带来明显的 GC 压力尤其在一些每秒调用上千次的服务里。我的做法分三层最底层是针对固定逻辑直接用static readonly字段缓存表达式树和编译好的委托几乎零开销中间层按类型 属性名做字典缓存比如动态排序表达式用ConcurrentDictionary(Type, string), object存编译结果最上层是业务层缓存整个条件组合构建后缓存只有配置变更时清缓存。关于字典的 key最好用(Type, string)这种不可变元组不要图方便拼字符串 key类名.属性名这种拼法在热路径上会产生字符串分配。另外缓存不要无脑长期持有如果表达式树依赖租户、用户 ID 这类变化频率较高的数据密钥就要带上这些上下文或者干脆不缓存只缓存表达式模板把参数值通过Invoke传入。6.4 操作可空类型时的装拆箱陷阱表达式树在拼接可空类型int?、DateTime?时也容易踩坑。比如你要构造u u.Birthday.HasValue u.Birthday threshold直接对MemberExpression做GreaterThanOrEqual可能会报类型不匹配因为Birthday类型是DateTime?不是DateTime。这时候要先访问.Value属性或者加一层转换var birthday Expression.Property(param, Birthday); var hasValue Expression.Property(birthday, HasValue); var valueExpr Expression.Property(birthday, Value); var threshold Expression.Constant(DateTime.Today); var compare Expression.GreaterThanOrEqual(valueExpr, threshold); var combined Expression.AndAlso(hasValue, compare);另外一个常见问题是拿Expression.Constant(null)去对比可空类型有时候需要把 null 的Type显式指定为目标类型否则编译期或运行期可能出现不兼容的异常。这个细节在动态 SQL 调试里很常见看到异常里带着 binary operator ... not defined 之类的话八成就是这个问题。最后说几句闲话表达式树这个特性入门时觉得绕用多了就会发现它是 C# 里非常趁手的一件工具。它最大的价值不是让你炫技而是能在运行时把代码逻辑当成可操作的数据来对待这给了很多框架级设计巨大的灵活性。我实际用下来最省心的场景还是动态排序和动态拼接查询条件这两类需求本身就很适合表达式树条件可组合、逻辑可替换、最终还能交给 EF Core 翻译成高效的 SQL。我个人在表达树上的成长路径是先打印树的形状熟悉节点类型然后写自定义ExpressionVisitor改树最后再去看 EF Core 的 SQL 日志对比验证翻译结果。这个路径对新人来说比较平滑不会一上来就被各种节点类型吓到。这里再分享一个小技巧如果你要在项目里大规模使用表达式树建议先封装一个表达式构建器的工具类把Parameter、Equal、AndAlso这些低级 API 包一层业务代码里只写语义化方法团队上手会快很多。表达式树能演化的方向其实还有很多比如规则引擎、动态表单校验、接口回放工具都是拿表达式树当中间表示。但前提是先把今天说的这些基础场景吃透尤其是参数替换、缓存编译、可空类型处理这几关过了后面基本就顺了。