ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Dapper + 原生SQL的CRUD实践:从基础增删改查到事务与性能优化

Dapper + 原生SQL的CRUD实践:从基础增删改查到事务与性能优化

1. 用了一年 Dapper 之后,我把这个原生 SQL 的 CRUD 示例重新理了一遍

前两天翻到之前负责的一个老项目,里面有一套用 VOL 框架搭的基础服务,数据访问层选的是 Dapper,SQL 全部手写,没上任何重量级 ORM。我重新把这套 CRUD 示例完完整整整理了一遍,发现当年很多踩坑的点、参数设计的原因、以及哪些地方看起来“土”但实际特别好用,都值得单独拿出来聊聊。

先说结论:对于中小型系统、内部管理系统、或者团队里 SQL 功底普遍过关的场景,Dapper + 原生 SQL 这套组合,性价比真的高。你不需要学一堆框架概念,不需要配置实体映射关系,写的就是纯 SQL,但又能拿到近乎 ADO.NET 的性能,还自带对象映射的便利。这次我就以这套 VOL__SqlDapper 的实际代码为线索,把从环境准备到增删改查、再到事务和动态 SQL 的完整链路拆开讲清楚。

适合谁看?刚接触 Dapper 想把 CRUD 写规范的新手,被各种 ORM 折腾得想做“减法”的老手,以及需要在团队里快速推广统一数据访问方式的技术负责人。下面不废话,直接进入正题。

2. 项目背景与选型:为什么是 Dapper 而不是 EF Core 或者纯 ADO.NET

2.1 这套示例解决的核心痛点

我记得这个项目启动的时候,团队骨干来自不同技术背景,有人习惯写 SQL,有人觉得 ORM 省心。结果第一周就出现了两种风格并存的混乱情况:一部分接口用 EF Core 跑模型驱动,另一部分又直接用 SqlConnection 手写冗余样板代码——每个查询都要自己写 SqlDataReader 遍历赋值实体,方法体里全是 while(reader.Read()),项目里散落着大量重复的字段映射代码。

VOL__SqlDapper 这个示例要解决的,恰恰就是“中间态”的问题:既要保住原生 SQL 对查询路径的完全可控性,又要砍掉 ADO.NET 里那些纯体力劳动。Dapper 提供的是扩展方法,本质上是把 DataReader 到实体的映射过程封装起来,但 SQL 字符串、参数、连接对象都由你自己掌握。它不是一个框架,不会侵入你的架构,任何时候你都可以回到纯 ADO.NET 去处理极端情况。

2.2 对比 EF Core 和 ADO.NET,Dapper 的取舍在哪里

拿 EF Core 举例,它的优势在于快速开发和自动迁移,但劣势也很明显:复杂的查询最终需要降级到 FromSqlRaw 才能写出高效的 SQL,而且模型状态跟踪在只读场景下是纯开销。Dapper 不一样,它不跟踪实体状态,查出来就是纯 POCO,改完自己写 UPDATE 语句更新,逻辑清楚直接。

拿 ADO.NET 对比,Dapper 最大的功绩是解决了 DataReader 映射的样板代码。你写十万行 SQL 也还是要拼 SQL 和填参数,但至少不用再做手工字段赋值了。另外 Dapper 内部对缓存做了不少优化,动态生成 IL 进行赋值,性能损耗远远小于反射,基本上接近手写 ADO.NET 的极限值。

从团队协作角度看,原生 SQL 还有一个隐性好处:DBA 和运维可以直接看代码里的 SQL 来排查问题瓶颈,不需要会 C#,不需要理解 ORM 的查询生成逻辑。我那会儿配合 DBA 做慢查询优化,直接把仓储层里的 SQL 字符串复制到数据库客户端跑一遍,执行计划一分析,问题在哪都明明白白。这是 ORM 给不了的东西。

2.3 什么时候不建议这么干

