跳到主要内容

使用 Mapperly 做对象映射

适用范围:ASP.NET Core 8 及以上版本,Riok.Mapperly 4.x。

1. 为什么需要对象映射

一个 Web API 里通常同时存在两种“长得很像”的模型:

Car(实体) ──映射──▶ CarDto(返回给客户端)
Car(实体) ◀──映射── CarDto(客户端提交的数据)

实体承担持久化和业务职责,DTO 承担 API 契约。两者分开,接口才能在不动数据库结构的前提下演进,也不会把车架号、导航属性这类字段意外暴露出去。代价是需要大量“把 Car 的属性抄到 CarDto 上”的代码。

手写映射最直观,但字段一多就既枯燥又容易漏:Car 加了一个字段,CarDto 也加了,映射代码却忘了改,编译器不会提醒。对象映射库就是用来消灭这类代码的。

1.1 Mapperly 的特点

Mapperly 是一个基于 Source Generator(源生成器) 的映射库。你只声明映射方法的签名,Mapperly 在编译期生成具体实现:

  • 没有运行时反射:生成的就是普通的赋值代码,性能与手写一致,启动时也没有配置扫描开销;
  • 错误在编译期暴露:目标属性没有来源、源属性没有被使用,都会产生编译警告,可以升级为错误;
  • 生成的代码可读、可调试:F12 就能跳到生成的实现,断点也能打进去;
  • 支持 Native AOT 与裁剪:因为不依赖反射;
  • 开源免费:Apache-2.0 协议。

和 AutoMapper 相比,最大的区别在于思路:AutoMapper 在运行时根据配置“推断”映射,出错要等到运行(或专门写配置校验测试)才知道;Mapperly 把映射变成了编译器能检查的普通代码。


2. 安装与第一个 Mapper

dotnet add package Riok.Mapperly

先看最简单的情况:CarCarDto 的属性完全一致。

public sealed class Car
{
public int Id { get; set; }
public string Model { get; set; } = string.Empty;
public int Seats { get; set; }
}

public sealed class CarDto
{
public int Id { get; set; }
public string Model { get; set; } = string.Empty;
public int Seats { get; set; }
}

定义一个 partial 类,标注 [Mapper],再声明 partial 映射方法:

using Riok.Mapperly.Abstractions;

[Mapper]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);
}

编译后,Mapperly 会生成类似下面的代码:

public partial class CarMapper
{
public partial CarDto ToDto(Car car)
{
var target = new CarDto();
target.Id = car.Id;
target.Model = car.Model;
target.Seats = car.Seats;
return target;
}
}

在 IDE 里对 ToDto 按 F12 即可看到生成的文件。想把生成文件落到磁盘上(便于 Code Review 或排查),在项目文件中加:

<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

生成的文件位于 obj/Debug/net8.0/generated/Riok.Mapperly/ 下。

后续章节会在这个基础上逐步给 CarCarDto 增加字段,演示各项功能。


3. 在 ASP.NET Core 中使用

Mapper 是一个普通类,有两种常见用法。

3.1 注册为服务

Mapper 没有状态,注册为单例即可:

builder.Services.AddSingleton<CarMapper>();
[ApiController]
[Route("api/[controller]")]
public sealed class CarsController(ICarRepository repository, CarMapper mapper) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<CarDto>> Get(int id, CancellationToken ct)
{
var car = await repository.FindAsync(id, ct);
if (car is null)
{
return NotFound();
}

return mapper.ToDto(car);
}
}

3.2 静态类与扩展方法

不需要注入的场景,可以直接写成静态扩展方法,调用更自然:

[Mapper]
public static partial class CarMapper
{
public static partial CarDto ToDto(this Car car);
}
return car.ToDto();

两种方式生成的代码几乎一样。如果 Mapper 需要依赖其他服务(例如在自定义转换里读取配置),就只能用实例 Mapper,通过构造函数注入。


