资讯动态

微型ORM Dapper入门:轻量、高性能的SQL映射与实践技巧

发布时间:2026/10/13 1:46:11 来源:尧图企业网站定制
今天的项目其实是个系列的开篇ORM-Dapper系列01-初探Dapper。相信不少人在项目里遇上过这种场景某个查询逻辑特别多用重量级ORM做起来总觉得绑手绑脚或者线上反馈接口慢一查发现是ORM自动生成的SQL不够聪明执行计划走了个全表扫。这种情况下Dapper往往是被反复提起的名字。它是“微型ORM”里最具代表性的一个以轻量、直接、性能接近手写ADO.NET著称核心机制就是给IDbConnection提供了几个扩展方法把查询、执行、事务这类常规操作做得非常顺手。这篇内容适合刚接触Dapper、想在真实业务里安全使用它或者正在做技术选型对比的开发者阅读。我会先聊它和EF Core这类完整ORM的定位差异再带大家走一遍从环境准备到核心API的完整过程中间穿插一些映射细节和多结果集的处理方式最后把日常使用里最常踩的坑单独列出来。1. 技术定位Dapper到底解决了什么问题1.1 它在ORM光谱里的位置要说Dapper得先搞清楚“ORM光谱”这回事。市面上叫ORM的东西不少但大体上分两派。一派是重量级完整ORM它们维护实体状态、追踪变更、生成复杂SQL、支持导航属性和延迟加载目标是让开发者感觉在操作纯对象而不是数据库另一派就是Dapper这类“micro ORM”它不追踪实体状态不给你生成复杂的关联查询SQL它做的是把你自己写的SQL执行起来然后把结果行映射成C#对象。Dapper的官方定位就是它不管你的SQL怎么写只管把SQL查出来之后那点“脏活”干掉——也就是把DataReader逐行读出来、按列名塞进对象属性里。看起来简单但正是这个定位让它在性能上几乎不输手写数据访问层而在开发效率上又比纯ADO.NET高不少。你可以把它想象成一个“手工面点师傅”它不给你做预制菜不替你决定菜谱但帮你把每一道工序都做得又快又稳。1.2 为什么选Dapper而不是EF Core或纯SQL拼接实际选型的时候很多人纠结Dapper还是EF Core。我的建议是先看团队对SQL的掌控能力。EF Core的强项是领域模型驱动和跨数据库迁移适合业务规则复杂、实体关系多的场景但代价是生成SQL的不可控性和学习曲线。Dapper则把主动权还给你SQL是你写的执行计划你心里有数出了问题看慢查询日志直接就能对应上。纯SQL拼接又太痛苦参数化、映射、DataReader循环读写、类型转换这些重复劳动不仅浪费时间还容易出低级错误。Dapper正好卡在中间那层你写SQLDapper处理映射、参数和连接管理。如果你团队里DBA话语权比较重或者系统对查询性能有硬指标Dapper往往是最好的折中方案。选型提示如果你预期系统后期会频繁调整表结构和关系模型选EF类工具会更省心如果核心链路是报表查询、复杂SQL手工优化、读写分离这类场景Dapper会更合适。2. 环境准备三步搭好一个能跑的Demo2.1 创建项目和引入包假设我们用.NET环境目标框架建议至少6.0以上语法体验会好很多。先用命令行建一个控制台项目或者直接在IDE里新建解决方案都可以。创建好项目之后安装Dapper包dotnet add package Dapper这个包只是核心库完全够用不需要额外引入任何东西。如果你后续要连SQL Server再装对应的数据库驱动包。Dapper本身上下层完全分离它只是扩展了IDbConnection所以你换数据库只需要换驱动包不需要改业务代码。2.2 准备数据库和表结构我这里用一个典型的订单表作为演示结构尽量贴近日常业务CREATE TABLE [dbo].[Orders]( [Id] [int] IDENTITY(1,1) NOT NULL, [OrderNo] [varchar](32) NOT NULL, [CustomerName] [nvarchar](50) NOT NULL, [TotalAmount] [decimal](18,2) NOT NULL, [Status] [int] NOT NULL, [CreatedAt] [datetime] NOT NULL, CONSTRAINT [PK_Orders] PRIMARY KEY CLUSTERED ([Id] ASC) )为什么特意加了一个下划线风格的OrderNo因为这是很多初学者容易踩的坑数据库列名和C#属性名不一致时怎么映射我们后面会专门讲。提前埋这个设计能让演示更贴近实际工作。2.3 建立连接和执行第一个查询写一个最基础的查询把订单总数拿出来看看using Dapper; using Microsoft.Data.SqlClient; string connectionString Server.;DatabaseDemoDb;Integrated Securitytrue;TrustServerCertificatetrue;; using var connection new SqlConnection(connectionString); connection.Open(); var count connection.ExecuteScalarint(SELECT COUNT(*) FROM Orders); Console.WriteLine($当前订单总数{count});这段代码里有几个关键细节需要注意。ExecuteScalar这个扩展方法是Dapper封装的专门用来执行标量查询它会把返回的一个值映射到指定类型上比你自己调DataReader再取第一行第一列省不少代码。还有一个细节是using var写在前面、连接在方法结束时自动释放这算是Dapper用的最常见标准姿势。3. 核心API实战日常查询与写操作3.1 Query把查询结果映射成对象列表接着上面的订单表查出一个列表。这个场景最常用也是Dapper最核心的APIpublic class OrderInfo { public int Id { get; set; } public string OrderNo { get; set; } public string CustomerName { get; set; } public decimal TotalAmount { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } } var sql SELECT * FROM Orders WHERE Status Status ORDER BY Id DESC; var orders connection.QueryOrderInfo(sql, new { Status 1 }).ToList();你可能注意到了条件值通过匿名对象的属性Status传入参数名是Status两边对应上了。这就是参数化查询的标准用法能有效避免SQL注入风险。为什么Dapper能做到这个绑定它内部会解析你传入的匿名对象把属性名和SQL里的参数名做匹配再逐一向DbCommand添加参数。这里有个点需要提醒一下SQL里有几个参数你的匿名对象字段名就必须和之一一对应多了没关系少了Dapper会直接抛异常。这段代码返回的是IEnumerable我加了ToList把它物化。一个问题大家经常疑惑为什么Dapper返回接口而不是List。因为懒加载机制在枚举之前数据其实还没从DataReader里完整读出来连接也还占着。ToList之后数据全部读取连接状态更可控。3.2 Execute插入、更新、删除怎么写查询之外写操作也很直白。Dapper用Execute执行非查询类命令返回受影响的行数。拿插入订单举例var insertSql INSERT INTO Orders (OrderNo, CustomerName, TotalAmount, Status, CreatedAt) VALUES (OrderNo, CustomerName, TotalAmount, Status, CreatedAt); var affected connection.Execute(insertSql, new { OrderNo $SO{DateTime.Now:yyyyMMddHHmmss}, CustomerName 张三, TotalAmount 199.99m, Status 1, CreatedAt DateTime.Now }); Console.WriteLine($影响行数{affected});这种写法比拼字符串方式高效太多而且自动参数化。如果有多条记录需要批量插入可以传入一个对象数组var list new[] { new { OrderNo SO001, CustomerName 李四, TotalAmount 88.00m, Status 1, CreatedAt DateTime.Now }, new { OrderNo SO002, CustomerName 王五, TotalAmount 66.00m, Status 1, CreatedAt DateTime.Now } }; var affected connection.Execute(insertSql, list);Dapper内部会遍历这个集合每条记录照样走参数化执行。很多人会问这和SqlBulkCopy比性能怎么样一句话回答Dapper的批量更适合数据量百级别以内、不需要极致的场景如果几万条数据要做初始化导入直接走DbBulkCopy之类的手段更科学。3.3 动态类型与单值查询的取舍Dapper还有一个极为实用的能力查询结果不绑定任何强类型直接返回dynamic对象。比如业务上只需要一两个字段不想为它单独建一个类var rows connection.Query(SELECT OrderNo, CustomerName FROM Orders WHERE Status Status, new { Status 0 }); foreach (var row in rows) { Console.WriteLine(${row.OrderNo} - {row.CustomerName}); }这种dynamic模式在一开始很灵活但也要注意没了强类型编译器帮不了你检查属性名错误等运行时才会抛出来字段名一旦写错执行时必然是绑不上值的。我的习惯是临时排查问题可以用dynamic正式业务代码能定义类就定义类不要为了省几行定义类的代码把类型安全埋掉。4. 映射机制从列名到属性的对应关系4.1 默认映射规则与下划线列名问题现在回到前面埋的那个坑。Dapper默认的映射规则是列名和属性名“语义相等”这里的相等是忽略大小写的但不处理下划线和驼峰之间的转换。什么意思OrderNo这个列名如果C#类里也定义OrderNo没问题但如果你的数据库规范是order_no而C#属性是OrderNo默认情况下映射不上得到的值是默认值0或者null。解决方案有好几种按我的推荐顺序列一下。4.2 方案一SQL别名映射最省事改SQL不改代码var sql SELECT Id, OrderNo AS OrderNo, customer_name AS CustomerName FROM Orders WHERE Id Id; var order connection.QueryFirstOrDefaultOrderInfo(sql, new { Id 1 });这种方式简单直观适合字段数量少的场景。缺点是每处查询都要记得写别名漏一处就出错维护成本偏高。4.3 方案二全局列名映射Dapper提供了一组专用接口可以在映射器里做全局转换。比如把所有下划线风格的列名转成属性名Dapper.DefaultTypeMap.MatchNamesWithUnderscores true;这一行代码是Dapper内置的“支持下划线匹配”开关。打开之后Dapper会把列名中的下划线去掉再比较属性名比如order_no会匹配OrderNo、customer_name会匹配CustomerName。注意这个开关是全局静态选项项目启动时设置一次就好不需要每条查询单独设置。这个方案很香但也要注意影响范围如果同一个项目里存在两种风格的数据库表旧表是驼峰列名、新表是下划线全局打开反而可能干扰旧表映射。项目初始化前务必和团队确认好标准我见过最尴尬的情况是混合数据库源下映射时灵时不灵排查了一整天才发现是这个开关造成的。4.4 方案三自定义Map如果连下划线规则都覆盖不了你的需求比如列名和属性名八竿子打不着那就必须上自定义映射var map new CustomPropertyTypeMap(typeof(OrderInfo), (type, columnName) type.GetProperty(Id) ?? type.GetProperty(CustomerName) ?? throw new InvalidOperationException($Cannot map {columnName})); Dapper.SqlMapper.SetTypeMap(typeof(OrderInfo), map);这种写法较繁琐而且逻辑不好维护能用别名或全局规则解决就不要轻易上它。说句实在话我做了这么多年项目自定义Map只在接第三方业务系统、对方数据库列名毫无规则可言的场景用过两三次。5. 进阶操作多结果集与事务协作5.1 QueryMultiple一次查询拿多份数据报表类页面经常遇到这种需求一个页面要同时显示订单总数、未处理订单数、失效订单数。传统写法是三次连接数据库三次查询Dapper的多结果集能一次完成并且省掉多次往返耗时using var multi connection.QueryMultiple( SELECT COUNT(*) FROM Orders WHERE Status Status; SELECT COUNT(*) FROM Orders WHERE Status Status2; SELECT COUNT(*) FROM Orders WHERE CreatedAt Date;, new { Status 1, Status2 0, Date DateTime.Today.AddDays(-7) }); var activeCount multi.Readint().Single(); var pendingCount multi.Readint().Single(); var recentCount multi.Readint().Single();关键在于SQL里多条语句写在同一个批次中用分号分隔Dapper的QueryMultiple负责一次性执行之后你按顺序Read即可。顺序一定要和SQL里的语句顺序完全一致错一位就是隐式bug不报错但数据串位。我在这个问题上栽过一次后来养成了习惯在Read前面加注释标清楚是哪个查询的结果。5.2 事务让多步写操作保持一致Dapper自己不管事务但因为它扩展的是IDbConnection天然支持数据库事务。很多人刚接触时不知道扩展方法里有个transaction参数导致在事务里漏传事务对象最后发现数据没回滚。标准写法是这样using var transaction connection.BeginTransaction(); try { connection.Execute(UPDATE Orders SET Status Status WHERE Id Id, new { Status 2, Id 1 }, transaction); connection.Execute( INSERT INTO OrderLogs (OrderId, Action, LogTime) VALUES (OrderId, Action, LogTime), new { OrderId 1, Action UpdateStatusToProcessing, LogTime DateTime.Now }, transaction); transaction.Commit(); } catch { transaction.Rollback(); throw; }重点只有一个每个Execute/Query都要把transaction作为最后一个参数传进去。漏传会怎样执行不会报错因为连接还是那个连接操作也做了但事务对它不起作用万一后面出异常回滚前面的操作就“逃逸”在事务之外数据就脏了。这个问题且不易察觉尤其在重构时新增一个Execute很容易忘记补参数。5.3 异步版本高并发场景下的正确姿势连接池耗尽不是小事。Dapper的异步扩展方法用起来几乎没有学习成本var orders await connection.QueryAsyncOrderInfo( SELECT * FROM Orders WHERE Status Status, new { Status 1 }); var affected await connection.ExecuteAsync( UPDATE Orders SET Status NewStatus WHERE Status OldStatus, new { NewStatus 2, OldStatus 1 });IO密集型的数据库访问强烈建议用async版本。它能释放线程不用阻塞等待数据库返回在高并发、连接数紧张的环境下这个习惯直接决定你的服务能不能扛住峰值流量。凡是同步方法都有对应async版本参数完全一样放心切换。6. 常见问题与排查技巧实录6.1 连接忘记释放引发的“连接池耗尽”初学者最容易犯的错是这类写法var conn new SqlConnection(connectionString); var count conn.ExecuteScalarint(SELECT COUNT(*) FROM Orders);连接没释放。Dapper的扩展方法在执行时会自动打开连接但如果是你自己new出来的连接且没Open某些操作会隐式打开如果显式Open了但没Close几次操作之后连接池就被占满了。最佳实践是开头演示的using var模式或者手动try-finally保证Close。凡是看到“The timeout period elapsed”这类异常第一反应就检查所有连接是否都在using块内。6.2 参数化到底防不防注入Dapper的参数化是防注入的但前提是你别自己拼接SQL// 错误表名、排序字段这种结构部分参数化救不了你 var sql SELECT * FROM Orders WHERE CustomerName name ; // 正确 var sql SELECT * FROM Orders WHERE CustomerName name;点名批评一下有一个非常经典的误用是把ORDER BY子句也放进参数里。orderBy参数化成什么最后拼出来SQL变成ORDER BY Id数据库不会报错但根本不会按Id排序。结构部分的处理方式一般是白名单校验而不是参数化。我自己的工具方法是先判断传来的字符串是不是在允许的列名列表里不在就抛异常这样最安全。6.3 返回列的字段映射不上值但不报错Dapper的默认行为很“宽容”查询结果有的列在实体类中找不到对应属性它直接忽略实体类里有的属性查询结果里没有对应列它保持默认值不报错。这个宽容性有个隐患你以为查出了数据实际字段是0或null不仔细看完全发现不了。排查思路就一条先打印一条返回结果的原始列名再用列名和实体属性逐一对照。写个调试小工具var columns connection.Query(SELECT * FROM Orders WHERE Id Id, new { Id 1 }) .FirstOrDefault(); // 在调试器里看这个动态对象的属性名和你的实体类一对就明白了6.4 N1查询使用Dapper时由于没有导航属性和延迟加载开发者容易在循环里反复查询数据库var orders connection.QueryOrderInfo(SELECT * FROM Orders WHERE Status Status, new { Status 1 }); foreach (var order in orders) { var log connection.QueryFirstOrDefaultOrderLog( SELECT * FROM OrderLogs WHERE OrderId OrderId, new { OrderId order.Id }); }这个写法如果是订单带日志的场景orders有1000条日志查询就执行1000次。Dapper官方不提供Join后自动填充子对象的机制所以你得自己用QueryMultiple结果集合并或者直接写一条JOIN查询把子表数据映射成扁平结构再内存分组。高查询比低查询好高往返比低往返好任何时候先考虑减少数据库往返次数。6.5 动态参数与IN子句的经典用法IN查询是使用Dapper时几乎必遇的场景直接传数组var ids new int[] { 1, 2, 3, 4, 5 }; var orders connection.QueryOrderInfo( SELECT * FROM Orders WHERE Id IN Ids, new { Ids ids });注意SQL里IN后面没有括号这是Dapper特有的展开写法它会把数组自动展开成参数列表。这不是标准SQL语法但Dapper封装时做了处理。如果你很不习惯也可以手动展开参数用字符串拼接作为参数名但明显麻烦得多建议直接信任Dapper这一层能力它在内部会帮你参数化好不用担心注入问题。6.6 性能为什么Dapper在某些场景反而变慢了话说回来Dapper不是万能加速器。有人觉得用了Dapper所有SQL就自动变快了这是认知误区。它的性能优势主要体现在映射环节——比EF Core少很多中间计算但它不能优化你自己的SQL。如果你的查询走了全表扫描、缺少必要索引Dapper再快也快不过数据库慢查询。真遇到慢查询先拿Dapper执行的SQL去数据库客户端里跑一遍看执行计划而不是甩锅给ORM。7. 写在最后的经验小结系列第一篇文章到这里把该铺的路都铺好了。Dapper的入门成本确实很低安装一个包、会写SQL、能new一个连接对象半小时内就能上手。不过入门容易做好难把映射规则搞明白、在每个Execute和Query里传对事务参数、养成用async和using的习惯这一整套肌肉记忆下来才算是真正会用。就我个人的经验来说Dapper最适合放在业务逻辑相对固定、SQL以优化空间为重点、并且团队不想被重量级ORM的抽象层牵着走的项目里。它不会替你做决策但会把你的决策高效地执行下去。这也是我为什么愿意写这个系列的原因——下一篇文章我会深入源码层面看看Dapper内部的缓存机制和DataReader映射过程到底是怎么做到“快”的。到时我们会一起试着读读底层代码从另一个维度来理解它。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