说句公道话,如果你做的是快速原型,需要频繁调整表结构并且希望代码追踪这些变更,EF Core 这类工具会省心很多。另外如果团队需要将数据库迁移到多种类型,动态生成 SQL 的 ORM 多少能降低切换成本。但对于核心业务稳定、SQL 技巧过硬、数据库类型固定的小团队而言,Dapper 方案的整体维护成本低得多——前提是大家愿意把 SQL 写规范。

3. 环境准备与仓储基础设计

3.1 依赖包与目录结构

我的环境用的是 .NET 6,数据库是 SQL Server 2019,NuGet 里只需要一个包:

dotnet add package Dapper

还有一个可选包System.Data.SqlClient或者微软推荐的Microsoft.Data.SqlClient,建议直接用后者,更新更活跃。目录结构上,我没有把 SQL 散落在控制器里,而是建了一个明确的仓储层:

VolSample/ ├── Models/ │ └── Product.cs ├── Repository/ │ ├── IProductRepository.cs │ └── ProductRepository.cs ├── Services/ │ └── ProductService.cs └── Controllers/ └── ProductController.cs

控制器只做参数接收和结果返回,业务判断放 Service,数据访问只能在 Repository 层里发生,这样以后换库也好、改表也好,影响面都被控制住了。

3.2 连接字符串管理

连接字符串放在 appsettings.json 里,我相信这是标配,不再赘述。有一点值得提的是:生产环境务必使用配置中心或者环境变量,避免把数据库账号写到源码仓库。很多人觉得“内网没关系”,但这类东西一旦泄露,牵连面会很大,该守的规矩要守住。

仓储基类里,我会放一个 DbContext 之类的封装吗?Dapper 场景下我的答案是:不需要。每个方法内部用using var connection = new SqlConnection(_connectionString);创建连接,用完自动释放。Dapper 的 Query 和 Execute 方法会自动管理连接开关,无需手动 Open/Close。但要注意一点:如果同一个方法里要进行多次数据库操作并且需要保证原子性,那就要手动开启事务,后面专门讲。

3.3 实体模型与表结构的映射约定

Dapper 走的是约定大于配置的路子。让 Dapper 能把查询结果映射到实体,需要字段名保持一致(或使用别名)。比如:

public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int CategoryId { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } }

对应表结构是Product(Id, Name, Price, CategoryId, CreatedAt, UpdatedAt)。这里我强烈建议:数据库字段命名采用 PascalCase 或者通过别名统一成实体的属性风格,减少不必要的映射代码。如果既有表是下划线命名的(比如category_id),SQL 里写别名即可:

SELECT Id, Name, Price, category_id AS CategoryId ...

列名映射是 Dapper 使用中最常见的坑,模板化和复用度高的地方,建议统一规范。

4. 原生 SQL 的 CRUD 核心实现:从基础到参数化细节

4.1 查询列表:不要用 SELECT *,永远指定列名

第一个示例是列表查询。很多人会顺手写SELECT * FROM Product,这在示例代码里可以,但真实项目里不该出现。原因有三:多余的列会增加网络传输和 DataReader 压力;列顺序变化会导致映射意外出错;隐式依赖让后续索引设计难以做覆盖索引优化。

我写的仓储方法如下:

public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(int categoryId) { const string sql = @" SELECT Id, Name, Price, CategoryId, CreatedAt, UpdatedAt FROM Product WHERE CategoryId = @CategoryId ORDER BY CreatedAt DESC"; using var connection = new SqlConnection(_connectionString); return await connection.QueryAsync<Product>(sql, new { CategoryId = categoryId }); }

注意@CategoryId参数:这是 Dapper 好用也危险的地方——你传一个匿名对象,Dapper 会把属性名与 SQL 参数匹配。这个过程是安全的,底层全部通过 SqlParameter 传入,能防止 SQL 注入。代价是:如果你在 SQL 里写@CategoryId,但匿名对象属性名拼错了,Dapper 不会编译报错,只会在运行时抛异常。这类问题排查起来比较烦,建议给仓储层写上基础的单元测试。