4. 属性匹配规则

4.1 同名匹配

Mapperly 按属性名把 CarCarDto 的属性对应起来,默认区分大小写。需要忽略大小写时,在 [Mapper] 上配置:

[Mapper(PropertyNameMappingStrategy = PropertyNameMappingStrategy.CaseInsensitive)]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);
}

4.2 自动扁平化

Car 加上制造商,给 CarDto 加上两个扁平字段:

public sealed class Car
{
// ...
public Manufacturer Manufacturer { get; set; } = null!;
}

public sealed class Manufacturer
{
public string Name { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
}

public sealed class CarDto
{
// ...
public string ManufacturerName { get; set; } = string.Empty; // ← Car.Manufacturer.Name
public string ManufacturerCountry { get; set; } = string.Empty; // ← Car.Manufacturer.Country
}

目标属性名如果能拆成“源属性路径”,Mapperly 会自动沿路径取值。不需要任何配置,CarDto.ManufacturerName 会映射自 car.Manufacturer.Name

反方向(从 CarDto.ManufacturerName 还原出 Car.Manufacturer.Name)不会自动发生,需要用 MapProperty 显式指定。

4.3 构造函数、record 与 init 属性

目标类型没有无参构造函数时,Mapperly 会选择参数能够全部匹配的构造函数;init 属性和 required 成员同样支持。因此 CarDto 完全可以写成不可变的 record:

public sealed record CarDto(int Id, string Model, int Seats, string ManufacturerName);

映射方法的写法不变。


5. 自定义属性映射

名字对不上、需要忽略、需要赋常量或需要转换时,用特性标注在映射方法上。

5.1 改名与嵌套路径:MapProperty

假设 CarDto 里的型号字段叫 ModelName,制造商名称字段叫 Brand

[Mapper]
public partial class CarMapper
{
[MapProperty(nameof(Car.Model), nameof(CarDto.ModelName))]
[MapProperty(nameof(@Car.Manufacturer.Name), nameof(CarDto.Brand))]
public partial CarDto ToDto(Car car);
}

nameof(@Car.Manufacturer.Name) 这种带 @ 的写法表示“取完整路径 Manufacturer.Name”,而不是 C# 默认的只取最后一段 Name。这样既能表达嵌套路径,又能在重命名属性时被重构工具一起修改。

5.2 忽略成员:MapperIgnoreSource / MapperIgnoreTarget

Car 上有车架号 Vin,不应该返回给客户端;从 CarDto 创建 Car 时,Id 由数据库生成,也不应该从请求里取:

[MapperIgnoreSource(nameof(Car.Vin))]
public partial CarDto ToDto(Car car);

[MapperIgnoreTarget(nameof(Car.Id))]
public partial Car ToEntity(CarDto dto);

显式写出忽略项,比关闭诊断更好:读代码的人能一眼看出“这个字段是有意不映射的”。

5.3 常量与计算值:MapValue

CarDto 创建 Car 时,给某些字段赋固定值或由方法计算:

[MapValue(nameof(Car.IsScrapped), false)]
[MapValue(nameof(Car.CreatedAt), Use = nameof(Now))]
public partial Car ToEntity(CarDto dto);

private static DateTime Now() => DateTime.UtcNow;

MapValue 给目标属性赋一个常量,或者用 Use 指定一个无参方法来计算值。

5.4 单个属性的转换:Use

Car.ProducedAtDateTimeCarDto.ProducedAt 是格式化后的字符串。某个属性需要特殊处理、又不适合推广到所有同类型属性时,在 MapProperty 上指定转换方法:

[MapProperty(nameof(Car.ProducedAt), nameof(CarDto.ProducedAt), Use = nameof(FormatDate))]
public partial CarDto ToDto(Car car);

private static string FormatDate(DateTime value) => value.ToString("yyyy-MM-dd");

5.5 由整个源对象计算:MapPropertyFromSource

CarDto.DisplayName 需要综合 Car 的多个字段:

[MapPropertyFromSource(nameof(CarDto.DisplayName), Use = nameof(BuildDisplayName))]
public partial CarDto ToDto(Car car);

private static string BuildDisplayName(Car car)
=> $"{car.Manufacturer.Name} {car.Model}{car.Seats} 座)";

6. 类型转换与自定义映射方法

6.1 内置转换

常见的类型差异 Mapperly 会自动处理,不需要任何配置:

  • 数值类型之间的转换(intlongintdecimal 等);
  • 任意类型 → string(调用 ToString()),string → 数值 / 枚举 / Guid / DateTime(调用 Parse);
  • 可空与非可空之间的转换;
  • 枚举与枚举、枚举与字符串、枚举与底层整数;
  • DateTimeDateOnly / TimeOnly
  • 集合类型之间的转换(见第 8 节)。

6.2 在 Mapper 里手写方法

Mapper 中非 partial 的方法会被 Mapperly 当作可复用的映射。只要类型匹配,生成代码就会自动调用它:

[Mapper]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);

// Car 里所有 DateTime → CarDto 里 string 的属性都会使用这个方法
private string FormatDateTime(DateTime value) => value.ToString("yyyy-MM-dd HH:mm:ss");

// Car.Price(Money 值对象)→ CarDto.Price(decimal)会使用这个方法
private decimal MoneyToDecimal(Money money) => money.Amount;
}

这是 Mapperly 里最常用的扩展方式:把“类型 A 怎么变成类型 B”写一次,整个 Mapper 通用。

同一对类型存在多个手写方法时,Mapperly 无法自动选择,会产生诊断。用 [UserMapping(Default = true)] 指定默认的那个,其余的通过 MapProperty(..., Use = ...) 按需指定。

6.3 映射前后的处理

Mapperly 没有 BeforeMap / AfterMap 钩子,因为用普通代码包一层就能做到。例如 CarDto.TireCount 需要在映射后单独计算:

[Mapper]
public partial class CarMapper
{
public CarDto ToDto(Car car)
{
var dto = MapToDto(car);
dto.TireCount = car.Tires.Count;
return dto;
}

[MapperIgnoreTarget(nameof(CarDto.TireCount))]
private partial CarDto MapToDto(Car car);
}

对外暴露的是手写方法,生成的方法设为 private


7. 枚举映射

CarCarDto 各加一个颜色字段,分别使用两个枚举:

public enum CarColor { Red, Blue, Black, Silver }

public enum CarColorDto { Black, Blue, Red, Grey }

两个枚举之间默认**按值(ByValue)**映射,也就是按底层整数对应。上面这两个枚举成员顺序不同,按值映射时 CarColor.Red 会被映射成 CarColorDto.Black——而且不会有任何报错。枚举分别定义在不同的层时,这种风险很常见。更稳妥的做法是按名称:

[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);
}

名称不一致的成员(SilverGrey)用 MapEnumValue 指定:

[MapEnum(EnumMappingStrategy.ByName)]
[MapEnumValue(CarColor.Silver, CarColorDto.Grey)]
public partial CarColorDto MapColor(CarColor color);

Mapper 里声明了 CarColor → CarColorDto 的方法后,ToDto 映射 Car.Color 时会自动复用它。按名称映射时,若 CarColor 有成员在 CarColorDto 中找不到对应,Mapperly 会给出诊断,而不是默默返回一个错误的值。

枚举与字符串之间的转换会自动生成基于 switch 的代码,不走 Enum.ToString() / Enum.Parse 的反射路径。


8. 集合与嵌套对象

Car 加上轮胎列表,CarDto 里用只读列表:

public sealed class Car
{
// ...
public List<Tire> Tires { get; set; } = [];
}

public sealed class CarDto
{
// ...
public IReadOnlyList<TireDto> Tires { get; set; } = [];
}

