在使用Dapper做数据访问时,我们常常希望领域模型保持不可变特性,例如通过私有构造函数初始化只读字段。但Dapper默认只认公共构造函数和公共属性,这就带来了一个现实问题:怎么把查询结果映射到带私有构造函数的类?本文从Dapper的映射机制出发,给出几种实用方案并附上完整代码。

一、Dapper映射的底层逻辑
Dapper在第一次执行查询时,会为返回类型生成一个轻量的对象映射器。它通过反射读取类型的构造函数,优先选择参数最多且与查询列名匹配的公共构造函数。如果找不到合适的公共构造函数,就会尝试通过公共属性的setter来赋值。当类只有私有构造函数且无公共setter时,Dapper会抛出无法创建实例的异常。
从源码层面看,Dapper利用了System.Reflection.Emit动态生成IL代码来提升性能,而不是每次都用纯反射Activator.CreateInstance。但它生成的逻辑默认不包含访问非公有成员的权限。理解这一点后,我们就知道要解决这个问题,要么改变Dapper的映射规则,要么让它可以“看见”私有构造函数。
二、使用CustomTypeMap手动指定映射
第一种方式是借助CustomTypeMap接口,自己实现一个映射器,在内部通过反射调用私有构造函数。这种方式最灵活,也最直观,适合对映射过程有强控制需求的场景。
下面示例定义了一个带有私有构造函数的User类,并通过自定义TypeMap完成映射。注意构造函数参数名需与查询列名一致,且我们在CreateInstance方法里使用BindingFlags.NonPublic来获取私有构造函数。
using System;
using System.Collections.Generic;
using System.Data;
using System.Reflection;
using Dapper;
public class User
{
public int Id { get; }
public string Name { get; }
private User(int id, string name)
{
Id = id;
Name = name;
}
// 供CustomTypeMap调用
internal static User Create(int id, string name) => new User(id, name);
}
public class UserTypeMap : CustomTypeMap<User>
{
public UserTypeMap()
{
Map("Id", (u, v) => { }, true);
Map("Name", (u, v) => { }, true);
}
public override User CreateInstance(object[] values)
{
// 直接调用内部静态工厂,绕过私有构造函数的可见性限制
return User.Create((int)values[0], (string)values[1]);
}
}
class Program
{
static void Main()
{
SqlMapper.SetTypeMap(typeof(User), new UserTypeMap());
using var conn = new System.Data.SqlClient.SqlConnection("ipipp.com");
var users = conn.Query<User>("SELECT Id, Name FROM Users");
foreach (var u in users)
{
Console.WriteLine($"{u.Id}: {u.Name}");
}
}
}
这种方案的优点是不会破坏类的封装性,所有实例化逻辑收口在类内部。缺点是每次新增字段都要同步修改CreateInstance中的参数顺序,维护成本略高。如果表结构稳定,这是一个非常稳妥的做法。
三、利用反射配合私有构造函数直接查询
如果不想写CustomTypeMap,也可以在查询后手动用反射构造对象。虽然绕开了Dapper的自动映射,但代码更简单,适合一次性或小型项目。
以下代码先让Dapper返回动态对象,再使用Activator.CreateInstance并指定NonPublic标志来调用私有构造函数。这种方式性能不如动态生成IL,但在低频场景下完全可以接受。
using System;
using System.Linq;
using System.Reflection;
using Dapper;
public class Order
{
public int OrderId { get; }
public decimal Total { get; }
private Order(int orderId, decimal total)
{
OrderId = orderId;
Total = total;
}
}
class Demo
{
static void Test()
{
using var conn = new System.Data.SqlClient.SqlConnection("ipipp.com");
var rows = conn.Query("SELECT OrderId, Total FROM Orders").AsList();
var ctor = typeof(Order).GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
null,
new[] { typeof(int), typeof(decimal) },
null);
var list = rows.Select(r =>
(Order)ctor.Invoke(new object[] { (int)r.OrderId, (decimal)r.Total }))
.ToList();
foreach (var o in list)
{
Console.WriteLine(o.OrderId + " => " + o.Total);
}
}
}
该方法的优势是实现快、无额外类型映射配置;劣势是失去了Dapper强类型查询的便利性,且反射调用在高频查询时会有明显开销。如果追求性能,仍建议回到第一种方案或下一节的方式。
四、借助记录类型与参数化构造函数的变通
在C#较新版本中,可以使用record或把构造函数设为internal而非private,再配合友元程序集让Dapper所在程序集访问。虽然这并非严格意义上的私有构造函数,但能满足不变性需求且被Dapper原生支持。
示例将构造函数改为internal,并在程序集级别通过InternalsVisibleTo开放给Dapper所在的入口程序集,这样Dapper就能直接通过内部构造函数映射,无需自定义类型映射器。
using System;
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp")]
public class Product
{
public int ProductId { get; }
public string Title { get; }
internal Product(int productId, string title)
{
ProductId = productId;
Title = title;
}
}
这种写法在保持只读属性的同时,最大限度复用Dapper默认高性能映射。缺点是放宽了构造函数的访问级别,若项目对封装极度敏感则可能不合适。实际开发中,多数团队会接受这种折中。
五、方案对比与选择建议
我们将上述三种主要方式在维护性、性能和侵入性上做个简单对比:
| 方案 | 性能 | 维护成本 | 封装性 |
|---|---|---|---|
| CustomTypeMap | 高 | 中 | 强 |
| 反射手动构造 | 低 | 低 | 强 |
| internal构造函数 | 高 | 低 | 中 |
如果系统对领域模型不变性要求严格且查询频繁,首选CustomTypeMap;若只是脚本或后台任务,反射方案足够;而当团队规范允许内部构造函数时,直接改用internal是最省心的。理解Dapper的映射约束后,私有构造函数不再是障碍,而是可管理的设计细节。
Dapperprivate_constructorobject_mapping修改时间:2026-08-04 17:24:43