4.2 单条查询:使用 QueryFirstOrDefault 而不是 Query 再取第一条

获取详情,要的是单条记录。我见过有人先Query<T>然后.FirstOrDefault(),这在数据量大时白白让 DataReader 多读若干行。正确做法是QueryFirstOrDefaultAsync<T>:

public async Task<Product> GetByIdAsync(int id) { const string sql = @" SELECT Id, Name, Price, CategoryId, CreatedAt, UpdatedAt FROM Product WHERE Id = @Id"; using var connection = new SqlConnection(_connectionString); return await connection.QueryFirstOrDefaultAsync<Product>(sql, new { Id = id }); }

这里补充一个从经验中得来的教训:如果查询结果列存在同名的歧义字段(比如多表 JOIN 时两个表都有 Id),务必在 SQL 中为重复列名加别名,否则 Dapper 会把后一列的值覆盖到前一列,极难察觉。这个问题在 JOIN 场景下很典型,列名冲突的排查成本远比写 SQL 时多花 10 秒起别名要高。

4.3 新增数据:拿到自增主键的三种姿势

Insert 操作如果主键是自增列,业务上往往需要拿回新生成的 Id。这里我实际使用过三种方式,各有适用场景。

方式一,使用 OUTPUT 子句,最直接:

INSERT INTO Product(Name, Price, CategoryId, CreatedAt, UpdatedAt) OUTPUT INSERTED.Id VALUES(@Name, @Price, @CategoryId, @CreatedAt, @UpdatedAt);

配合 Dapper 的ExecuteScalarAsync<int>或者写成QuerySingleAsync<int>即可拿到新 Id。好处是语句原子、返回明确。要注意的是,如果表上有触发器,OUTPUT 子句在部分数据库环境下可能拿不到预期结果,SQL Server 里触发器场景下行为也有差异,这种情况下不建议用 OUTPUT。

方式二,SCOPE_IDENTITY():

INSERT INTO Product(...) VALUES(...); SELECT SCOPE_IDENTITY() AS Id;

这套 SQL 我会把它写在一个字符串里,然后用QuerySingleAsync<int>执行。注意不要用@@IDENTITY,因为它在触发器存在时会返回触发器里生成的身份值,不是当前插入行的值;SCOPE_IDENTITY()更可靠。

方式三,先插入再 UPDATE 一次的复合逻辑就不用多说了,适合数据对象已经包含业务主键(比如订单号)的场景。

我自己主推方式一,简洁、直观、减少一次往返。

4.4 更新操作:显式更新必要字段,并获取受影响行数

更新需要注意的一个问题是:Dapper 的ExecuteAsync返回的是受影响行数,你可以利用这个值判断记录是否存在。我习惯在 Service 层封装业务规则:

public async Task<bool> UpdateProductAsync(Product product) { const string sql = @" UPDATE Product SET Name = @Name, Price = @Price, CategoryId = @CategoryId, UpdatedAt = @UpdatedAt WHERE Id = @Id"; using var connection = new SqlConnection(_connectionString); var rows = await connection.ExecuteAsync(sql, new { product.Name, product.Price, product.CategoryId, product.UpdatedAt, product.Id }); return rows > 0; }

这里有一个小技巧:更新的 SQL 里我只设置需要更新的业务字段,CreatedAt 这种创建字段绝对不允许出现在 SET 子句里。有人为了省事会把所有字段一股脑更新,这导致审计信息丢失、并发冲突概率上升、索引维护成本变大。规范的做法是“按需更新”,同时在 Service 层做字段校验和默认值填充。

4.5 删除操作:软删除优先于物理删除

删除在真实业务系统中,尤其是人员、订单、商品这类数据,不建议直接 DELETE。最稳妥的做法是加一个IsDeleted或者Status字段做软删除。示例里物理删除代码如下:

