在C# 9引入record类型之后,很多团队希望用不可变的数据模型来接收数据库查询结果,Dapper作为轻量级ORM,从较新版本开始已经能够直接把行数据映射到record。理解它的映射机制,有助于在保持代码简洁的同时避免常见的赋值错误。

一、Dapper对record类型的映射原理
Dapper在执行查询时,会先通过反射获取目标类型的构造函数和属性信息。对于record类型,编译器会生成一个包含全部主键属性的主构造函数,同时生成对应的只读属性。Dapper在内部使用System.Reflection获取构造函数参数名称,并将其与数据库字段名进行不区分大小写的匹配,从而通过构造函数完成对象创建。
当record仅包含get;的自动属性时,Dapper会优先寻找带有全部参数的构造函数;如果找不到合适的构造函数,就会尝试通过属性setter赋值,但record的默认属性是只读的,这时会抛出异常。因此,使用record接收Dapper结果时,要么保留编译器生成的主构造函数,要么显式定义带参数的构造函数。
1.1 基础映射示例
下面定义一个简单的record,并用Dapper查询数据。注意属性名称需要与数据库字段一致,或者使用列别名。
public record UserRecord(int Id, string Name, string Email);
using var conn = new SqlConnection("Server=127.0.0.1;Database=test;User Id=sa;Password=pwd;");
var list = conn.Query<UserRecord>("SELECT Id, Name, Email FROM Users").ToList();
foreach (var u in list)
{
Console.WriteLine($"{u.Id}-{u.Name}-{u.Email}");
}
上述代码可以正常运行,因为UserRecord的主构造函数参数与查询字段完全对应。Dapper通过构造函数注入完成了映射,没有依赖属性setter。
如果数据库字段是user_name而record参数是Name,则需要在SQL中写user_name AS Name,或者配置自定义的映射规则,否则参数匹配会失败。
二、record与传统class实体的区别
传统class实体通常带有公共的get; set;属性,Dapper可以通过无参构造函数创建对象后再逐个赋值。record则倾向于不可变,默认提供值相等比较,并且在模式匹配、深拷贝时有语法优势。两者在映射时的核心差异体现在可变性和生命周期管理上。
| 对比维度 | 传统class实体 | record类型 |
|---|---|---|
| 可变性 | 属性可随时修改 | 默认只读,需init或构造函数传参 |
| 相等语义 | 引用相等 | 值相等,按属性内容比较 |
| Dapper映射方式 | 无参构造+属性setter | 主构造函数参数匹配 |
| 适用场景 | 需要跟踪状态变更的富领域模型 | 只读查询模型、DTO传输对象 |
从性能角度看,record通过构造函数一次性赋值,减少了属性setter的调用开销,在高频只读查询中略有优势。但它的不可变性也意味着更新操作必须创建新对象,不适合需要原地修改的业务实体。
另一个容易忽略的点是,record生成的ToString方法会输出所有属性值,在日志调试时非常方便,而class默认只输出类型名。对于排查Dapper映射结果是否符合预期,record自带的诊断信息能节省不少时间。
三、使用init属性兼容Dapper
如果希望record既保持不可变语义,又能在某些场景下用属性赋值,可以使用init访问器。init属性允许在对象初始化表达式中被赋值,但在初始化完成后变为只读。Dapper较新版本在映射时会识别init属性并尝试赋值。
public record Product
{
public int Id { get; init; }
public string ProductName { get; init; }
public decimal Price { get; init; }
}
using var conn = new SqlConnection("Server=192.168.0.1;Database=shop;User Id=root;Password=123;");
var products = conn.Query<Product>("SELECT Id, ProductName, Price FROM Products").ToList();
这段代码中Product没有显式构造函数,但所有属性都是init,Dapper会利用无参构造函数创建实例,然后通过init setter填充数据。这种方式比主构造函数更灵活,也更容易和EF Core等其它框架混用。
需要注意的是,如果同时定义了主构造函数和init属性,Dapper仍以主构造函数为准。当数据库字段比构造函数参数少时,未传参的属性会保持默认值,这可能引发隐蔽的数据缺失问题。
四、自定义TypeHandler处理复杂映射
当record中包含Dapper无法直接转换的类型,例如JSON字符串转对象,或者枚举使用自定义存储格式时,可以通过实现SqlMapper.TypeHandler<T>来扩展。下面示例将数据库中的逗号分隔字符串映射到record的List字段。
public record TaggedUser(int Id, List<string> Tags);
public class TagListHandler : SqlMapper.TypeHandler<List<string>>
{
public override List<string> Parse(object value)
{
return value?.ToString().Split(',').ToList() ?? new List<string>();
}
public override void SetValue(IDbDataParameter parameter, List<string> value)
{
parameter.Value = string.Join(",", value);
}
}
SqlMapper.AddTypeHandler(new TagListHandler());
using var conn = new SqlConnection("Server=127.0.0.1;Database=test;User Id=sa;Password=pwd;");
var users = conn.Query<TaggedUser>("SELECT Id, Tags FROM UserTags").ToList();
通过自定义Handler,record的不可变特性不会受到破坏,因为转换发生在构造函数参数赋值之前。这样即使底层存储格式特殊,上层模型依然保持干净的值对象形态。
在微服务架构中,这种写法尤其有用:数据库里的冗余字段可以在映射层被收敛成强类型的record,避免把字符串解析逻辑散落在业务代码里。
五、常见错误与排查建议
开发者常遇到的错误是“无法找到合适的构造函数”或“只读属性赋值失败”。这通常是因为record被手动改成了只有无参构造函数,却忘了给属性加init,或者SQL字段别名和参数名不一致。建议在写查询时用AS显式对齐名称。
排查时可以先把record临时改成class并加上setter,若此时映射成功,说明问题出在构造函数或只读属性上,而不是SQL本身。
另外,Dapper版本低于2.0.30时对record支持不完善,升级NuGet包通常能解决大部分兼容问题。若项目受限于旧框架无法升级,可以用匿名类型接收后再用LINQ投影成record,作为折中方案。
var anon = conn.Query("SELECT Id, Name FROM Users").Select(r => new UserRecord(r.Id, r.Name)).ToList();
这种手动投影方式不依赖Dapper的反射适配,在老版本中也能稳定工作,只是多了一步转换代码。对于小型查询影响不大,但在批量场景下要留意额外的枚举开销。
Dapperrecord_typeCSharp9修改时间:2026-08-01 17:21:18