Mapster 中 C# Record 类型的映射:不可变语义、构造函数选择与 v7.4.0 / v10.0 行为对比
【免费下载链接】MapsterA fast, fun and stimulating object to object Mapper项目地址: https://gitcode.com/GitHub_Trending/ma/Mapster
Mapster 将 C# Record 视为不可变类型处理,映射时不会原地修改目标对象,而是执行"非破坏性变更"(Nondestructive mutation)——即通过构造函数创建一个带有修改属性值的新对象。本文以 Record-types.md 为核心,结合 Mapster 源码中的RecordTypeAdapter、RecordTypeIdentityHelper与测试用例,完整讲解 Record 类型的映射原理、构造函数参数默认值规则、多构造函数选择策略,以及 v7.4.0 与 v10.0 两个版本的行为差异和可用的扩展配置(MapToConstructor、Ignore、IgnoreNullValues)。
Record 类型映射的不可变语义
[!IMPORTANT] Mapster 将 Record 类型视为不可变类型(immutable type)。 映射时只进行非破坏性变更(Nondestructive mutation)——即创建一个带有修改属性值的新对象,而不是修改原对象。
对于目标类型是 Record 的映射:
var result = source.adapt(data) // 等价于 var result = data with { X = source.X.Adapt(), ...}也就是说,Adapt的结果是一个新实例,源对象中与目标属性同名的成员会被依次转换并传入目标 Record 的构造函数。这一点与普通 class 的"就地赋值"行为有本质区别:普通 class 映射会通过属性 setter 修改目标对象,而 Record 映射则始终走"构造新对象"的路径。
这一语义在源码中得到了验证。在 RecordTypeAdapter.cs 的CreateInstantiationExpression中,Record 的实例化表达式直接构建为new TDestination(src.Prop1, src.Prop2)形式;随后RecordInlineExpression再把剩余的可写成员通过MemberInit绑定到新实例上,最终返回一个全新的对象表达式。
测试 WhenMappingRecordRegression.cs 中的AdaptRecordToRecord也印证了"总是创建新对象"的行为:
var _result = _source.Adapt(_destination); object.ReferenceEquals(_result, _destination).ShouldBeFalse(); // 结果一定不是原目标对象即使是从 Record 到 Record 的"更新"式映射(MapToTarget),Mapster 也不会复用目标实例,而是创建新对象,只有构造函数中无法覆盖的成员(或被Ignore的成员)才会从目标对象"恢复"原值。
Mapster 如何识别 Record 类型
RecordTypeAdapter的CanMap判断目标类型是否为 Record:
protected override bool CanMap(PreCompileArgument arg) { return arg.DestinationType.IsRecordType(); }而IsRecordType扩展方法定义在 ReflectionUtils.cs 中,其判断逻辑为:
- 可空类型(
Nullable<T>)不算 Record; - 实现了
IConvertible的原始类型不算 Record; - 通过
RecordTypeIdentityHelper.IsDirectiveTagret检查是否带有[AdaptWith(AdaptDirectives.DestinationAsRecord)]指令,支持通过自定义特性强制把某类型当作 Record 处理; KeyValuePair<,>泛型类型被视为 Record(为了兼容 Config Clone 和 Fork 的既有行为);- 最终调用
RecordTypeIdentityHelper.IsRecordType(type)做标准检测。
核心检测逻辑位于 RecordTypeIdentityHelper.cs,它依据 C# 规范中 Record 的两个"身份特征"来判断:
- 存在复制构造函数:类型拥有两个及以上构造函数,其中一个是受保护的(
IsFamily)或 sealed record 中私有的(IsPrivate),且参数类型为自身类型(即record编译器生成的protected R(R original)复制构造函数); - 存在
<Clone>$方法:类型包含一个带IL方法实现标志的<Clone>$方法。
只有同时满足这两个条件,类型才会被认定为真正的 C# Record。这也解释了测试DetectFakeRecord(WhenMappingRecordRegression.cs)中的"伪 Record"(带protected复制构造函数的普通 class)不会被当作 Record 映射,而是走普通 class 的"修改原对象"路径。
v10.0 与 v7.4.0 的行为差异
原文档按版本区分了两套行为,写作时请以当前仓库版本对应的 v10.0 行为为准。
v10.0:全量 Record 支持
[!NOTE] 默认情况下,所有 C# Record 都被视为 record 类型进行映射。 Mapster 7.4.0 中关于构造函数数量与构造函数参数的限制不再适用。
换句话说,v10.0 起:
- 位置 Record(positional record)与普通 Record 声明均可直接映射;
- 目标 Record 可以拥有多个构造函数;
- 构造函数参数与属性名不必完全一致,Mapster 会按映射规则自动匹配源成员;
- 构造函数参数可以带默认值,源中没有对应成员时回退到默认值(详见下文)。
v7.4.0:严格限制(历史行为)
[!NOTE] Record 类型不能有 setter,且只能有一个非空构造函数,并且所有构造函数参数名必须与属性名完全匹配。
如果不满足上述条件,就需要显式添加MapToConstructor配置,例如:
class Person { public string Name { get; } public int Age { get; } public Person(string name, int age) { this.Name = name; this.Age = age; } } var src = new { Name = "Mapster", Age = 3 }; var target = src.Adapt<Person>();在 v7.4.0 中,这类"只读属性 + 唯一构造函数"的类型可以自动通过构造函数完成映射。而一旦出现多个构造函数或 setter,就必须借助MapToConstructor手动指定。这些限制在 v10.0 中已全部移除。
构造函数参数的默认值处理
原文档指出:如果源类型中不存在可以作为构造函数参数使用的成员,那么将使用该参数类型的默认值(default)。
示例:
class SourceData { public string MyString {get; set;} } record RecordDestination(int myInt, string myString); var result = source.Adapt<RecordDestination>() // 等价于 var result = new RecordDestination(default(int), source.myString)也就是说,源对象SourceData中没有myInt对应的成员,Mapster 会给它填入default(int)(即 0);而myString能从源中找到同名成员,则使用源值。这一行为与位置 Record 的参数默认值相辅相成。
测试 WhenMappingRecordTypes.cs 的Map_RecordType给出了带默认参数值的完整验证:
public record RecordType { public RecordType(Guid id, DayOfWeek day, string name = "foo", int age = 10) { this.Id = id; this.Day = day; this.Name = name; this.Age = age; } public Guid Id { get; } public string Name { get; } public int Age { get; } public DayOfWeek Day { get; } } var source = new SimplePoco {Id = Guid.NewGuid(), Name = "bar"}; var dest = source.Adapt<RecordType>(); dest.Id.ShouldBe(source.Id); // 源中有 Id,使用源值 dest.Name.ShouldBe(source.Name); // 源中有 Name,使用源值 dest.Day.ShouldBe(default(DayOfWeek)); // 源中无 Day,使用默认值 default(DayOfWeek) dest.Age.ShouldBe(10); // 源中无 Age,但构造函数声明了默认值 10注意这里两条规则并行生效:
- 构造函数参数带默认值(
name = "foo"、age = 10)时,若源中无对应成员,Mapster 会优先使用声明的默认值; - 构造函数参数不带默认值(如
int myInt)时,若源中无对应成员,则回退到default(T)。
多构造函数 Record:自动选择参数最多的构造函数
原文档规定:如果 Record 有多个构造函数,默认使用参数数量最多的那个构造函数进行映射。
示例:
record MultiCtorRecord { public MultiCtorRecord(int myInt) { MyInt = myInt; } public MultiCtorRecord(int myInt, string myString) // 此构造函数将被使用 : this(myInt) { MyString = myString; } }源码中该策略的实现位于 RecordTypeAdapter.cs:
var ctor = arg.DestinationType.GetConstructors() .OrderByDescending(it => it.GetParameters().Length).ToArray().FirstOrDefault(); // 使用参数数量最多的公共构造函数对应测试MultyCtorRecordWorked(WhenMappingRecordRegression.cs)验证了两个构造函数的 Record 可以同时完成"新建映射"与"更新映射"。
补充说明:
- 该"选参数最多构造函数"的默认逻辑仅在未显式指定构造函数时生效。源码中判断条件是:
arg.GetConstructUsing() != null || arg.Settings.MapToConstructor != null时直接走base.CreateInstantiationExpression(即使用用户显式配置),否则才执行自动选择逻辑(RecordTypeAdapter.cs); - 当构造函数参数多于源可用成员时,无法匹配的参数按上节规则回退到默认值;
- 构造完成后,Record 中未被构造函数覆盖的可写成员(如
init属性、带 setter 的属性)会通过成员初始化器(MemberInit)一并赋值,源码见RecordInlineExpression(RecordTypeAdapter.cs)。
支持的其他映射特性对比
原文档以表格形式给出了 v7.4.0 与 v10.0 对三个附加映射特性的支持情况:
| 映射特性 | v7.4.0 | v10.0 |
|---|---|---|
| 自定义构造函数映射(MapToConstructor) | - | ✅ |
| Ignore(忽略成员) | - | ✅ |
| IgnoreNullValues(忽略空值) | - | ✅ |
自定义构造函数映射:MapToConstructor
当需要手动指定目标构造函数(而不是依赖"参数最多"的自动选择)时,使用MapToConstructor配置,参见 Constructor-mapping.md:
// 全局生效 TypeAdapterConfig.GlobalSettings.Default.MapToConstructor(true); // 针对某一对类型 TypeAdapterConfig<Poco, Dto>.NewConfig().MapToConstructor(true); // 显式传入 ConstructorInfo var ctor = typeof(Dto).GetConstructor(new[] { typeof(int), typeof(int) }); TypeAdapterConfig<Poco, Dto>.NewConfig() .MapToConstructor(ctor);配置MapToConstructor后,自定义成员映射需要使用 PascalCase 命名:
TypeAdapterConfig<Poco, Dto>.NewConfig() .MapToConstructor(true) .Map('Code', 'Id'); // 使用 PascalCaseIgnore:忽略成员
Ignore可用于在 Record 映射时跳过某个目标成员。对 Record 而言,被忽略的构造函数参数在新建映射(Adapt<T>())中会被置为默认值,在更新映射(Adapt(destination))中则会保留目标对象中的原值。测试WhenRecordReceivedIgnoreCtorParamProcessing(WhenMappingRecordRegression.cs)验证了这一点:
TypeAdapterConfig<UserDto456, UserRecord456>.NewConfig() .Ignore(dest => dest.Name); var map = userDto.Adapt<UserRecord456>(); // map.Name 为默认值(空) var maptoTarget = userDto.Adapt(user); // 被忽略成员保留目标原值 "John"源码中,MapToTarget模式下被Ignore且未在构造函数中的成员会通过RecordIngnoredWithoutConditonRestore从目标对象"恢复"原值(RecordTypeAdapter.cs),从而保证忽略语义在不可变类型上依然成立。
IgnoreNullValues:忽略空值
IgnoreNullValues(true)配合 Record 映射,可实现在更新场景下只覆盖源中非 null 的成员:
TypeAdapterConfig<UpdateUser, UserAccount> .NewConfig() .IgnoreNullValues(true);测试UpdateNullable(WhenMappingRecordRegression.cs)展示了其效果:源对象中为 null 的Email、Modified字段不会覆盖目标 Record 中的既有值,而源中非 null 的Id会被正常映射。源码中RecordInlineExpression对IgnoreNullValues的处理见 RecordTypeAdapter.cs:Map 模式下直接跳过可空的 null 源成员;MapToTarget 模式下则生成条件表达式,源成员非 null 时映射、为 null 时保留目标值。
小结与使用建议
针对 Record 类型的目标对象,Mapster 的处理可以总结为以下几点:
- 不可变语义:
Adapt永远返回新实例,等价于with表达式的"非破坏性变更"; - Record 识别:通过复制构造函数 +
<Clone>$方法识别真正的 C# Record,也可用[AdaptWith]特性强制指定; - 构造函数默认值:源无对应成员时,优先使用构造函数参数的声明默认值,否则使用
default(T); - 多构造函数:未显式配置时,自动选择参数数量最多的公共构造函数;
- 版本差异:v7.4.0 要求 Record 无 setter、单一非空构造函数且参数名与属性名完全匹配(否则需
MapToConstructor),v10.0 已取消这些限制; - 扩展配置:
MapToConstructor、Ignore、IgnoreNullValues均可在 Record 映射上正常使用,相关源码位于 RecordTypeAdapter.cs,完整测试用例可参考 WhenMappingRecordTypes.cs 与 WhenMappingRecordRegression.cs。
在实际项目中,如果目标 DTO 是不可变 Record 且需要增量更新,推荐组合使用MapToTarget(Adapt(destination))与IgnoreNullValues(true),既能享受 Record 的不可变与线程安全优势,又能避免空值覆盖已有数据。
【免费下载链接】MapsterA fast, fun and stimulating object to object Mapper项目地址: https://gitcode.com/GitHub_Trending/ma/Mapster
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考