public async Task<bool> DeleteAsync(int id) { const string sql = @"DELETE FROM Product WHERE Id = @Id"; using var connection = new SqlConnection(_connectionString); return await connection.ExecuteAsync(sql, new { Id = id }) > 0; }

但我在项目里的习惯是:只要有回滚和审计需求,就改成UPDATE ... SET IsDeleted = 1 WHERE Id = @Id,同时所有查询默认带IsDeleted = 0条件。这个决定在发生误删时救过我们一次,值得强调。

4.6 参数化 SQL 的细节:类型、精度不可忽略

参数化并不只是把字符串拼成new { ... }那么简单。decimal 类型需要指定精度时,直接用匿名对象可能让 SQL Server 推断出错。比如Price是decimal(18,2),而传入的是一个 .NET decimal,Dapper 默认发送参数时按decimal处理,通常没有问题。但如果你传进去的属性值为long或int,与数据库字段类型不匹配,可能出现隐式转换带来的额外开销甚至精度丢失。

遇到这类情况,用 DbType 做显式控制:

var parameters = new DynamicParameters(); parameters.Add("Price", product.Price, DbType.Decimal, ParameterDirection.Input, 18, 2);

DynamicParameters是 Dapper 处理复杂参数的基础设施,后面动态 SQL 部分还会频繁用到。这里先记住一个原则:与金额、数量相关的字段,宁可写明细参数也不依赖推断。

5. 进阶功能:事务、动态 SQL 与批量操作

5.1 多表操作如何保证原子性:Dapper + SqlTransaction

平时单表读写没问题,一旦涉及“先插主表,再插明细表,然后更新汇总字段”这种场景,就必须开事务了。Dapper 并不自带事务管理,它需要你手动创建连接、开启事务,然后传给扩展方法。

public async Task<bool> CreateOrderAsync(Order order, List<OrderItem> items) { const string sqlOrder = @" INSERT INTO Orders(CustomerId, TotalAmount, CreatedAt) OUTPUT INSERTED.Id VALUES(@CustomerId, @TotalAmount, @CreatedAt);"; const string sqlItem = @" INSERT INTO OrderItems(OrderId, ProductId, Quantity, Price) VALUES(@OrderId, @ProductId, @Quantity, @Price);"; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); using var transaction = await connection.BeginTransactionAsync(); try { var orderId = await connection.QuerySingleAsync<int>( sqlOrder, new { order.CustomerId, order.TotalAmount, order.CreatedAt }, transaction); foreach (var item in items) { await connection.ExecuteAsync( sqlItem, new { OrderId = orderId, item.ProductId, item.Quantity, item.Price }, transaction); } await transaction.CommitAsync(); return true; } catch { await transaction.RollbackAsync(); throw; } }

这段代码有几点值得注意:

  • using var transaction = await connection.BeginTransactionAsync(),事务处置交给 using。
  • 所有 Dapper 扩展方法都要显式传transaction参数,忘记传时不会报错,但会导致事务不一致,而且极难发现。
  • 更新前捕获异常回滚时,只throw重新抛出,不要吞掉异常,否则日志系统没法记录真实错误。

5.2 动态查询条件:用 StringBuilder 还是 DynamicParameters

实际项目中列表页常常有多个可选筛选条件:按名称模糊搜索、按分类筛选、按价格范围筛选、按时间范围筛选。不能为每个组合写一条 SQL 语句,合理的做法是动态拼接 WHERE 子句。

我的常用模式如下:

public async Task<IEnumerable<Product>> SearchProductsAsync(ProductQuery query) { var sql = new StringBuilder(@" SELECT Id, Name, Price, CategoryId, CreatedAt, UpdatedAt FROM Product WHERE 1 = 1"); var parameters = new DynamicParameters(); if (!string.IsNullOrEmpty(query.Name)) { sql.Append(" AND Name LIKE @Name"); parameters.Add("Name", $"%{query.Name}%"); } if (query.CategoryId.HasValue) { sql.Append(" AND CategoryId = @CategoryId"); parameters.Add("CategoryId", query.CategoryId.Value); } if (query.MinPrice.HasValue) { sql.Append(" AND Price >= @MinPrice"); parameters.Add("MinPrice", query.MinPrice.Value); } sql.Append(" ORDER BY CreatedAt DESC"); using var connection = new SqlConnection(_connectionString); return await connection.QueryAsync<Product>(sql.ToString(), parameters); }

