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 ListUser(); 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.QueryUser(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.QueryDeviceInfo(SELECT * FROM DeviceInfo WHERE IsOnline 1).ToList();这看起来平平无奇但背后有几个关键细节新手很容易栽跟头。第一列名和属性名的映射默认是“不区分大小写、忽略下划线差异”的。也就是说如果数据库列名是DEVICE_NAME而你的属性是DeviceNameDapper 能自动对上。但如果你用了一个完全没有对应关系的列名比如Device_IP映射到IPAddressDapper 就不会帮你做词义推断它只会按名称精确匹配对不上就保持默认值。第二如果你只关心部分字段不需要把整个实体类都映射出来直接查普通对象就行。比如你只需要设备名和 IP可以定义一个轻量类或者用匿名类型conn.Query(SELECT DeviceName FROM DeviceInfo)返回的就是动态类型集合。这在写临时统计报表时非常方便不用为每一次查询都定义一个类。第三如果查询结果有百万行你要小心ToList()的内存开销。Dapper 的 Query 方法默认是一次性把所有结果读出来填充列表的对于超大结果集更好的做法是用QueryBuffered: false参数让它变成流式读取。2.3 QueryFirst / QuerySingle / Execute 到底差在哪除了 QueryDapper 还提供了几个语义不同的方法很多人分不清什么时候用哪个这里我直接说结论。QueryFirstT返回第一行哪怕结果集有多行也只取第一行。适合类似“找出最新一条报警记录”的场景。QuerySingleT返回唯一一行如果结果集为空或者多于一行都会抛异常。适合类似“根据主键查数据”的场景。QueryFirstOrDefaultT返回第一行没有结果时返回默认值 null。最常用因为大多数查询你都希望“查不到就算了”。QuerySingleOrDefaultT返回唯一一行没有结果返回默认值多于一行业抛异常。我把这四个方法的区别整理成一张表平时记不清了就直接查方法空结果多于一行适合场景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. 参数化与动态SQLDapper的灵魂所在3.1 拼接字符串的代价参数化解决什么在 C# 开发里最常见的坏习惯就是直接拼接 SQL 字符串var sql SELECT * FROM Users WHERE Name name ;这样做最直接的问题是 SQL 注入。用户输入一个 OR 11就能把整个表带出来这在高并发的公网服务里是致命的。但我们也要承认一个现实很多上位机项目、企业内部工具数据库根本不暴露到公网于是有人觉得“我内部系统谁没事注入我啊”。这种想法在单机工控机上确实风险没那么大但一旦你的程序需要接收网络数据、需要和第三方系统对接输入就不可信了别堵运气。参数化还有一个很多人没意识到的好处——它就是给数据库引擎的“预编译”信号。SQL Server 在执行带参数的语句时会缓存执行计划。如果你总是拼接字符串数据库每次都要重新解析、编译 SQL性能差异在高频操作下会非常明显。Dapper 对参数化的支持非常自然。它支持两种常见的传参方式匿名对象和 DynamicParameters。3.2 匿名对象参数与 DynamicParameters 实战匿名对象是最简单的用法var user conn.QueryFirstOrDefaultUser( SELECT * FROM Users WHERE Id id, new { id 42 });Dapper 会把匿名对象的属性名和 SQL 里的参数名做匹配会自动识别id、:id、?id这些不同数据库的参数占位符。SQL Server 用Oracle 用:或?Dapper 都帮你处理好了。但匿名对象有个局限性——它只能传静态的属性。如果你要构建一个“可选条件”的查询也就是用户不填姓名就不查姓名填了才查匿名对象就不够灵活了。这时候要用 DynamicParametersvar 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 11; 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.QueryUser(sql, dp).ToList();这里需要注意WHERE 11只是一个占位技巧让后面的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 Listint { 1, 2, 3, 4, 5 }; var list conn.QueryDeviceInfo( 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.ReadDeviceInfo().ToList(); var alarms multi.ReadAlarmLog().ToList(); var operations multi.ReadOperationLog().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.Getint(result); string message p.Getstring(message);这里有几个细节值得展开说说。第一输出参数一定要指定 DbType 和 size尤其是字符串类型。如果不指定 size有些驱动会因为长度不确定而报错或截断。第二执行完存储过程后用p.GetT(参数名)方法取输出值这个方法的泛型类型要和声明时尽量匹配类型不符会有转换问题。第三存储过程里如果有 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 TaskListAlarmLog LoadRecentAlarmsAsync() { using (var conn new SqlConnection(connString)) { return (await conn.QueryAsyncAlarmLog( 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 Taskint 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.ExecuteScalarAsyncint(sql, data); } } public async TaskIEnumerableDeviceData GetRecentDataAsync(int topCount) { const string sql SELECT TOP (topCount) * FROM DeviceData ORDER BY CollectTime DESC; using (var conn new SqlConnection(_connString)) { return await conn.QueryAsyncDeviceData(sql, new { topCount }); } } }这里有几个非常实用的细节。插入后返回自增主键我用的是ExecuteScalarAsyncint配合SCOPE_IDENTITY()。这是拿自增 ID 的最保险方式比SELECT IDENTITY更准确因为IDENTITY可能会拿到触发器或其他连接产生的 IDSCOPE_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类属性是DeviceNameDapper 默认能匹配因为它忽略了下划线。但如果你遇到数据库是三段式命名比如DEVICE_IP_ADDRESS映射到IpAddress也是可以的。最怕的是属性写错单词怎么都配不上。第二类查询结果有列但类里没有对应属性。Dapper 默认把数据行塞入对象时只映射匹配的属性多余列直接忽略不会报错。这既是优点也是隐患有时候你明明 SQL 写错了查出来的列根本不对但程序跑得很平稳数据却是错的。所以必要时要先var row conn.QueryFirst(sql)查看原始列。第三类类型不匹配。数据库里是 varchar你属性写了 intDapper 在取值时会发生转换。能转还好不能转就直接抛异常。这是最典型的“开发环境好好的上了数据量大的库就报错”的原因之一。排查这类问题最佳路径是把数据库的真实类型打出来看看到底是什么。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 什么时候放弃DapperDapper 很好但它不是万能的。如果你的项目里实体关系极其复杂需要级联操作、导航属性、自动迁移Dapper 会变得很繁琐因为这些都要自己写 SQL 手动维护。如果你发现自己写了大量重复的 SQL、拼装动态查询、手动处理一对多关系这时候就该重新考虑 EF Core 或者更合适的 ORM 了。另外如果项目是大型分布式系统那要考虑的就不只是 ORM 选型了数据库访问层、事务边界、分库分表这些都要重新设计。Dapper 在这种场景下依然可以发挥作用但它只是基础设施里很小的一块别指望它解决一切。7.3 最后一个实用小技巧把 Dapper 的扩展方法封装成自己项目里的一套基础工具类日常开发能省不少事。我在自己的工具类里通常会加这么几个东西一个统一获取连接的方法方便切换数据库一个日志包裹的 Execute 和 Query方便追踪 SQL 执行和耗时还有一个批量执行的简易封装避免到处写循环。这样下来团队里其他成员写数据访问时只需要调用DbHelper.Query(...)或者DbHelper.Execute(...)不需要每次关心连接释放、异常处理出问题时也能从统一入口收集日志。这套东西不复杂但长期收益非常明显。在我的体会里Dapper 的魅力在于它把“数据访问”这件事变得简单而可控。你不需要学一整套复杂的规则SQL 还是那份 SQLC# 还是那个 C#但它帮你把最繁琐的样板代码抹掉了。也正因为这份克制它才能一直保持轻量、直观让我在用了这么多年之后依然愿意在新项目里优先想到它。