ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

EF Core原生SQL实战:FromSql/SqlQuery映射与仓储封装

2026/9/13 15:04:24 拓冰建站 浏览量
EF Core原生SQL实战:FromSql/SqlQuery映射与仓储封装 你有没有过这种经历业务报表越写越复杂LINQ 表达式树绕得头大Dapper 又不敢乱引最后实在绷不住在 EF Core 里直接塞了一段原生 SQL结果一运行就被“列名无效”“无法映射”各种报错打懵我上个月就撞上这么一回。项目是 .NET 8 的 Web APIEF Core 8.0 连 SQL Server查询要跨五张表做聚合统计。LINQ 写出来一大坨生成八层嵌套子查询线上跑一次 3 秒多。我索性在仓储层写原生 SQL结果发现 EF Core 里能用来执行原生 SQL 的入口远不止一个FromSqlRaw、FromSqlInterpolated、SqlQueryRaw……每个的映射边界还不一样踩完坑才把整条路摸清。这篇文章不打算讲官网文档里那些翻来覆去的基础用法而是把实际使用过程中积累的经验整理出来重点聊清楚三件事EF Core 原生 SQL 的几个入口分别在什么场景用、对象映射的边界到底卡在哪、以及在三层架构下怎么安全地把原生 SQL 封装进 BaseRepository 和 BaseService。适合已经在用 EF Core但遇到复杂查询时想把原生 SQL 用得更踏实的 .NET 开发者。1. 先理清楚EF Core 原生 SQL 的三条主要通道1.1 FromSqlRaw / FromSqlInterpolated面向实体的查询入口FromSql 系列是在 DbSet 上直接调用的这是它和后面 SqlQuery 最本质的区别。比如context.Blogs.FromSqlRaw(SELECT * FROM Blogs WHERE Id {0}, id)它的返回类型必须和 DbSet 的泛型参数保持一致也就是只能查实体类型不能像 Dapper 那样随心所欲地返回一个匿名对象或自定义 DTO。在实际项目里FromSql 系列最大的价值是可以继续叠加 LINQ。EF Core 会把你的原生 SQL 当作一个子查询包起来然后在外层追加 Where、OrderBy、Skip、Take。这意味着你可以在仓储层写一段很复杂的表连接 SQL再在上层继续分页筛选。这个特性用过之后是真的回不去比如下面这段代码先通过原生 SQL 把七天内需要处理的订单捞出来再在内存端其实是数据库端做排序和分页var id 1024; // EF Core 8.0 推荐用法FormattableString 自动参数化 var blogs await context.Blogs .FromSql($SELECT * FROM Blogs WHERE Id {id}) .OrderBy(b b.CreatedAt) .Take(10) .ToListAsync();FromSql 和 FromSqlInterpolated 都接收 FormattableString插值表达式里的变量会被 EF Core 自动转成 SQL 参数不需要手工创建 SqlParameter。FromSqlRaw 则接收原始字符串参数用{0}占位适合已经拼好 SQL 模板的代码。老实说 FromSql 和 FromSqlInterpolated 行为基本一致8.0 官方更推荐直接用 FromSql命名更简洁。这里有一个很多新手踩过的坑FormattableString 里插入的表名、列名不会拼进 SQL而是被当成参数值。比如$SELECT * FROM {tableName} WHERE Id {id}EF Core 会把 tableName 变成一个查询参数最终 SQL 变成SELECT * FROM p0 WHERE Id p1数据库直接报语法错。动态表名只能用 FromSqlRaw 拼接并且必须自己做白名单校验这个后面第三章会详细说。1.2 SqlQuery / SqlQueryRaw面向标量与 DTO 的查询入口SqlQuery 系列是 EF Core 7.0 引入的当时叫 Query8.0 改名成 SqlQuery。它挂在context.Database上而不是 DbSet 上这一点决定了它的定位查询结果不一定要是实体类型。最直接的用法是返回标量类型集合比如只需要取一批 Idvar ids await context.Database .SqlQueryint($SELECT Id FROM Blogs WHERE CreatedAt cutoff) .ToListAsync();更常见的场景是返回自定义 DTO。报表、统计、跨表投影这些需求用实体映射反而别扭因为需要额外创建一堆没有业务行为的“伪实体”。SqlQuery 直接把结果映射到 POCO干净利落var rows await context.Database .SqlQueryRawBlogTitleDto( SELECT Id, Title AS BlogTitle FROM Blogs WHERE Id id, new SqlParameter(id, id)) .FirstOrDefaultAsync();注意这里的命名带 Raw 后缀的方法接收原始 SQL 字符串和参数数组不带 Raw 的接收 FormattableString 自动参数化。如果你写的是SqlQueryRaw但 SQL 里用{0}占位参数却传 SqlParameter那可能会得到意料之外的结果这点我建议一开始就统一约定团队里不要两种风格混用。SqlQuery 返回的结果默认是不跟踪的它纯粹是“查询出来、填充对象、用完即走”没有 ChangeTracker 那套状态管理。所以它的性能开销通常比 FromSql 小适合只读报表。1.3 核心区别速览可组合、返回类型、跟踪行为写了这么多先把三条通道的核心区别用表格整理明白。我平时做技术评审时也喜欢直接用这个表跟同事对齐避免大家因为 API 名字长得像就乱用维度FromSql / FromSqlRawSqlQuery / SqlQueryRaw调用位置DbSet 上调用Database 对象上调用返回类型实体类型8.0 也支持复杂类型标量类型、自定义 POCO、实体查询跟踪默认跟踪可 AsNoTracking不跟踪只读可组合性支持叠加 LINQSQL 会被包成子查询终端查询不建议再叠加 LINQ主键要求实体必须能识别主键无主键要求典型场景实体列表查询、性能优化、分段更新报表 DTO、标量聚合、临时结果集除了查询类 APIEF Core 还有 ExecuteSql、ExecuteSqlRaw、ExecuteSqlInterpolated 用于执行非查询 SQLINSERT / UPDATE / DELETE / 存储过程返回影响行数。这篇文章重点讲查询映射就先不展开。2. 对象映射的边界到底卡在哪2.1 列名匹配的隐藏规则别名和大小写先说一个最常见的映射报错现场。你写了SELECT Id, Title, CreateTime FROM ...返回结果给BlogDtoBlogDto 里属性叫BlogTitle、CreatedAt结果跑出来全是 null。为什么因为 EF Core 做对象映射时默认规则是“SQL 返回的列名”对应“目标类型的属性名”它不是按位置匹配的。列名对不上属性就保持默认值。解决办法通常是在 SQL 里起别名不要嫌麻烦var rows await context.Database .SqlQueryRawBlogTitleDto( SELECT Id, Title AS BlogTitle, CreatedAt FROM Blogs) .ToListAsync();还有大小写的坑。SQL Server 默认排序规则对大小写不敏感CREATEtime也能匹配上CreatedAt。但如果你用的是 PostgreSQL不带引号的标识符会被折叠成小写实体属性是 PascalCase 的CreatedAt查询返回的列名是createdat两边就对不上了。同一个项目切数据库时这类问题会集中爆发。我的建议是凡是 SqlQuery 映射的 SQL都主动写别名并且保持列名和属性名完全一致别依赖数据库的大小写容错。2.2 实体映射的边界缺列、主键与跟踪行为FromSql 映射实体类型时有一个演变过程需要知道。EF Core 7.0 之前FromSql 要求 SQL 必须返回实体映射的所有列少一列就报异常而且报错信息特别硬核。7.0 之后放宽了限制SQL 可以只返回实体属性的一部分缺失的列会用默认值填充。听起来很美好但这里藏着一个大坑如果实体是默认跟踪状态恰好还有一列没查出来当你调用 SaveChanges 时缺的那一列会以默认值写回数据库覆盖掉真实数据。这个事故我真实遇到过虽然不是生产数据但也足够心惊胆战。所以我的规范很简单FromSql 缺列可以但只允许配合 AsNoTracking 使用任何需要后续更新保存的实体必须把映射涉及到的列查全。还有一个和主键相关的边界FromSql 的结果如果被跟踪EF Core 需要能从结果集里识别实体的主键。如果 SQL 没返回主键列实体状态判断就会出问题。所以 FromSql 的 SQL 里主键列一定不能漏。2.3 DTO 映射的边界构造函数、只读属性与可空类型SqlQuery 映射自定义类型时对类型的形状有隐含要求需要无参构造函数属性要有可写入的 setter。EF Core 物化对象时要先创建实例再往里填属性值。如果你的 DTO 只有带参构造函数或者全是 init-only / get-only 属性就会遇到映射失败或者属性全为默认值的情况。我一开始写报表 DTO 时习惯把所有字段设成只读试图保持对象不可变性结果 SqlQuery 返回的每个对象属性都是 null。后来改成标准 POCO属性带 public setter才正常。EF Core 8.0 对复杂类型的映射支持也更好了但复杂类型的属性嵌套映射、列命名规则又是另一套玩法比如嵌套对象默认映射列名是Owner_Address_City这种下划线拼接SQL 里不写对应列名就映射不上。这类边界问题除非确实需要复杂类型建模否则我建议优先用扁平 DTO简单直接。另外SQL 返回 NUll 时如果目标属性是非空值类型int、DateTime、Guid物化过程会抛异常。处理办法有两个要么在 SQL 里用ISNULL/COALESCE兜底要么把 DTO 属性改成可空类型。报表场景我通常两者一起用SQL 兜底保证兼容老数据DTO 可空属性让代码对空值更宽容。2.4 导航属性和关系原生 SQL 不背这个锅很多人以为 FromSql 查出一个实体EF Core 会自动把导航属性填好这个理解是错的。无论是 FromSql 还是 SqlQuery返回的结果集本质是扁平的行数据EF Core 不会因为 SQL 里 JOIN 了别的表就自动填充导航属性。导航属性的加载需要独立查询或者依赖 ChangeTracker 的关系修复原生 SQL 的这一拍是空白的。那需要关联数据怎么办我一般是直接投影到 DTO一次性把需要的字段都 Select 出来。比如文章表 JOIN 作者表DTO 里直接放 AuthorName而不是放一个 Author 导航对象然后去访问dto.Author.Name。这样做的好处是对象图扁平化后续序列化、前端消费都省事。如果确实想要实体导航属性可以尝试在 FromSql 之后继续使用 Include比如context.Blogs.FromSql(...).Include(b b.Posts)。EF Core 会生成第二条查询去加载关联数据但第一条 SQL 必须是可组合的存储过程或含聚合的 SQL 在这个场景下很容易翻车。3. 三层架构实战在 BaseRepository 里安全地封装原生 SQL3.1 先封装一个安全的 BaseRepository近期在群里看到不少 .NET 8 EF Core 的朋友在做三层架构BaseService / BaseRepository 一套泛型基类打天下。这个思路本身没问题但很多人的 BaseRepository 里只封装了标准的 CRUD遇到原生 SQL 需求就不知道怎么放进去了最后 Controller 里直接 new DbContext三层结构形同虚设。我的做法是在 BaseRepository 里提供两个受保护的方法分别对应 FromSql 和 SqlQuery这样派生仓储类既能复用又不会把 SQL 逻辑泄露到 Service 层public class BaseRepositoryT where T : class { protected readonly AppDbContext Db; protected BaseRepository(AppDbContext db) { Db db; } // 实体查询适合查询后需要继续跟踪、修改的场景 protected async TaskListT FromSqlListAsync( FormattableString sql, CancellationToken ct default) { return await Db.SetT() .FromSql(sql) .AsNoTracking() .ToListAsync(ct); } // DTO/标量查询适合报表、投影等只读场景 protected async TaskListTDto SqlQueryListAsyncTDto( string sql, params object[] parameters) where TDto : class { return await Db.Database .SqlQueryRawTDto(sql, parameters) .ToListAsync(); } }注意这里 FromSqlListAsync 默认加了 AsNoTracking。原因就是前面说的原生 SQL 查出来的实体如果你还继续跟踪万一 SQL 缺列SaveChanges 时可能覆盖数据。加了 AsNoTracking 之后查询结果就是纯粹的只读对象安全很多。3.2 一个真实的报表分页案例光说不练没意思分享一个实际项目里的报表分页需求后台需要按时间范围查看文章列表关联出作者名和分类名还要支持分页并返回总记录数。最开始的写法是用 LINQ 加两层查询一次查总数一次查列表。后来觉得性能不够好改成一条 SQL 搞定用窗口函数COUNT(*) OVER ()在返回分页数据的同时把总数也查出来SELECT a.Id, a.Title, u.Name AS AuthorName, c.Name AS CategoryName, a.CreatedAt, COUNT(*) OVER () AS TotalCount FROM Articles a JOIN Users u ON a.AuthorId u.Id JOIN Categories c ON a.CategoryId c.Id WHERE a.CreatedAt start AND a.CreatedAt end ORDER BY a.CreatedAt DESC OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY对应的 Repository 方法public async TaskArticleReportResult GetArticleReportAsync( DateTime start, DateTime end, int pageIndex, int pageSize, CancellationToken ct default) { var offset (pageIndex - 1) * pageSize; const string sql SELECT a.Id, a.Title, u.Name AS AuthorName, c.Name AS CategoryName, a.CreatedAt, COUNT(*) OVER () AS TotalCount FROM Articles a JOIN Users u ON a.AuthorId u.Id JOIN Categories c ON a.CategoryId c.Id WHERE a.CreatedAt start AND a.CreatedAt end ORDER BY a.CreatedAt DESC OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY ; var parameters new object[] { new SqlParameter(start, start), new SqlParameter(end, end), new SqlParameter(offset, offset), new SqlParameter(pageSize, pageSize) }; var items await SqlQueryListAsyncArticleReportDto(sql, parameters); var total items.FirstOrDefault()?.TotalCount ?? 0; return new ArticleReportResult(items, total); }这里有三个细节值得说。第一SQL Server 的 OFFSET / FETCH 分页语句必须配套 ORDER BY否则直接报错。第二COUNT(*) OVER ()在结果集为空时不返回任何行所以 total 要做空值兜底。第三参数用命名 SqlParameterSQL 文本里直接用start这种情况下不要再用{0}占位符两套风格混用会乱。ArticleReportDto 就非常简单标注一下它的形状保证列名和属性名能对上public class ArticleReportDto { public int Id { get; set; } public string Title { get; set; } public string AuthorName { get; set; } public string CategoryName { get; set; } public DateTime CreatedAt { get; set; } public int TotalCount { get; set; } }3.3 动态表名与参数化的安全边界原生 SQL 最大的安全红线就是 SQL 注入。FromSql / FromSqlInterpolated / SqlQuery 这些基于 FormattableString 的方法会把插值项自动参数化这一点比较安全。真正危险的是 FromSqlRaw 和 SqlQueryRaw它们的 SQL 是你传入的原始字符串一旦里面有字符串拼接风险就是 100%。动态表名和动态排序字段是另一个容易被忽视的坑。前面提过FormattableString 会把表名也参数化导致 SQL 语法错误。所以动态表名只能靠拼接解决而拼接就必须有白名单校验。我一般在代码里写一个允许表名的集合private static readonly HashSetstring AllowedTables new(StringComparer.OrdinalIgnoreCase) { Articles, Users, Categories }; public async TaskListTDto QueryFromTableAsyncTDto( string tableName, DateTime start, DateTime end) where TDto : class { if (!AllowedTables.Contains(tableName)) { throw new ArgumentException($非法表名: {tableName}); } var sql $ SELECT * FROM [{tableName}] WHERE CreatedAt start AND CreatedAt end ; var parameters new object[] { new SqlParameter(start, start), new SqlParameter(end, end) }; return await SqlQueryListAsyncTDto(sql, parameters); }排序字段也是同样处理白名单里只有固定的几个列名用户传来的排序值必须先映射再拼接。值永远走参数化结构永远走白名单这条铁律我写在项目文档第一页。3.4 BaseService 层如何消费Repository 封装好之后Service 层调用就非常清爽。BaseService 的职责是组合多个 Repository 的查询结果做业务校验和组装不直接接触 DbContext更不接触 SQLpublic class ArticleService : BaseServiceArticle { private readonly ArticleRepository _articleRepository; public ArticleService(ArticleRepository articleRepository) : base(articleRepository) { _articleRepository articleRepository; } public async TaskArticleReportResult GetReportAsync( DateTime start, DateTime end, int pageIndex, int pageSize, CancellationToken ct default) { // 业务规则时间范围最大 90 天 if (end - start TimeSpan.FromDays(90)) { throw new BusinessException(报表时间范围不能超过 90 天); } return await _articleRepository.GetArticleReportAsync( start, end, pageIndex, pageSize, ct); } }为什么强调三层边界因为原生 SQL 一旦出现在 Controller 里复用的是“那一段代码”而不是“那一段能力”。你今天在 Controller 写了一个 SQL明天另一个接口要复用基本只能复制粘贴改了这段忘了那段。收进 Repository 后所有调用方都走同一个方法参数校验、性能监控、日志埋点都能集中在仓储层做这才是三层架构的意义。4. 常见问题与排查技巧实录4.1 列名无效最常见错的现场复盘项目里最常遇到的报错是这个The required column CreatedAt was not present in the results from the SQL query.看到这个错误先别急着改代码按顺序排查第一步把 SQL 复制到数据库客户端单独跑一遍看返回的列名到底是什么。第二步对照目标实体或 DTO 的属性名看是否完全一致注意大小写和下划线。第三步检查 SQL 里是不是用了 DISTINCT 或 GROUP BY聚合后的结果集经常会丢掉一些原表列。第四步如果实体上有影子属性或者计算属性EF Core 物化时也可能要求额外列这时候要专门把对应列查出来。举个例子同样是查 Id 和 TitleSQL 里写成SELECT Id, Name AS Title目标对象属性是Title这种情况别名已经处理了一般没问题。但如果写成SELECT Id, Name目标属性是Title那映射结果就是 null而不是报错。报错往往发生在“某列真的是必要列”的时候比如主键缺失。我这里有个习惯SQL 写完先在数据库工具里用结果集的列名和目标对象属性逐一对一遍。这一步 30 秒的事能省掉你半小时的 debug 时间。4.2 追加 LINQ 后行为异常的真相FromSql 支持组合 LINQ不代表着所有 SQL 都能随意组合。EF Core 的组合机制是把你的原生 SQL 包成一个子查询再在外层追加 WHERE / ORDER BY / OFFSET 等。如果你的原生 SQL 里已经带了 ORDER BY组合后外层的排序会覆盖内层排序最后结果可能会和你预期的不一致。SqlQuery 就更要注意它本质是终端查询官方没有承诺支持继续组合。有些人看到Database.SqlQueryT返回的是 IQueryable就在后面顺手加 Where编译能过但运行时的行为可能不符合预期。我的规范是SqlQuery 的 SQL 必须把筛选、排序、分页一次写完拿到的就是最终结果如果还要二次筛选在内存用 LINQ to Object 处理不要让 EF Core 去组合。这不是性能洁癖是明确边界避免排查问题时认知混乱。4.3 存储过程与表值函数的边界EF Core 7.1 之前FromSql 不能直接调用存储过程运行时报错。7.1 之后支持了但有两个默认条件要注意一是存储过程的结果集列名要和目标类型映射匹配二是存储过程的查询结果不能作为子查询继续组合。实际项目中存储过程的调试难度和维护成本都更高我倾向于用普通 SQL 代替存储过程来承载复杂查询。只有在老系统里存储过程已经封装了很重业务逻辑短期内无法迁移时才用 FromSql 调它。表值函数TVF也是类似情况需要在模型里用HasDbFunction注册后才能在 FromSql 里调用。如果你在 DbContext 的 OnModelCreating 里没做过任何 TVF 配置直接调用会报找不到函数。这块我建议项目里统一写一个 DbFunction 的配置类把允许外部调用的 TVF 都集中注册避免散落在各个代码文件里。4.4 性能与跟踪一次没必要的实体跟踪引发的事故前面反复提到跟踪问题这里讲一个真实性能事故。有个报表接口用 FromSqlRaw 查询实体列表返回一万行数据内存占用一直居高不下接口响应时间随数据量线性恶化。排查发现罪魁祸首就是默认跟踪EF Core 给每行实体都创建了状态快照一万行意味着大量的字典存储和比较开销。加一个 AsNoTracking 后内存峰值从 2GB 降到 700MB 左右响应时间也大幅下降。还有一个经典场景是“查询实体只为展示”。这种情况下完全没必要用 FromSql 查实体改成 SqlQuery 直接投影 DTO既省跟踪开销又省字段传输。我的习惯是能 DTO 就 DTO能用 AsNoTracking 就 AsNoTracking实体跟踪只留给真正要写回数据库的查询。最后整理一个速查表方便你排查时对号入座症状可能原因处理方法required column 报错SQL 缺列、列名不匹配、版本低于 7.0补列或加别名确认版本SqlQuery 结果属性全为 nullSQL 列名与属性名不一致使用 AS 别名保持列名一致SaveChanges 后数据被默认值覆盖FromSql 缺列 实体被跟踪加 AsNoTracking或返回全列存储过程调用报错EF Core 版本低于 7.1或列不匹配升级版本检查结果集追加 LINQ 后排序错乱组合打破了子查询的 ORDER BY排序在 SQL 内完成或内存排序动态表名拼接导致语法错误FormattableString 表名被参数化白名单校验 FromSqlRawSQL 中文字符显示乱码连接字符串字符集配置不当检查连接串配置 utf8 等字符集踩过几次坑之后我现在写原生 SQL 的稳定心态是先明确这段查询的结果要走实体还是 DTO再决定用 FromSql 还是 SqlQuery列名一律显式对齐参数一律参数化动态结构一律白名单能用 LINQ 表达的绝不上原生 SQL一旦上了原生 SQL就让它只活在 Repository 层。希望这篇经验总结能帮你把“边界慌”变成“边界清”。