这种写法里 WHERE 后面先写一个永远成立的条件1=1,是为了避免频繁判断“是否第一个条件”的逻辑分支。很多 SQL 爱好者不喜欢这个写法,觉得不够优雅,但它的可维护性极佳:后续每加一个筛选条件,只需要追加一行sql.Append,不会破坏已有逻辑。对于我们这种要长期维护的查询模块,可读性比“炫技”重要得多。

LIKE 参数有一个小坑:%符号要拼进参数值而不是拼进 SQL 字符串。若拼在 SQL 里,Dapper 参数化会把它们当作字面量处理,依然安全,但可读性差一些。数值类型的可选参数用HasValue判断(即 nullable 类型),字符串为空判断用string.IsNullOrEmpty,不要用== ""。

5.3 批量 Insert 的高效方式:UDT(表值参数)还是逐条循环

如果你一次性要插入数十乃至数百行数据,用 foreach 循环调用单行 INSERT 是可以工作的,但性能不具备优势。SQL Server 场景下,表值参数(TVP)是成熟的方案,前提是数据库里定义对应的自定义表类型。示例里做了一个简化的参数表类型:

CREATE TYPE dbo.ProductType AS TABLE ( Name NVARCHAR(100), Price DECIMAL(18,2), CategoryId INT );

C# 需要把列表转成 DataTable,然后通过 StoredProcedure 方式传给 Dapper:

var table = new DataTable(); table.Columns.Add("Name", typeof(string)); table.Columns.Add("Price", typeof(decimal)); table.Columns.Add("CategoryId", typeof(int)); foreach (var item in products) { table.Rows.Add(item.Name, item.Price, item.CategoryId); } var parameters = new DynamicParameters(); parameters.Add("@products", table.AsTableValuedParameter("dbo.ProductType")); await connection.ExecuteAsync("sp_InsertProducts", parameters, commandType: CommandType.StoredProcedure);

这是高性能批量插入的正确打开方式。如果数据库不是 SQL Server(比如 MySQL、PostgreSQL),TVP 不适用,可以改用 MySqlBulkCopy 或 PostgreSQL 的 NpgsqlBinaryImporter。原则是一致的:不要在应用层写循环单条插入大数据集合,数据库批量导入才是归宿。

5.4 ExecuteAsync 和 QueryAsync 的返回类型差异

有人问我 Dapper 里ExecuteAsync有没有办法返回值,答案是它只能返回受影响行数,不能返回结果集。如果你需要“插入后同时取回新主键”,就不能用 ExecuteAsync,要用 QuerySingleAsync 或 ExecuteScalarAsync。这是初学者最容易掉进去的坑之一,写代码之前先把目标定义清楚:返回行数还是返回数据。

6. 常见问题与排查技巧实录

6.1 Dapper 参数无法映射,报 “Object contains no definition for X”

这个报错往往不是没有任何定义,而是传入的匿名对象属性名与 SQL 里的参数名不一致。排查思路很简单:打开项目里对应的 SQL 字符串,逐一对照@后面的名字和传入匿名对象的属性名,注意大小写是否一致(SQL Server 不区分,Dapper 解析时会区分参数键值)。我踩坑最深的一次是_id和Id混用,排查了小半天,最后用断点查看 DynamicParameters 内部模板参数列表才发现问题。这个教训告诉我:写仓储方法时参数命名最好全项目统一,比如全部用实体属性名。

6.2 查询返回 null 而不是空集合

