Dapper 这个库,说老实话,在我接触过的 C# 类库里算是比较特殊的一个。它没有 EF Core 那样复杂庞大的上下文模型,也没有 ADO.NET 那样原始繁琐的样板代码,它更像是夹在两者之间的一个轻量级“工具人”。很多刚接触 C# 的开发者可能都听过这个名字,也看过几行示例代码,但真正要把它用到自己的项目里,尤其是上位机、工控、后台服务这类场景,就会遇到一堆文档里没写明白的细节。
这些年我用 Dapper 写过不少数据库访问层,从早期的单体应用到现在的一些服务端程序,踩过的坑不少,但用顺手之后,确实很难再退回纯手写 ADO.NET 的日子。这篇文章就当是把我这几年的实战经验做个系统梳理,从基础 API 到进阶玩法,再结合 C# 上位机项目里常见的取数写库场景来讲,尽量把那些“为什么这么做”的逻辑讲透。
1. Dapper到底是什么,为什么我还在用这个“老家伙”
1.1 从一条SQL到一个对象的距离
如果你用过原生 ADO.NET 写数据访问,应该对下面这段代码不会陌生:先创建 SqlConnection,再创建 SqlCommand,然后设置 CommandType,往 Parameters 里一个个加参数,最后还要手动用 SqlDataReader 一行行读取,再用索引器取值、强转类型、塞进对象里。
using (var conn = new SqlConnection(connString)) { conn.Open(); var cmd = new SqlCommand("SELECT Id, Name FROM Users WHERE Age > @age", conn); cmd.Parameters.AddWithValue("@age", 18); using (var reader = cmd.ExecuteReader()) { var list = new List<User>(); while (reader.Read()) { list.Add(new User { Id = reader.GetInt32(0), Name = reader.GetString(1) }); } return list; } }这段代码的逻辑不复杂,但写起来确实繁琐。每一张表、每一个查询,都得手动做“列到属性”的映射,一旦字段多了,代码就变成了一堆枯燥的机械操作。写多了不仅容易出错,维护起来也痛苦,加一个字段要改好几处。
Dapper 的定位很简单:它帮你把连接管理、命令执行、参数绑定、结果集映射这些“重复劳动”封装起来,让你用最少的代码完成同样的工作。同样是上面这个查询,用 Dapper 写就是一行:
using (var conn = new SqlConnection(connString)) { var list = conn.Query<User>("SELECT Id, Name FROM Users WHERE Age > @age", new { age = 18 }).ToList(); }它确实没有发明什么新概念,也没有自己的查询语言,所有 SQL 还是你熟悉的原生 SQL,所以学习成本很低。正因如此,很多人在项目里放着 EF Core 不用,偏偏选这个“老家伙”,就是看中了它这种“贴地飞行”的感觉。
1.2 Dapper、EF Core、ADO.NET,怎么选
这三者的关系,其实可以用一句话概括:ADO.NET 是自行车,Dapper 是摩托车,EF Core 是汽车。自行车什么都能跑,但是费腿;摩托车灵活轻快,大部分路况都能应付,但需要你自己握好方向;汽车什么都有,空调、导航、安全气囊齐全,但光加油和保养就够你喝一壶。
具体来说,我的选择标准是这样的:
- 如果项目里只有几张表、几条查询,或者底层数据库结构经常变,Dapper 非常合适,因为它不维护模型映射关系,数据库结构变了只要改 SQL。
- 如果项目比较复杂,有大量实体关系、需要 Code First 迁移、想省掉大多数 SQL 编写,那 EF Core 值得选,它的 Linq 表达式树和状态跟踪能省不少事。
- 如果项目对性能极敏感,已经压到了毫秒级,那可能还是要老老实实写好原生 ADO.NET,或者干脆用 Dapper 的底层扩展自己控制一切。
Dapper 还有一个经常被忽视的优势——它本身是开源项目,源码不大,依赖也少,出了问题你可以直接看源码排查。这在排查性能瓶颈或诡异异常时,远比对着黑盒框架瞎猜要有效率。
2. 五分钟跑通Dapper基础三件套
2.1 安装与命名空间
使用 Dapper 只需要两步:安装 NuGet 包,然后引入命名空间。
在 Visual Studio 的“管理 NuGet 程序包”里搜索Dapper,安装最新稳定版即可。如果你喜欢用命令,也可以这样:
dotnet add package DapperDapper 实际上是一组扩展方法,作用在IDbConnection接口上。这意味着你不需要改变原有的连接对象类型,SqlConnection、OracleConnection、MySqlConnection 都可以用同一套 API。引入命名空间后,连接对象上会自动出现 Query、Execute 这些扩展方法:
using Dapper; using System.Data.SqlClient;有一点需要注意,Dapper 本身不负责创建连接,也不管理连接字符串,它只负责“扩展”你的连接对象。连接怎么打开、什么时候打开,仍然是你自己决定的。如果你用惯了 EF Core,这个转变可能需要适应一下。
2.2 Query:把查询结果映射成对象
Query 是 Dapper 里使用频率最高的方法。它的作用很简单:执行一条 SQL,把返回的每一行数据映射成指定类型的对象。
public class DeviceInfo { public int Id { get; set; } public string DeviceName { get; set; } public string IPAddress { get; set; } public DateTime LastOnlineTime { get; set; } } var list = conn.Query<DeviceInfo>("SELECT * FROM DeviceInfo WHERE IsOnline = 1").ToList();这看起来平平无奇,但背后有几个关键细节,新手很容易栽跟头。
第一,列名和属性名的映射默认是“不区分大小写、忽略下划线差异”的。也就是说,如果数据库列名是DEVICE_NAME,而你的属性是DeviceName,Dapper 能自动对上。但如果你用了一个完全没有对应关系的列名,比如Device_IP映射到IPAddress,Dapper 就不会帮你做词义推断,它只会按名称精确匹配,对不上就保持默认值。
第二,如果你只关心部分字段,不需要把整个实体类都映射出来,直接查普通对象就行。比如你只需要设备名和 IP,可以定义一个轻量类,或者用匿名类型conn.Query("SELECT DeviceName FROM DeviceInfo"),返回的就是动态类型集合。这在写临时统计报表时非常方便,不用为每一次查询都定义一个类。
第三,如果查询结果有百万行,你要小心ToList()的内存开销。Dapper 的 Query 方法默认是一次性把所有结果读出来填充列表的,对于超大结果集,更好的做法是用QueryBuffered: false参数让它变成流式读取。
2.3 QueryFirst / QuerySingle / Execute 到底差在哪
除了 Query,Dapper 还提供了几个语义不同的方法,很多人分不清什么时候用哪个,这里我直接说结论。
QueryFirst<T>:返回第一行,哪怕结果集有多行也只取第一行。适合类似“找出最新一条报警记录”的场景。QuerySingle<T>:返回唯一一行,如果结果集为空或者多于一行,都会抛异常。适合类似“根据主键查数据”的场景。QueryFirstOrDefault<T>:返回第一行,没有结果时返回默认值 null。最常用,因为大多数查询你都希望“查不到就算了”。QuerySingleOrDefault<T>:返回唯一一行,没有结果返回默认值,多于一行业抛异常。
我把这四个方法的区别整理成一张表,平时记不清了就直接查:
| 方法 | 空结果 | 多于一行 | 适合场景 |
|---|---|---|---|
| QueryFirst | 抛异常 | 取第一行 | 必须要有数据的最新记录 |
| QueryFirstOrDefault | 返回 null | 取第一行 | 查不到就算了 |
| QuerySingle | 抛异常 | 抛异常 | 主键查询、唯一约束 |
| QuerySingleOrDefault | 返回 null | 抛异常 | 查存在性、避免重复 |
Execute 则又不同,它不返回结果集,只返回受影响的行数。插入、更新、删除操作都用它:
var affectedRows = conn.Execute( "UPDATE DeviceInfo SET LastOnlineTime = @now WHERE Id = @id", new { now = DateTime.Now, id = 1 });这里affectedRows就是实际影响的行数,可以用来判断操作是否成功,比一概而论地返回 true/false 要更有意义。
3. 参数化与动态SQL:Dapper的灵魂所在
3.1 拼接字符串的代价,参数化解决什么
在 C# 开发里,最常见的坏习惯就是直接拼接 SQL 字符串:
var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";这样做最直接的问题是 SQL 注入。用户输入一个' OR '1'='1就能把整个表带出来,这在高并发的公网服务里是致命的。但我们也要承认一个现实:很多上位机项目、企业内部工具,数据库根本不暴露到公网,于是有人觉得“我内部系统,谁没事注入我啊”。这种想法在单机工控机上确实风险没那么大,但一旦你的程序需要接收网络数据、需要和第三方系统对接,输入就不可信了,别堵运气。
参数化还有一个很多人没意识到的好处——它就是给数据库引擎的“预编译”信号。SQL Server 在执行带参数的语句时,会缓存执行计划。如果你总是拼接字符串,数据库每次都要重新解析、编译 SQL,性能差异在高频操作下会非常明显。
Dapper 对参数化的支持非常自然。它支持两种常见的传参方式:匿名对象和 DynamicParameters。
3.2 匿名对象参数与 DynamicParameters 实战
匿名对象是最简单的用法:
var user = conn.QueryFirstOrDefault<User>( "SELECT * FROM Users WHERE Id = @id", new { id = 42 });Dapper 会把匿名对象的属性名和 SQL 里的参数名做匹配,会自动识别@id、:id、?id这些不同数据库的参数占位符。SQL Server 用@,Oracle 用:或?,Dapper 都帮你处理好了。
但匿名对象有个局限性——它只能传静态的属性。如果你要构建一个“可选条件”的查询,也就是用户不填姓名就不查姓名,填了才查,匿名对象就不够灵活了。这时候要用 DynamicParameters:
var dp = new DynamicParameters(); dp.Add("@name", name, DbType.String, ParameterDirection.Input); dp.Add("@minAge", minAge, DbType.Int32); if (isOnline.HasValue) { dp.Add("@isOnline", isOnline.Value, DbType.Boolean); } var sql = "SELECT * FROM Users WHERE 1=1"; if (!string.IsNullOrEmpty(name)) sql += " AND Name = @name"; if (minAge.HasValue) sql += " AND Age >= @minAge"; if (isOnline.HasValue) sql += " AND IsOnline = @isOnline"; var list = conn.Query<User>(sql, dp).ToList();这里需要注意WHERE 1=1只是一个占位技巧,让后面的AND可以安心追加。性能上其实没有多少影响,SQL Server 的优化器会处理掉这个常量条件。
DynamicParameters 更强大的地方在于它可以定义参数的 DbType、方向(输入/输出/返回值),这在调用存储过程时几乎是必须的。后面我会专门讲到。
3.3 批量操作与 List 展开的小技巧
批量插入是 Dapper 用得最频繁但坑也最多的场景。网上很多教程会告诉你 Dapper 支持这样写:
conn.Execute("INSERT INTO DeviceInfo(DeviceName) VALUES(@DeviceName)", deviceList);这个写法确实会把 List 当作一个参数集合传给方法,Dapper 内部会对这个列表逐条执行 SQL。它的好处是代码干净,坏处是——如果列表很大(比如上万条),它会一条一条地执行,性能其实不佳,而且一条失败不会自动回滚。
我自己处理大量数据时更喜欢用一个更直接的办法:把参数展开成多个值组。Dapper 对 List 参数的一个内置支持是把它自动展开成 IN 子句:
var ids = new List<int> { 1, 2, 3, 4, 5 }; var list = conn.Query<DeviceInfo>( "SELECT * FROM DeviceInfo WHERE Id IN @ids", new { ids }).ToList();注意这里的写法,IN @ids没有括号,Dapper 会自动把 List 展开成IN (1,2,3,4,5)。这是 Dapper 一个非常贴心的设计。如果列表很长,你要注意 SQL 参数个数限制(SQL Server 默认是 2100 个参数),一般来说超过这个数就要分批处理了。
批量插入数据量较大时,我个人推荐用表值参数(TVP)或者 SqlBulkCopy,这已经超出了标准 Dapper 的范畴,但可以通过 Dapper 的扩展方法来实现,后面会提。
4. 进阶玩法:事务、多结果集、存储过程与异步
4.1 事务与工作单元:让数据不半路丢
开发上位机或者后台服务时,遇到“先写主表,再写明细表”的流程很常见。比如一条报警记录,既要写报警主表,又要写日志表,还要更新设备状态。中间任何一步失败,数据就不一致了。
Dapper 本身不提供自动事务,但它支持在事务上下文中执行查询,做法是显式创建事务:
using (var conn = new SqlConnection(connString)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { conn.Execute( "INSERT INTO AlarmLog(DeviceId, Message) VALUES(@DeviceId, @Message)", new { DeviceId = 1, Message = "CPU温度过高" }, tx); conn.Execute( "UPDATE DeviceInfo SET AlarmCount = AlarmCount + 1 WHERE Id = @DeviceId", new { DeviceId = 1 }, tx); tx.Commit(); } catch { tx.Rollback(); throw; } } }关键点在于,Dapper 的所有扩展方法都有三个参数的重载:SQL、参数对象、事务对象。传入事务对象之后,这些 SQL 就会在同一个数据库连接和事务范围内执行。有些新手会忘记传第三个参数 tx,这样 SQL 虽然执行成功了,但它跑在独立上下文里,事务提交后不一定能保证一致性,这就是“看起来成功了但数据不对”的典型原因。
4.2 一次查询拿回多份数据:QueryMultiple
有些页面需要同时显示设备列表、报警列表、操作日志,如果分开三次查询,就要开三次连接,或者在一个连接上执行三次往返。网络开销在这种场景下会被放大,尤其是数据库和应用服务器不在同一台机器时。
Dapper 提供了一个更高效的方式:QueryMultiple,一条 SQL 返回多个结果集。
var sql = @" SELECT * FROM DeviceInfo; SELECT * FROM AlarmLog WHERE Status = 0; SELECT * FROM OperationLog ORDER BY CreateTime DESC;"; using (var multi = conn.QueryMultiple(sql)) { var devices = multi.Read<DeviceInfo>().ToList(); var alarms = multi.Read<AlarmLog>().ToList(); var operations = multi.Read<OperationLog>().ToList(); }这里有个顺序问题要特别注意:Read 读取的顺序必须和 SQL 中 SELECT 的顺序一致,不能跳着读。比如你先 Read 了第二个结果集,再想 Read 第一个,就晚了。Dapper 内部是按顺序消费结果的,所以写 SQL 时就要规划好几个结果集的先后顺序。
4.3 存储过程与输出参数
Dapper 调用存储过程非常顺手,这也是很多人选择它的原因之一。核心是 CommandType 设为 StoredProcedure,然后用 DynamicParameters 定义输入和输出参数:
var p = new DynamicParameters(); p.Add("@deviceId", 1001, DbType.Int32, ParameterDirection.Input); p.Add("@result", dbType: DbType.Int32, direction: ParameterDirection.Output); p.Add("@message", dbType: DbType.String, size: 200, direction: ParameterDirection.Output); conn.Execute("dbo.GetDeviceStatus", p, commandType: CommandType.StoredProcedure); int resultCode = p.Get<int>("@result"); string message = p.Get<string>("@message");这里有几个细节值得展开说说。
第一,输出参数一定要指定 DbType 和 size,尤其是字符串类型。如果不指定 size,有些驱动会因为长度不确定而报错或截断。第二,执行完存储过程后,用p.Get<T>("@参数名")方法取输出值,这个方法的泛型类型要和声明时尽量匹配,类型不符会有转换问题。第三,存储过程里如果有 SELECT 语句,同时还有输出参数,那么要小心结果集的消费顺序,通常要先处理结果集,再取输出参数的值。
4.4 异步API:上位机里怎么用才不卡界面
现代 C# 开发已经全面进入异步时代。Dapper 从很早期就支持 Async 版本,几乎所有方法都有对应的QueryAsync、ExecuteAsync、QueryFirstOrDefaultAsync等。
在上位机项目里,异步 API 最大的价值是避免 UI 线程卡死。比如界面需要一个“启动时读取最近100条报警”的操作,如果同步执行,数据库查询期间窗体就会处于假死状态,体验非常差。改成异步之后,UI 线程可以继续响应用户操作:
private async void Form_Load(object sender, EventArgs e) { var alarms = await LoadRecentAlarmsAsync(); dataGridView1.DataSource = alarms; } private async Task<List<AlarmLog>> LoadRecentAlarmsAsync() { using (var conn = new SqlConnection(connString)) { return (await conn.QueryAsync<AlarmLog>( "SELECT TOP 100 * FROM AlarmLog ORDER BY CreateTime DESC")).ToList(); } }有一点我要特别提醒:如果用异步方法,要把整个调用链都改成异步,尽量不要在 UI 层用async void又去.Result阻塞等待。async void本身适合事件处理器,但如果你在方法内部用了.Result或.Wait(),就很容易死锁,尤其是在有 SynchronizationContext 的环境里,比如 WinForms 和 WPF。
5. 实操现场:C#上位机数据访问完整走查
5.1 场景设定:设备数据采集入库
结合前面讲到的内容,我模拟一个很典型的上位机场景:假设我们在做一套设备监控系统,需要定时从 PLC 或传感器采集数据,然后把采集到的数据写入数据库,同时查一下当天的报警记录。这个场景在 C# 上位机开发中非常常见,很多做产线、做设备运维的朋友应该都有共鸣。
围绕这个场景,我总结了一个“Dapper + SqlConnection + Repository”的简化模式。它不复杂,但足够实用,核心思想是:连接对象不要到处 new 到处传,尽量用一个仓储类集中管理所有数据库操作。
5.2 核心实现与代码走查
先定义实体模型:
public class DeviceData { public int Id { get; set; } public string DeviceCode { get; set; } public string DeviceName { get; set; } public double Temperature { get; set; } public double Humidity { get; set; } public int Status { get; set; } public DateTime CollectTime { get; set; } }然后写一个仓储类:
public class DeviceDataRepository { private readonly string _connString; public DeviceDataRepository(string connString) { _connString = connString; } public async Task<int> InsertAsync(DeviceData data) { const string sql = @" INSERT INTO DeviceData(DeviceCode, DeviceName, Temperature, Humidity, Status, CollectTime) VALUES(@DeviceCode, @DeviceName, @Temperature, @Humidity, @Status, @CollectTime); SELECT CAST(SCOPE_IDENTITY() AS INT);"; using (var conn = new SqlConnection(_connString)) { return await conn.ExecuteScalarAsync<int>(sql, data); } } public async Task<IEnumerable<DeviceData>> GetRecentDataAsync(int topCount) { const string sql = "SELECT TOP (@topCount) * FROM DeviceData ORDER BY CollectTime DESC"; using (var conn = new SqlConnection(_connString)) { return await conn.QueryAsync<DeviceData>(sql, new { topCount }); } } }这里有几个非常实用的细节。
插入后返回自增主键,我用的是ExecuteScalarAsync<int>,配合SCOPE_IDENTITY()。这是拿自增 ID 的最保险方式,比SELECT @@IDENTITY更准确,因为@@IDENTITY可能会拿到触发器或其他连接产生的 ID,SCOPE_IDENTITY()只返回当前作用域内的值。
RowCount的写法。SELECT TOP (@topCount)里参数要加括号,这是 SQL Server 的语法要求,不加会报错。如果你在 MySQL 里则是LIMIT @topCount,不同数据库的写法要自己注意。
仓储类里每个方法都自行创建、打开、释放连接。这样写虽然连接对象频繁创建,但 SQL Server 的连接池机制会把这些开销降到非常低,实际表现并不差。相比在多个方法之间共享一个长连接,这种方式更安全,不会出现连接状态错乱的问题。
5.3 日志与错误处理:别人看不到的坑
数据访问代码最容易出问题的不是 SQL 写错,而是“连接失败”“超时”“死锁”这些数据库层面的异常。在开发环境数据量小可能根本遇不到,一旦上了生产,各种网络抖动、服务重启、数据库连接数耗尽都会出现。
我个人的处理习惯是:在仓储层不吞异常,直接抛出去给上层处理;在调用层用 try-catch 记录日志,并尽量给出“用户能看懂”的提示信息。比如:
try { var repo = new DeviceDataRepository(connString); await repo.InsertAsync(data); } catch (SqlException ex) when (ex.Number == -2) { // 超时 Log.Error(ex, "数据库操作超时,设备数据未写入"); } catch (SqlException ex) { Log.Error(ex, "数据库操作失败,ErrorCode={ErrorCode}", ex.Number); }有个小技巧是使用when过滤器来捕获特定错误码。比如-2是超时,1205是死锁,2601是主键或唯一索引冲突。能够用错误码区分业务场景后,处理逻辑会非常干净。
另外千万不要在 catch 块里只写一句Log.Error(ex.Message),这会丢掉堆栈信息,排错的时候你会非常痛苦。一定记日志时带上完整异常对象ex。
6. 常见问题与排查技巧实录
6.1 映射不上的几类经典问题
Dapper 用起来很爽,但最让人头疼的问题大多出在“映射”环节。我整理了这几年最常遇到的几类问题,直接给结论。
第一类:列名和属性名有下划线或前缀差异。比如数据库列是Device_Name,类属性是DeviceName,Dapper 默认能匹配,因为它忽略了下划线。但如果你遇到数据库是三段式命名,比如DEVICE_IP_ADDRESS映射到IpAddress,也是可以的。最怕的是属性写错单词,怎么都配不上。
第二类:查询结果有列,但类里没有对应属性。Dapper 默认把数据行塞入对象时,只映射匹配的属性,多余列直接忽略,不会报错。这既是优点也是隐患,有时候你明明 SQL 写错了,查出来的列根本不对,但程序跑得很平稳,数据却是错的。所以必要时要先var row = conn.QueryFirst(sql)查看原始列。
第三类:类型不匹配。数据库里是 varchar,你属性写了 int,Dapper 在取值时会发生转换。能转还好,不能转就直接抛异常。这是最典型的“开发环境好好的,上了数据量大的库就报错”的原因之一。排查这类问题,最佳路径是把数据库的真实类型打出来,看看到底是什么。
6.2 数据类型与性能问题速查
我把一些典型的 Dapper + SQL Server 性能问题整理成了表格,方便参考:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 大量参数传入时执行变慢 | 变量化参数导致执行计划无法有效缓存 | 考虑用表值参数或拼成 IN 列表分批执行 |
| 查询超时 | 数据量大,等待锁,或者 SQL 索引缺失 | 先用 Dapper 的QueryBuffered: false试流式读,再加索引 |
| 大量插入极慢 | 一条一条 INSERT 的往返开销大 | 改 TVP 或 SqlBulkCopy |
| 内存暴涨 | 大结果集全部加载到 List | 用 buffered: false 流式处理 |
| 偶发死锁 | 事务范围过长或更新顺序不一致 | 缩短事务,更新多表时统一顺序 |
Dapper 本身不是性能瓶颈,它只是消除了一部分映射开销。真正的瓶颈往往在 SQL 和数据库端。排查性能问题时,我会先用 SQL Server Profiler 或STATISTICS IO看语句本身执行计划,而不是一开始怀疑 Dapper 慢。
6.3 几个查错心法
遇到 Dapper 相关的诡异问题,我的排查顺序一般是这样。
第一步,先确认连接字符串和目标数据库。上位机项目里,往往因为切换环境后连接字符串指向了不同的库,导致“数据看起来不对”或者“明明连上了却查不到”。先排除这种低级问题。
第二步,把要执行的 SQL 和参数值打印出来,手工在数据库客户端里执行一遍。Dapper 的报错信息有时候很绕,但你在查询分析器里跑一下,很容易发现是语法错还是数据问题。这里要注意,打印参数时不要直接用字符串拼接,要格式化打印,防止日志泄露连接信息。
第三步,如果问题只在生产环境出现,优先怀疑数据类型和并发。用SELECT TOP 0 * FROM TableName先看表结构,确认真实列类型。
第四步,Dapper 是开源的,遇到某些行为异常但又摸不着头脑的功能,直接到 GitHub 上查 issue。很多坑其实早就被别人填过或者讨论过了。
7. 写在最后的几点建议
7.1 代码分层,别让SQL到处飞
在我接触过的不少项目里,Dapper 都是直接写在 UI 事件里的,这种写法的确简单,前期开发速度快,但一旦业务逻辑变多,就会出现“存数据的地方到处都是,改一个表结构翻了半天代码”的尴尬。
我的建议是至少分出一层“仓储/服务”。这一层不一定要很重,但要让所有 SQL 都集中到一处。哪怕就是一个静态类加上十几个方法,也比随手在按钮里写conn.Query(...)要强得多。理由很简单,将来你要加日志、加缓存、改数据库,只需要动这一层。
7.2 什么时候放弃Dapper
Dapper 很好,但它不是万能的。如果你的项目里实体关系极其复杂,需要级联操作、导航属性、自动迁移,Dapper 会变得很繁琐,因为这些都要自己写 SQL 手动维护。如果你发现自己写了大量重复的 SQL、拼装动态查询、手动处理一对多关系,这时候就该重新考虑 EF Core 或者更合适的 ORM 了。
另外,如果项目是大型分布式系统,那要考虑的就不只是 ORM 选型了,数据库访问层、事务边界、分库分表这些都要重新设计。Dapper 在这种场景下依然可以发挥作用,但它只是基础设施里很小的一块,别指望它解决一切。
7.3 最后一个实用小技巧
把 Dapper 的扩展方法封装成自己项目里的一套基础工具类,日常开发能省不少事。我在自己的工具类里通常会加这么几个东西:一个统一获取连接的方法,方便切换数据库;一个日志包裹的 Execute 和 Query,方便追踪 SQL 执行和耗时;还有一个批量执行的简易封装,避免到处写循环。
这样下来,团队里其他成员写数据访问时,只需要调用DbHelper.Query(...)或者DbHelper.Execute(...),不需要每次关心连接释放、异常处理,出问题时也能从统一入口收集日志。这套东西不复杂,但长期收益非常明显。
在我的体会里,Dapper 的魅力在于它把“数据访问”这件事变得简单而可控。你不需要学一整套复杂的规则,SQL 还是那份 SQL,C# 还是那个 C#,但它帮你把最繁琐的样板代码抹掉了。也正因为这份克制,它才能一直保持轻量、直观,让我在用了这么多年之后,依然愿意在新项目里优先想到它。