数据查询进阶(上):作者热度榜报表与 Dapper 的“可控查询”
哈喽,我是黑棠
在CRUD中,通用仓储IRepository<T>+ EF Core 的查询表达式,足以覆盖列表、详情、简单筛选。
但在“报表类查询”里,常见的痛点不是写不出来,而是不可控:SQL 形态难以预测、性能难以定位、规则容易被复制粘贴到多个入口。
这一章用一个更贴近业务的例子,说明什么时候应该把查询从 LINQ 世界里抽离出来,收敛到自定义仓储,用 Dapper 做“可审计、可调优、可复用”的实现。
一、报表需求:作者热度榜
假设运营侧要一个“作者热度榜”页面,用于观察某个定价区间里哪些作者更活跃。页面展示字段如下:
- 作者:作者名
- 作品:作品A / 作品B / 作品C(展示最近 3 本)
- 单价区间:20-50¥(作为筛选条件,同时在报表上显示)
- 最新作品:作品C
热度的口径在这里先简化为:指定价格区间内,该作者作品数量越多越“热”。口径看似简单,但它天然会牵扯跨表 JOIN、聚合、TopN、二次取数(拿到作者集合后再取作品明细)等形态,已经超出了“标准 CRUD”那套查询模式。
二、仓储的边界:把查询当成可演进的契约
在 DDD 语境里,仓储不仅是“封装数据库访问”,更关键的是把查询能力做成领域的接口契约,避免 SQL/表名/数据库语法扩散到应用层。
当你需要满足下面任意一种特征时,自定义仓储通常比在应用服务里拼 LINQ 更稳定:
- 查询跨聚合或明显偏离 CRUD(报表、统计、窗口函数/CTE、TopN)
- 查询只读且高频,希望执行计划和索引策略可控
- 查询需要数据库特性(聚合函数、JSON、全文检索等)
三、实现路径:Dapper + ABP 工作单元
目标是两点同时成立:
- 复杂查询写成可控的 SQL(便于分析、压测和调优)
- 连接与事务仍然复用 ABP/EF Core 的工作单元边界(不“偷跑”到另一条连接上)
1. 定义报表返回模型
把“报表返回形态”定义成一个明确的模型(这是查询契约的一部分)。
Lecture 07/src/Acme.BookStore.Domain/Books/AuthorHotRankItem.cs:
publicclassAuthorHotRankItem{publicGuidAuthorId{get;set;}publicstringAuthorName{get;set;}=string.Empty;publicstringWorks{get;set;}=string.Empty;publicstringLatestWork{get;set;}=string.Empty;}2. 扩展领域仓储接口
Lecture 07/src/Acme.BookStore.Domain/Books/IBookRepository.cs:
publicinterfaceIBookRepository:IRepository<Book,Guid>{Task<List<Book>>GetListByPriceAsync(floatminPrice,floatmaxPrice);Task<List<AuthorHotRankItem>>GetAuthorHotRankAsync(floatminPrice,floatmaxPrice,inttop,intmaxWorksPerAuthor);}3. 在 EfCoreRepository 中用 Dapper 实现报表查询
这个报表很容易写成循环查询“先查作者 TopN,再逐个作者查作品”的形式,但那会把查询次数放大为 1 + N(下一章会专门拆解这个坑)。
这里采用两段式查询:
- 先用聚合拿到 TopN 作者(只返回 AuthorId)
- 再一次性拉取这些作者在区间内的作品明细,在内存里组装“作品列表 + 最新作品”
Lecture 07/src/Acme.BookStore.EntityFrameworkCore/Books/EfCoreBookRepository.cs:
publicasyncTask<List<AuthorHotRankItem>>GetAuthorHotRankAsync(floatminPrice,floatmaxPrice,inttop,intmaxWorksPerAuthor){vardbContext=awaitGetDbContextAsync();varconnection=dbContext.Database.GetDbConnection();vartransaction=dbContext.Database.CurrentTransaction?.GetDbTransaction();vartopAuthorsSql=""" SELECT b."AuthorId"AS"AuthorId",COUNT(*)AS"BookCount",MAX(b."PublishDate")AS"LatestPublishDate"FROM"AppBooks"b WHERE b."Price">=@MinPrice AND b."Price"<=@MaxPrice GROUP BY b."AuthorId"ORDER BY"BookCount"DESC,"LatestPublishDate"DESC LIMIT @Top""";vartopAuthors=(awaitconnection.QueryAsync<TopAuthorRow>(topAuthorsSql,new{MinPrice=minPrice,MaxPrice=maxPrice,Top=top},transaction:transaction)).AsList();if(topAuthors.Count==0){return[];}varauthorIds=topAuthors.Select(x=>x.AuthorId).ToArray();varrankByAuthorId=topAuthors.Select((x,index)=>new{x.AuthorId,index}).ToDictionary(x=>x.AuthorId,x=>x.index);vardetailsSql=""" SELECT a."Id"AS"AuthorId",a."Name"AS"AuthorName",b."Name"AS"BookName",b."PublishDate"AS"PublishDate"FROM"AppAuthors"a INNER JOIN"AppBooks"b ON b."AuthorId"=a."Id"WHERE b."Price">=@MinPrice AND b."Price"<=@MaxPrice AND a."Id"=ANY(@AuthorIds)ORDER BY a."Id",b."PublishDate"DESC,b."Name"""";vardetails=(awaitconnection.QueryAsync<AuthorBookRow>(detailsSql,new{MinPrice=minPrice,MaxPrice=maxPrice,AuthorIds=authorIds},transaction:transaction)).AsList();varresult=details.GroupBy(x=>new{x.AuthorId,x.AuthorName}).Select(g=>{varorderedBooks=g.OrderByDescending(x=>x.PublishDate).ThenBy(x=>x.BookName).ToList();returnnewAuthorHotRankItem{AuthorId=g.Key.AuthorId,AuthorName=g.Key.AuthorName,LatestWork=orderedBooks.FirstOrDefault()?.BookName??string.Empty,Works=string.Join(" / ",orderedBooks.Select(x=>x.BookName).Take(maxWorksPerAuthor))};}).OrderBy(x=>rankByAuthorId[x.AuthorId]).ToList();returnresult;}这段实现的关键点是:连接来自DbContext.Database.GetDbConnection(),事务来自CurrentTransaction,因此不会破坏工作单元边界。
为了让示例更聚焦在“查询形态”,上面的代码省略了TopAuthorRow/AuthorBookRow这两个用于 Dapper 映射的内部类型;在仓储实现里它们只是承载AuthorId/BookCount/PublishDate等字段的简单模型。
如果你把这条查询投入生产,通常还需要补齐两件事:
- 索引策略:至少保证
AppBooks(Price)、AppBooks(AuthorId, PublishDate)这类组合索引覆盖筛选与排序方向,否则 TopN 聚合容易退化成全表扫描。 - 方言细节:本示例基于 PostgreSQL,
ANY(@AuthorIds)能把参数数组下推到数据库侧;如果换成 SQL Server/MySQL,需要把“IN 列表”写法和参数绑定方式一起调整。
4. 提供应用服务接口
在应用层,我们把报表封装成一个明确的 API:输入是价格区间与 TopN,输出是“作者热度榜”的展示字段。
Lecture 07/src/Acme.BookStore.Application.Contracts/Books/GetAuthorHotRankInput.cs:
publicclassGetAuthorHotRankInput{[Range(0,double.MaxValue)]publicfloatMinPrice{get;set;}=20;[Range(0,double.MaxValue)]publicfloatMaxPrice{get;set;}=50;[Range(1,1000)]publicintTop{get;set;}=10;[Range(1,20)]publicintMaxWorksPerAuthor{get;set;}=3;}Lecture 07/src/Acme.BookStore.Application.Contracts/Books/AuthorHotRankDto.cs:
publicclassAuthorHotRankDto{publicstringAuthorName{get;set;}=string.Empty;publicstringWorks{get;set;}=string.Empty;publicstringPriceRange{get;set;}=string.Empty;publicstringLatestWork{get;set;}=string.Empty;}Lecture 07/src/Acme.BookStore.Application/Books/BookAppService.cs(节选):
publicasyncTask<ListResultDto<AuthorHotRankDto>>GetAuthorHotRankAsync(GetAuthorHotRankInputinput){if(input.MaxPrice<input.MinPrice){thrownewUserFriendlyException("MaxPrice must be greater than or equal to MinPrice.");}varitems=await_bookRepository.GetAuthorHotRankAsync(input.MinPrice,input.MaxPrice,input.Top,input.MaxWorksPerAuthor);varpriceRange=$"{input.MinPrice:0.##}-{input.MaxPrice:0.##}¥";vardtos=items.Select(x=>newAuthorHotRankDto{AuthorName=x.AuthorName,Works=x.Works,LatestWork=x.LatestWork,PriceRange=priceRange}).ToList();returnnewListResultDto<AuthorHotRankDto>(dtos);}四、把“报表查询”做成可维护资产
把报表放进仓储,不是为了“追求 Dapper”,而是为了把它当成一种可维护资产:
- SQL 可见且可审计,便于用 EXPLAIN/慢查询日志定位问题
- 接口契约稳定,规则变更集中在一处(而不是散落在多个应用服务里)
- 你可以围绕这条查询做更工程化的治理:索引、缓存、压测基线、回归用例
下一章会继续用这个报表,讨论两个更容易被忽略的问题:规则复用(规约)与 N+1(查询次数失控)。
本文首发于CSDN:[黑棠会长],转载请注明来源。
关注我,一起用轻松的方式读懂前沿科技。