QueryAsync在没有记录时返回的是空集合,还是null?分情况:QueryAsync返回IEnumerable<T>,无命中时是空的List<T>,不会是 null;但QueryFirstOrDefaultAsync无命中时返回 null。因此业务代码里如果直接对 FirstOrDefault 的结果做属性访问,切记先判空。

var product = await _repository.GetByIdAsync(id); if (product is null) { return NotFound(); }

6.3 N+1 查询问题:Dapper 不会帮你自动解决

EF Core 有导航属性可以延迟加载,但 Dapper 完全没有这个概念。所以在写复杂列表接口时,很容易出现先查列表,再循环查每一条子表的 N+1 问题。我在一个报表功能里踩过这个坑:页面加载 200 条主数据,每条去查一次子表,结果接口耗时超过 8 秒。修改方式是把循环查询改成一次 IN 查询,或者使用多结果集一次返回。

Dapper 的QueryMultipleAsync可以处理多结果集的场景:

var sql = @" SELECT Id, Name, Price, CategoryId, CreatedAt, UpdatedAt FROM Product WHERE CategoryId = @CategoryId; SELECT Id, Name FROM Category;"; using var multi = await connection.QueryMultipleAsync(sql, new { CategoryId }); var products = await multi.ReadAsync<Product>(); var categories = await multi.ReadAsync<Category>();

这样一次数据库往返带回多个结果集,性能提升立竿见影。不过要注意:多结果集要求数据库服务器支持,SQL Server 支持多条 SQL 语句一次执行,MySQL 也有类似能力,但某些数据库需要额外配置。

6.4 连接池耗尽的 case 与诊断方法

Dapper 并不会自动帮你 Open/Close 连接,它只是封装了 ADO.NET 的打开关闭过程。如果你在仓储类里定义了SqlConnection为字段,并且每次方法复用而迟迟不释放,连接池很快会耗尽。我的习惯是绝对不在仓储层之外缓存连接对象,每个方法内 using,让连接归还池子。诊断连接池问题时,在 SQL Server 的 DMV 里查sys.dm_exec_sessions或者看性能计数器User Instances,一般能看到堆积的会话。

6.5 SQL 注入风险巡查方法

即使 Dapper 帮你参数化,你仍有可能写出不安全的 SQL。常见问题是忘记参数化表名或列名,比如动态排序字段。研发规范里我要求:所有用户可控的表名、列名、排序字段必须白名单校验,不允许直接拼进 SQL。参数化能解决值注入问题,但不能解决标识符注入问题,这需要靠代码审查兜底。

我个人的巡检方法是:在仓储层搜一下string.Format、$"..."这类字符串拼接符号,凡是 SQL 语句里出现拼接的,逐行确认拼接内容是否来自枚举、常量或者白名单纯判断后的结果。这套方法在团队 Code Review 里实施之后,SQL 注入风险基本就杜绝了。

6.6 Dapper 的缓存:实体映射为何第一次慢

Dapper 内部对实体的映射做了动态 IL 缓存,首次查询时需要编译生成映射函数,所以“冷启动”会慢一点点。如果你在高并发上线前,预热机制做得不够,可能第一波请求比较慢。解决方式是在应用启动时将常用仓储方法触发一遍,让缓存生效。这个细节在纯 ORM 里不一定有,但在 Dapper 方案里需要注意。

7. 性能优化的几个实践经验

7.1 查询不要返回不需要的列和行

这个建议看似老生常谈,但真到场景里,随手SELECT *的人还是很多。尤其是内部工具类页面,数据量一旦上来,IO 和带宽的消耗极其直观。列表接口建议只返回当前页面需要的列,详情接口再返回完整字段。不要试图用一个接口通吃列表和详情。

7.2 用 ExecuteRowCount 或监视计划获取真实消耗

如果你确认某一个查询变慢了,第一步是通过数据库客户端查看执行计划,找到缺失索引或隐式转换。Dapper 不会帮你做查询优化,它只保证你不被框架性能所拖累,SQL 本身的优化责任始终在开发者肩上。我的做法是为高频查询建立覆盖索引,并定期抽查慢查询日志。

