在后端开发中,对象之间的字段搬运是最高频也最枯燥的工作:数据库实体转 DTO、DTO 转视图模型、前端传参转实体……手写映射代码不仅重复率高,还容易在字段增删时漏改。上一篇文章我们聊过 LINQ 表达式树如何把 Where/OrderBy 翻译成 SQL,这次来看另一个同样基于表达式树构建的库——Mapster,它是 .NET 生态中性能最好的对象映射框架之一。
本文基于一个真实的菜单权限系统源码,覆盖 Mapster
三个最实用的进阶能力:AfterMapping
后处理钩子、Map 表达式类型转换、MapWith
完全自定义映射,最后实现一个”扁平列表 → 三层树形结构”的完整案例。
一、基础用法:一行 Adapt 搞定映射
Mapster 的入口是 Adapt<T>()
扩展方法。同名字段自动映射,零配置即可工作:
// User -> UserDto,同名同类型字段自动复制
var dto = user.Adapt<UserDto>();
反方向也只需一行:
var entity = dto.Adapt<User>();
对于简单的实体对,这样已经够用。但现实中更多是”字段名不同、类型不同、结构不同”的映射场景,这时就需要集中式配置。
二、集中配置:TypeAdapterConfig 与启动注册
Mapster 支持在程序启动时注册映射规则,避免散落在各个服务里。我们在业务层建一个静态配置类:
public static class MapsterConfig
{
public static void Configure()
{
// 各类型映射规则集中在这里注册
TypeAdapterConfig<User, UserDto>.NewConfig();
}
}
然后在 Program.cs 中调用一次:
// 应用启动时注册所有映射规则
MapsterConfig.Configure();
这样 Api、Worker 等多个宿主程序共享同一套映射规则,改字段时只需要改一个地方。这就是”约定优于配置 + 集中管理”的思路:简单映射靠约定,复杂映射才写配置。
三、AfterMapping:映射后的字段定制钩子
AfterMapping 在默认映射完成后执行,适合处理”字段名不同 +
需要组合加工”的场景。项目里最典型的是数据库菜单实体 VueMenu
转前端路由树节点
MenuTree——前端组件要求的是全小写字段名(id、pid、name),且部分字段需要从对象转字符串、从
bool 取反:
// VueMenu -> MenuTree:前端路由节点字段全小写,需逐个定制
TypeAdapterConfig<VueMenu, MenuTree>
.NewConfig()
.AfterMapping((src, dest) =>
{
dest.children = new List<MenuTree>();
dest.component = src.Component.ToString();
dest.hidden = !src.Visible; // 取反
dest.meta = new MenuMeta // 组装嵌套对象
{
icon = src.Icon,
link = src.IsFrame ? src.Path : null,
title = src.Text,
noCache = !src.IsCache
};
dest.name = src.Path.ToString(); // int -> string
dest.id = src.Id.ToString();
dest.pid = src.ParentId.ToString();
dest.alwaysShow = src.MenuType == "M"; // 条件表达式
dest.path = src.Path;
});
这段代码展示了 AfterMapping 的三个典型用途:
- 初始化集合属性:
children在映射阶段 Mapster 不会自动创建,手动new一次,后面构建树时直接往里塞; - 字段改名 + 类型转换:
int类型的Id转成前端要的字符串id; - 组装嵌套对象:把散落的
Icon、Text、IsFrame收拢进前端约定的meta对象。
需要注意,AfterMapping 的委托签名是
(src, dest),两个参数都是 object
之外的强类型,写起来和普通 lambda
没区别,但它在每次映射时都会执行,所以里面不要放重逻辑。
反向映射(MenuTree -> VueMenu)也是同样的套路,把字符串
pid 用 Convert.ToInt32 转回
int:
TypeAdapterConfig<MenuTree, VueMenu>
.NewConfig()
.AfterMapping((src, dest) =>
{
dest.Id = Convert.ToInt32(src.id);
dest.ParentId = Convert.ToInt32(src.pid);
dest.Visible = !src.hidden;
dest.IsFrame = src.meta.link != null;
// ...其余字段同理
});
一对配置类互为镜像,这在前端路由表和数据库菜单表之间双向同步的场景里非常常见。
四、Map 表达式:int 与 bool 的双向转换
数据库里布尔值通常存成 0/1 整型,而 C# 实体里是
bool。这类转换用 .Map() 加表达式最直观:
// MenuDto -> VueMenu:数据库整型 0/1 转布尔
TypeAdapterConfig<MenuDto, VueMenu>
.NewConfig()
.Map(dest => dest.IsCache, src => src.IsCache > 0)
.Map(dest => dest.IsDelete, src => src.IsDelete > 0)
.Map(dest => dest.IsFrame, src => src.IsFrame > 0)
.Map(dest => dest.Visible, src => src.Visible > 0);
// VueMenu -> MenuDto:布尔转回整型
TypeAdapterConfig<VueMenu, MenuDto>
.NewConfig()
.Map(dest => dest.IsCache, src => src.IsCache ? 1 : 0)
.Map(dest => dest.IsDelete, src => src.IsDelete ? 1 : 0)
.Map(dest => dest.IsFrame, src => src.IsFrame ? 1 : 0)
.Map(dest => dest.Visible, src => src.Visible ? 1 : 0);
.Map()
的第一个参数是目标属性的表达式,第二个参数是源属性到目标值的转换表达式。相比
AfterMapping,它的优势是:
- 声明式:一眼看清哪些字段有特殊转换;
- 可参与编译期优化:Mapster 会把表达式编译成委托缓存起来,性能接近手写赋值;
- 粒度更细:只覆盖单个字段,不影响其他字段的默认映射。
经验法则:单字段的类型转换用
.Map(),需要多个字段联动或组装新对象的用
AfterMapping。
五、MapWith:扁平列表转三层树形结构
前两种手段都还在”逐字段”的层面,遇到结构级变化——比如把一张平铺的网元类型表转成”网络类型
→ 厂家 → 设备类型”的三层树——就该 MapWith
出场了。它直接替换整个映射函数:
// List<NeTypeView> -> List<NeTreeDto>:整体替换为自定义构建方法
TypeAdapterConfig<List<NeTypeView>, List<NeTreeDto>>
.NewConfig()
.MapWith(dtos => BuildTree(dtos));
调用侧依然是一行
Adapt,使用者完全感知不到背后是一次树构建:
// 服务层调用:扁平视图列表直接映射为树
var trees = viewDtos.Adapt<List<NeTreeDto>>();
BuildTree 的实现思路是三层循环 + Distinct
去重,每层筛选上一层的数据集:
public static List<NeTreeDto> BuildTree(IEnumerable<NeTypeView> dtos)
{
var tree = new List<NeTreeDto>();
// 第一层:网络类型去重
var networkTypes = dtos.Select(s => new
{
NetworkId = s.NetworkTypeId,
NetworkName = s.NetworkTypeName,
NetworkAbbr = s.NetworkTypeAbbreviation,
}).Distinct();
foreach (var gnt in networkTypes)
{
var gntNode = new NeTreeDto
{
Id = gnt.NetworkId,
Label = gnt.NetworkName,
Children = new List<NeTreeDto>(),
Type = 0,
};
// 第二层:当前网络类型下的厂家去重
var manufacturers = dtos
.Where(s => s.NetworkTypeId == gnt.NetworkId)
.Select(s => new { s.VendorId, s.VendorName, VendorAddr = s.VendorAbbreviation })
.Distinct();
foreach (var em in manufacturers)
{
var emNode = new NeTreeDto
{
Id = em.VendorId,
Label = em.VendorName,
Route = $"{gntNode.Label}/{em.VendorName}",
Children = new List<NeTreeDto>(),
Type = 0
};
// 第三层:当前厂家下的设备类型
var deviceTypes = dtos
.Where(s => s.NetworkTypeId == gnt.NetworkId && s.VendorId == em.VendorId)
.Select(s => new { s.DeviceTypeId, s.DeviceTypeName, s.DeviceTypeAbbreviation })
.Distinct();
foreach (var get in deviceTypes)
{
var getNode = new NeTreeDto
{
Id = get.DeviceTypeId,
Label = get.DeviceTypeName,
Route = $"{gntNode.Label}/{em.VendorName}/{get.DeviceTypeName}",
AbbrevGroup = $"{gntNode.Abbrev}_{em.VendorAddr}_{get.DeviceTypeAbbreviation}",
Type = 1,
};
emNode.Children.Add(getNode);
}
gntNode.Children.Add(emNode);
}
tree.Add(gntNode);
}
return tree;
}
整条执行链路可以用一张图概括:
flowchart LR
A[viewDtos 扁平列表] -->|Adapt<List<NeTreeDto>>| B{Mapster 查找配置}
B -->|命中 MapWith| C[BuildTree 自定义构建]
C --> D[第一层: 网络类型 Distinct]
D --> E[第二层: 厂家 Distinct]
E --> F[第三层: 设备类型 Distinct]
F --> G[三层树形结构]
MapWith
的价值在于封装:树构建的复杂度被关在配置类里,业务代码只面对
Adapt 一个动词。如果哪天数据结构变了,也只改
BuildTree 一处。
六、组合技:映射后二次加工
最后看一个实战里很常见的组合:先 Adapt
走通用映射,再对个别字段做上下文相关的修正。比如菜单树需要根据当前用户的权限位(AccessBits)计算勾选状态——这个值不在映射配置里,因为它是调用时才知道的上下文:
var menuDict = entities.ToDictionary(
s => s.Id.ToString(),
s =>
{
var tree = s.Adapt<MenuTree>(); // 通用映射
tree.@checked = (AccessBits & s.AccessBits) > 0; // 上下文相关的位运算
return tree;
});
(AccessBits & s.AccessBits) > 0
是经典的位与判断:权限位集合里包含该菜单的权限标记,则勾选。这类依赖运行时上下文的加工不适合写进全局配置,映射后手动补一行反而更清晰。
七、选型小结
| 手段 | 适用场景 | 粒度 |
|---|---|---|
Adapt<T>() 零配置 |
同名字段直接映射 | 整个对象 |
.Map() 表达式 |
单字段类型转换(int/bool 等) | 单字段 |
AfterMapping |
多字段联动、组装嵌套对象、初始化集合 | 映射后整对象 |
MapWith |
结构级转换(扁平转树、聚合拆分) | 整个映射函数 |
| 映射后手动修正 | 依赖运行时上下文的字段 | 调用处 |
几点实践建议:
- 配置集中注册:所有
TypeAdapterConfig收敛到一个静态类,Program.cs一行调用,多宿主共享; - 优先声明式:能用
.Map()就不用AfterMapping,能AfterMapping就别MapWith,按复杂度递进选择; - 无映射配置的类型对也要
NewConfig():显式注册一遍,能防止未来字段类型变更时隐式行为变化带来的坑; - 注意
AfterMapping的执行频率:每次映射都会跑,里面避免查询数据库等重操作。
对象映射本身没有技术难度,难的是在字段名不一致、类型不一致、结构不一致的现实面前,把转换逻辑放对地方。Mapster 提供了从字段到结构的完整工具链,用好这四个层级,映射代码也能写得又快又干净。