集合与嵌套对象不需要额外声明,Mapperly 会自动生成或复用对应的映射:

[Mapper]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);

public partial List<CarDto> ToDtoList(IEnumerable<Car> cars);
}

Car.TiresCarDto.Tires 会被自动映射:集合类型由 List<> 转成 IReadOnlyList<>,元素由 Tire 转成 TireDto。如果 Mapper 里已经声明了 Tire → TireDto 的方法,就复用它(连同上面的特性配置);没有声明时 Mapperly 会自动生成一个。ToDtoList 同理,会逐个调用 ToDto

数组、List<T>IEnumerable<T>HashSet<T>Dictionary<TKey, TValue>ImmutableArray<T> 等常见集合类型之间都可以互相转换。


9. 更新已有对象:MappingTarget

前面的方法都是“创建一个新对象”。更新接口通常需要的是“把 CarDto 的值写到已经从数据库查出来的 Car 上”,这样 EF Core 的变更跟踪才能生效:

[Mapper]
public partial class CarMapper
{
[MapperIgnoreTarget(nameof(Car.Id))]
[MapperIgnoreTarget(nameof(Car.Vin))]
public partial void UpdateCar(CarDto dto, [MappingTarget] Car car);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, CarDto dto, CancellationToken ct)
{
var car = await dbContext.Cars.FindAsync([id], ct);
if (car is null)
{
return NotFound();
}

mapper.UpdateCar(dto, car);
await dbContext.SaveChangesAsync(ct);
return NoContent();
}

[MappingTarget] 标注的参数不会被重新创建,Mapperly 只对它的属性逐个赋值。主键、车架号、创建时间这类不允许被请求修改的字段,一定要用 MapperIgnoreTarget 排除——否则客户端就能通过更新接口改掉它们。


10. IQueryable 投影

直接在查询上声明映射,Mapperly 会生成一个 Select 表达式:

[Mapper]
public static partial class CarQueryMapper
{
public static partial IQueryable<CarDto> ProjectToDto(this IQueryable<Car> query);
}
var cars = await dbContext.Cars
.Where(car => car.Seats >= 5)
.ProjectToDto()
.ToListAsync(ct);

生成的是表达式树而不是方法调用,所以 EF Core 能把它翻译成 SQL,只查询 CarDto 需要的列,不会先把完整的 Car 连同 ManufacturerTires 加载到内存再转换。列表查询、分页查询优先用这种方式。

需要注意:投影会被翻译成 SQL,所以第 6 节中那些手写的转换方法,只有能被 EF Core 翻译的才可以用在投影里。像 value.ToString("yyyy-MM-dd") 这类格式化逻辑,要么留到内存中处理,要么放到前端去做。


11. 编译期诊断

Mapperly 最有价值的能力是在编译期发现映射问题。最常遇到的两类诊断:

诊断含义常见原因
RMG012目标成员找不到来源CarDto 新加了字段,Car 里没有或名字不一致
RMG020源成员没有映射到任何目标Car 新加了字段,CarDto 没加;或者这个字段本就不该暴露

默认它们是警告,很容易淹没在构建输出里。建议在 .editorconfig 中升级为错误:

[*.cs]
dotnet_diagnostic.RMG012.severity = error
dotnet_diagnostic.RMG020.severity = error

这样“Car 加了字段却忘了改 CarDto”会直接导致编译失败。确实不需要映射的成员,用 MapperIgnoreSource / MapperIgnoreTarget 显式声明。

CarDto 本来就只是 Car 的子集时,逐个忽略源成员很繁琐,可以只要求目标成员全部有来源:

[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class CarMapper
{
public partial CarDto ToDto(Car car);
}

RequiredMappingStrategy 的取值为 Both(默认,源和目标都检查)、SourceTargetNone,也可以用 [MapperRequiredMapping(...)] 只对单个方法生效。