示例里的 Product 表,CategoryId + CreatedAt是查询高频条件,我就建了对应的索引。这个优化动作对接口时延的改善,远比纠结 ORM 映射性能明显。

7.3 分页查询的 SQL 写法

SQL Server 分页我推荐OFFSET / FETCH语法:

SELECT Id, Name, Price, CategoryId, CreatedAt FROM Product ORDER BY CreatedAt DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

Dapper 里参数绑定直接传Offset和PageSize。老项目里有人还在用 ROW_NUMBER() OVER (ORDER BY ...) 做分页,也能运行,但 OFFSET/FETCH 可读性更好。要注意的是 ORDER BY 必须存在且唯一性足够,否则分页会出现重复或遗漏数据。

7.4 使用异步方法避免线程池饥饿

中国互联网公司的服务端基本都要扛并发,Dapper 提供了完整的异步 API 集合。即便你的业务逻辑是 CPU 密集,数据库 IO 等待期间把线程让出去总是有价值的选择。我见过有人在 async 接口里调用.Result阻塞等待,这是大忌。仓储层全部使用Async后缀方法,Service 层层层 async/await 传递,Controller 返回 Task,确保端到端异步。

7.5 连接字符串里的关键选项

连接字符串里Pooling=True(默认开启)配合Max Pool Size=100这种配置非常重要。有一回生产环境在高峰期频繁报“连接池超时”,排查后发现有人手动把Max Pool Size改成了 5。这个配置极度影响并发上限,建议事前就设置一个与实例规格匹配的值,而不是依赖默认值。另一个选项MultipleActiveResultSets=True在需要同时开多个 DataReader 时很有用,但也可能掩盖连接管理上的坏味道,谨慎开启。

8. 这套示例后续还可以怎么扩展

整理完这套 CRUD 示例之后,我发现后续扩展空间还很大。比如引入仓储基类,把公共的 CRUD 方法抽取成泛型接口,可以进一步减少重复代码;再比如配合数据库迁移工具管理表结构版本,让原生 SQL 脚本也纳入版本控制。

另外我在考虑把查询对象标准化:所有列表接口都定义专门的条件入参对象,并在仓储层统一处理排序、分页、字段过滤,这样以后如果要接缓存中间层,改动的边界会清晰很多。

不过这个改造要控制复杂度,不能让简单方案变成过度设计。我觉得 Dapper 最大的魅力在于它没有强加任何架构理念,允许你以最简单的方式把逻辑表达出来。保持这个状态,后续怎么扩展都不会被框架拖住。

9. 收尾:我实操中的一些心得

这套 VOL__SqlDapper 示例里,最让我觉得有价值的地方,不是代码本身写得有多炫,而是它验证了一个简单理念:数据访问层不需要复杂的框架,只需要一条清晰的路线和一致的规范。团队里只要统一“SQL 手写 + Dapper 映射 + 仓储分层”这套约定,新成员上手速度明显快于让他们去啃 ORM 的文档。

我的个人体会是,用过 Dapper 之后会对数据库有更强的掌控感——因为你必须知道自己的表结构、索引和 SQL 查询逻辑,而不是把这一切交给框架去猜。它迫使你去理解数据存储的本质,这是框架给不了的成长。

最后分享一个小技巧:给仓储层方法命名时,尽量把意图写进方法名里,比如GetProductByIdAsync就比QueryProductAsync让人更省心。数据访问层的方法名直接映射到业务场景,长期维护的人会感谢你的。

如果你正在写原生 SQL 但苦于映射样板代码的折磨,或者你正被各种 ORM 的“黑盒”折腾得焦头烂额,建议花一个下午把 Dapper 这套 CRUD 跑一遍,然后把示例里的规范吸收到自己的项目里,实测下来会很稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进