在 .NET 项目开发中,DTO(Data Transfer
Object)与实体之间的转换是家常便饭。手动写 new
和属性赋值不仅繁琐,还容易漏字段。AutoMapper 曾经是最主流的选择,但
Mapster
凭借编译时映射、高性能、零依赖的特点,越来越受到开发者青睐。
最近我在维护 SwitchData
项目时,把菜单权限、用户管理等模块的 DTO 转换逐步迁移到了
Mapster。本文结合项目中的真实代码,分享 Mapster
的三种核心配置策略:简单字段映射(Map)、复杂对象装配(AfterMapping)、自定义转换逻辑(MapWith)。
一、为什么选择 Mapster
Mapster 的核心理念是”约定优于配置”。同名同类型字段会自动映射,需要额外干预时才写配置。相比 AutoMapper:
- 编译期生成映射代码,运行时性能更高;
- 支持
Adapt扩展方法,调用简洁; - 配置 API 直观,不用写大量
CreateMap; - 支持
AfterMapping、MapWith、Ignore等扩展点。
在 SwitchData.Api 的 Program.cs
中,我们在应用启动时统一注册映射配置:
MapsterConfig.Configure();
所有映射规则集中在
SwitchData.Business/Model/MapsterConfig.cs
中管理,避免散落在业务代码里。
二、简单字段映射:bool 与 int 互转
数据库里经常把布尔值存成 tinyint 或
int(0/1),但 DTO 里用 bool 更直观。Mapster
的 Map 方法可以轻松解决这种类型差异。
以菜单模块为例,VueMenu 实体使用 bool
字段,MenuDto 使用 int 字段:
// MenuDto -> VueMenu
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 接收一个
Lambda,直接描述”目标字段如何由源对象计算得出”。这种方式比写两个方向各一个
ValueConverter 更紧凑,也更容易阅读。
三、复杂对象装配:AfterMapping 构建菜单树
菜单系统是典型的”平铺数据转树形结构”场景。数据库里
VueMenu 是一条条记录,前端需要的是 Element UI / Vue Router
风格的 MenuTree 嵌套对象。
VueMenu 和 MenuTree
的字段命名、结构差异很大,仅靠同名自动映射远远不够。我们使用
AfterMapping 在自动映射完成后,再手动装配目标对象:
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();
dest.id = src.Id.ToString();
dest.pid = src.ParentId.ToString();
dest.alwaysShow = src.MenuType == "M";
dest.path = src.Path;
});
AfterMapping 的执行时机在默认字段映射之后,因此:
- 如果字段同名同类型,Mapster 已经自动完成映射;
- 对于命名不同、需要计算、需要创建子对象的场景,我们在 Lambda 里补充。
比如 dest.hidden = !src.Visible
这种语义反转,dest.meta 这种嵌套对象创建,都交给
AfterMapping 处理。
对应的反向映射 MenuTree -> VueMenu
也用同样的思路:
TypeAdapterConfig<MenuTree, VueMenu>
.NewConfig()
.AfterMapping((src, dest) =>
{
dest.Id = Convert.ToInt32(src.id);
dest.ParentId = Convert.ToInt32(src.pid);
dest.Component = src.component;
dest.Visible = !src.hidden;
dest.Icon = src.meta.icon;
dest.IsFrame = src.meta.link != null;
dest.Text = src.meta.title;
dest.IsCache = !src.meta.noCache;
dest.Path = src.path;
dest.MenuType = src.MenuType;
dest.IsDelete = false;
dest.Order = src.Order;
dest.Remark = src.remark;
});
四、自定义转换逻辑:MapWith 组装网元树
比 AfterMapping 更彻底的是
MapWith:完全接管整个对象的创建过程。当目标对象无法通过字段一一对应得到,而需要聚合、分组、递归时,MapWith
是最佳选择。
在 SwitchData 的网元查询模块里,我们需要把
List<NeTypeView> 转成三层树形
List<NeTreeDto>:
- 第一层:网络类型
- 第二层:厂商
- 第三层:设备类型
这个转换涉及分组、拼接路由、设置 AbbrevGroup
等复杂逻辑,用 MapWith 直接调用一个独立方法:
TypeAdapterConfig<List<NeTypeView>, List<NeTreeDto>>
.NewConfig()
.MapWith(dtos => BuildTree(dtos));
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,
Route = gnt.NetworkName,
Abbrev = gnt.NetworkAbbr,
Children = new List<NeTreeDto>(),
AbbrevGroup = gnt.NetworkAbbr,
Type = 0,
};
var manufacturers = dtos
.Where(s => s.NetworkTypeId == gnt.NetworkId)
.Select(s => new { s.VendorId, s.VendorName, s.VendorAbbreviation })
.Distinct();
foreach (var em in manufacturers)
{
var emNode = new NeTreeDto
{
Id = em.VendorId,
Label = em.VendorName,
Route = $"{gnt.NetworkName}/{em.VendorName}",
Abbrev = em.VendorAbbreviation,
Children = new List<NeTreeDto>(),
AbbrevGroup = $"{gnt.NetworkAbbr}_{em.VendorAbbreviation}",
Type = 0,
};
// ... 继续添加设备类型节点
gntNode.Children.Add(emNode);
}
tree.Add(gntNode);
}
return tree;
}
MapWith 让 Mapster
不再尝试自动映射列表里的元素,而是把源集合整体交给自定义方法。这种写法把”映射框架”和”业务算法”的边界划得很清楚:Mapster
负责触发转换,业务代码负责具体构造。
五、业务代码中的调用方式
配置好之后,业务代码里只需要一行:
// 单个对象转换
var tree = vueMenu.Adapt<MenuTree>();
// 列表转换
var menuDtos = webMenus.Select(s => s.Adapt<MenuDto>()).ToList();
// 映射到已有实例(保留上下文)
var entity = dto.Adapt(user);
在 MenuService.BuildMenuTree 中,我们先通过
Adapt 把数据库实体转成前端树节点,再用字典做父子关联:
var menuDict = webMenus.ToDictionary(
s => s.Id.ToString(),
s => s.Adapt<MenuTree>());
foreach (var menu in menuDict.Values.OrderBy(o => o.Order))
{
if (string.IsNullOrEmpty(menu.pid) || menu.pid == "0")
{
rootMenus.Add(menu);
}
else
{
var parent = menuDict[menu.pid];
parent.children.Add(menu);
}
}
可以看到,映射与树构建完全解耦:Mapster 负责”形状转换”,业务代码负责”结构组装”。
六、Mapster 工作流程
flowchart LR
A[源对象 Source] --> B{存在 MapWith?}
B -->|是| C[执行自定义方法]
B -->|否| D[自动映射同名字段]
D --> E{存在 Map 配置?}
E -->|是| F[应用自定义字段映射]
E -->|否| G[保留自动映射结果]
F --> H[执行 AfterMapping]
G --> H
C --> I[目标对象 Destination]
H --> I
七、实践建议
- 配置集中管理:像
MapsterConfig一样,把所有TypeAdapterConfig放在统一入口,避免业务代码里到处是NewConfig。 - 区分 Map / AfterMapping / MapWith:
- 单个字段计算用
Map; - 多个字段、嵌套对象、语义反转用
AfterMapping; - 完全自定义创建对象用
MapWith。
- 单个字段计算用
- 注意可空类型和枚举:转换失败时优先检查源/目标字段类型是否一致。
- 单元测试覆盖映射配置:迁移到 Mapster 后,建议对关键 DTO 转换写单元测试,防止字段遗漏。
八、总结
Mapster 不是 AutoMapper
的简单替代品,它在配置简洁度和运行时性能上都有明显优势。通过
Map、AfterMapping、MapWith
三种机制,可以覆盖从简单字段转换到复杂树形结构构建的大部分场景。
在 SwitchData 项目中,我们用 Mapster
把菜单、用户、网元等模块的 DTO
转换统一起来,既减少了样板代码,也让映射规则变得可维护。如果你的项目还在手写
new Dto { ... },不妨试试 Mapster。