做 EF Core 一段时间后,很多人都会遇到同一个节点:常规 LINQ 能覆盖大多数查询,但一到复杂报表、视图或者历史 SQL 复用场景,就会开始考虑原生 SQL。问题不在于“能不能写 SQL”,而在于怎么写得可维护、可观测、还能和 EF Core 的映射体系配合好。这篇文章讲解 FromSql、SqlQuery 的使用边界和对象映射的一些坑。
1. 问题背景:为什么原生 SQL 常常能跑但难以长期维护
在系统演进到中后期后,下面这些场景非常常见:
- 报表查询需要 GROUP BY、聚合,LINQ 写出来可读性很差。
- 历史系统已经有稳定 SQL,需要在新服务里复用。
- 部分查询要精确控制执行计划,团队希望直接落 SQL。
这时候“能跑”的版本通常很快就能写出来,但过一段时间就会暴露问题:
- SQL 拼接字符串,参数化做得不一致,埋下注入和计划污染风险。
- 映射类型定义不清晰,字段一改名就出现运行时映射异常。
- 查询读模型和实体模型混用,导致跟踪行为和更新语义变得混乱。
- 慢 SQL 能看到语句,但定位不到具体业务查询意图。
所以这篇不是教你“怎么在 EF Core 里执行 SQL”,而是讲“如何把原生 SQL 纳入 EF Core 的工程边界”。
2. 原理解析:先分清 FromSql 和 SqlQuery 的职责
FromSql 和 SqlQuery 都能执行原生 SQL,但它们解决的是不同问题。
2.1 FromSql:面向 DbSet 的查询入口
FromSql 适合挂在 DbSet 或 Set() 上执行原生 SQL,典型用途有两类:
- 查询实体类型(可跟踪,也可 AsNoTracking)
- 查询 Keyless 读模型(只读投影)
关键点:
- 优先用参数化写法(如 FromSqlInterpolated),不要拼接原始字符串。
- SQL 返回列要和映射类型属性一致,否则会在运行时出错。
- 若用于纯读场景,建议显式 AsNoTracking() 降低跟踪开销。
2.2 SqlQuery:面向轻量读模型和标量结果
Database.SqlQuery 更适合“读多写少”的轻量查询:
- 直接映射到 DTO
- 执行标量统计(如 count/sum)
它的定位就是查询,不承担实体生命周期管理。对报表、后台统计、运营看板这类读路径很实用。
2.3 对象映射边界的核心原则
无论用哪种方式,建议固定三条原则:
- 命令边界:原生 SQL 负责读模型查询,不直接承载复杂写入事务语义。
- 模型边界:实体模型和报表 DTO 分离,避免查询模型反向污染领域模型。
- 可观测边界:关键查询用 TagWith 标注业务意图,便于慢 SQL 排障。
3. 示例代码:从危险写法到可维护落地
3.1 问题写法:字符串拼 SQL + 实体/读模型混用
- public async Task<List<Order>> SearchOrdersAsync(string keyword, CancellationToken ct)
- {
- var sql = $"""
- SELECT *
- FROM Orders
- WHERE CustomerName LIKE '%{keyword}%'
- """;
- return await _db.Orders
- .FromSqlRaw(sql)
- .ToListAsync(ct);
- }
复制代码 这段代码的风险很集中:
- 直接拼接字符串,参数化缺失。
- SELECT * 对列变化非常敏感。
- 查询语义是“搜索结果”,却直接映射实体,后续容易和更新流程耦合。
3.2 优化写法一:FromSql + Keyless 读模型承接报表查询
先定义读模型:- public sealed class MonthlyTopCustomerRow
- {
- public string CustomerNo { get; set; } = string.Empty;
- public decimal TotalAmount { get; set; }
- public int OrderCount { get; set; }
- }
复制代码 在 OnModelCreating 中声明 Keyless:- modelBuilder.Entity<MonthlyTopCustomerRow>().HasNoKey();
复制代码 查询代码:- public async Task<List<MonthlyTopCustomerRow>> GetTopCustomersAsync(
- DateTime monthStart,
- DateTime monthEnd,
- CancellationToken ct)
- {
- return await _db.Set<MonthlyTopCustomerRow>()
- .FromSqlInterpolated($"""
- SELECT
- o.CustomerNo,
- SUM(o.TotalAmount) AS TotalAmount,
- COUNT(1) AS OrderCount
- FROM Orders o
- WHERE o.CreatedAt >= {monthStart} AND o.CreatedAt < {monthEnd}
- GROUP BY o.CustomerNo
- """)
- .TagWith("Report:MonthlyTopCustomers")
- .AsNoTracking()
- .OrderByDescending(x => x.TotalAmount)
- .Take(20)
- .ToListAsync(ct);
- }
复制代码 这套写法把“报表查询”明确限定在读模型上,不和实体跟踪语义混在一起。
3.3 优化写法二:SqlQuery 映射 DTO 与标量统计
定义 DTO:- public sealed class OrderRevenueDto
- {
- public long OrderId { get; set; }
- public string OrderNo { get; set; } = string.Empty;
- public decimal Revenue { get; set; }
- }
复制代码 查询 DTO:- public async Task<List<OrderRevenueDto>> GetPaidOrderRevenueAsync(CancellationToken ct)
- {
- var paid = 2;
- return await _db.Database
- .SqlQuery<OrderRevenueDto>($"""
- SELECT
- o.Id AS OrderId,
- o.OrderNo,
- SUM(i.LineAmount) AS Revenue
- FROM Orders o
- INNER JOIN OrderItems i ON i.OrderId = o.Id
- WHERE o.Status = {paid}
- GROUP BY o.Id, o.OrderNo
- """)
- .ToListAsync(ct);
- }
复制代码 查询标量:- public async Task<int> GetPendingOrderCountAsync(CancellationToken ct)
- {
- var pending = 1;
- return await _db.Database
- .SqlQuery<int>($"""
- SELECT COUNT(1)
- FROM Orders
- WHERE Status = {pending}
- """)
- .SingleAsync(ct);
- }
复制代码 3.4 最容易踩的映射坑
- 列名不一致:DTO 属性和 SQL 别名不一致会导致映射失败或值错位。
- 可空性不匹配:数据库可空列映射到不可空属性,运行时容易报错。
- 类型边界不清:例如数据库 bigint 映射到 int,高位数据会溢出。
- 把读模型当实体:查询 DTO 后又尝试走 SaveChanges,语义会混乱。
4. 总结
在 EF Core 里使用原生 SQL 的关键,不是“写不写 SQL”,而是“把 SQL 放在正确边界”。FromSql 更适合承接 DbSet 维度查询,SqlQuery 更适合轻量 DTO 和标量统计。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |