EF框架下如何在单对象映射场景中复用Expression且不调用Compile
EF 的 SQL 翻译器仅能解析结构化的标准 Expression 树节点,碰到以下两类节点时无法生成可执行的SQL:
- 调用已编译委托的节点(也就是使用
Compile()生成的调用) - 针对内存集合的操作节点(比如写的
new List<Blog>{post.Blog}.Select(...))
这两类节点都会触发EF的客户端评估,要么把全量数据拉回内存处理,要么直接抛出翻译异常。
要实现可复用、可被EF正常翻译的Expression嵌套,不需要调用Compile,只需要对已有的内层映射Expression做参数替换:把内层Expression原本针对实体(比如Blog)的输入参数,替换成外层Expression里对应的导航属性(比如post.Blog),再把替换后的Expression节点嵌入到外层投影里即可。
整个过程完全操作Expression树的结构,不生成任何编译后的委托,所有节点都符合EF的解析要求,最终生成的Expression树和直接手写的投影结构完全一致。
第一步:实现通用的Expression参数替换工具
通过内置的ExpressionVisitor遍历表达式树,把内层映射的输入参数替换为外层表达式的对应导航属性:
public static class ExpressionReuseHelper { /// <summary> /// 把源投影的参数,替换为目标输入对应的导航属性表达式 /// </summary> /// <typeparam name="TOuterInput">外层表达式的输入类型(比如Post)</typeparam> /// <typeparam name="TInnerSource">内层投影的输入类型(比如Blog)</typeparam> /// <typeparam name="TResult">投影输出的DTO类型</typeparam> /// <param name="innerProjection">已定义好的内层映射Expression(比如Blog的投影)</param> /// <param name="outerPropertyAccessor">外层表达式中对应导航属性的访问路径(比如post => post.Blog)</param> public static Expression<Func<TOuterInput, TResult>> NestProjection<TOuterInput, TInnerSource, TResult>( this Expression<Func<TInnerSource, TResult>> innerProjection, Expression<Func<TOuterInput, TInnerSource>> outerPropertyAccessor) { // 建立参数映射:把内层的输入参数,替换为外层的导航属性访问表达式 var parameterMapping = new Dictionary<ParameterExpression, Expression> { [innerProjection.Parameters[0]] = outerPropertyAccessor.Body }; // 遍历替换内层表达式中所有的匹配参数 var replacedBody = new ParameterReplaceVisitor(parameterMapping).Visit(innerProjection.Body); // 构造新的Lambda表达式,入参和外层保持一致 return Expression.Lambda<Func<TOuterInput, TResult>>(replacedBody, outerPropertyAccessor.Parameters); } /// <summary> /// 自定义Expression访问者,实现参数节点替换 /// </summary> private class ParameterReplaceVisitor : ExpressionVisitor { private readonly IReadOnlyDictionary<ParameterExpression, Expression> _mapping; public ParameterReplaceVisitor(IReadOnlyDictionary<ParameterExpression, Expression> mapping) { _mapping = mapping; } protected override Expression VisitParameter(ParameterExpression node) { // 如果碰到需要替换的参数,就用映射的表达式替换,否则保留原节点 return _mapping.TryGetValue(node, out var replacement) ? replacement : base.VisitParameter(node); } } }
第二步:改造你的Mapper逻辑
在PostMapper中直接调用上面的工具方法完成嵌套,全程不需要调用Compile:
public class PostMapper { public Expression<Func<Post, PostDto>> ProjectPost { get; } public PostMapper(BlogMapper blogMapper) { // 定义外层Post到对应Blog导航属性的访问路径 ParameterExpression postParameter = Expression.Parameter(typeof(Post), "post"); Expression<Func<Post, Blog>> blogNavigationPath = post => post.Blog; // 把Blog的投影Expression嵌套到当前上下文,替换参数为post.Blog var nestedBlogProjection = blogMapper.ProjectBlogList.NestProjection(blogNavigationPath); // 构造Post的投影:直接拼接各构造参数的Expression节点 var postDtoConstructor = typeof(PostDto).GetConstructor(new[] { typeof(int), typeof(string), typeof(string), typeof(BlogListDto) }); var projectionBody = Expression.New( postDtoConstructor, // 依次传递PostDto构造参数的Expression节点 Expression.Property(postParameter, nameof(Post.PostId)), Expression.Property(postParameter, nameof(Post.Title)), Expression.Property(postParameter, nameof(Post.Content)), // 直接使用替换好的Blog投影节点,无编译操作 nestedBlogProjection.Body ); ProjectPost = Expression.Lambda<Func<Post, PostDto>>(projectionBody, postParameter); } }
你写的new List<Blog>{post.Blog}.Select(blogMapper.ProjectBlog).First()这种写法,本质是在内存中构造了一个单个元素的集合,EF识别到操作对象是内存集合后,会触发客户端评估——也就是把Blog实体的所有列数据先从数据库拉回到内存,再执行集合的Select和First操作,完全失去了EF投影的优化能力(比如只查询需要的列、减少网络传输量),和直接在内存中做映射没有区别。
用上面的写法生成的Expression树结构,会被EF正常翻译为下推到数据库的SQL查询,生成的SQL大致如下,完全不需要客户端评估:
SELECT p.PostId, p.Title, p.Content, b.BlogId, b.Url FROM Posts p INNER JOIN Blogs b ON p.BlogId = b.BlogId
这种嵌套的效果,和集合场景下blog.Posts.Select(postMapper.ProjectPost)的翻译逻辑完全一致,不会出现任何兼容问题。
内容的提问来源于stack exchange,提问作者Peter Pollen

