在 .NET 项目开发中,DTO(Data Transfer Object)与实体之间的转换是家常便饭。手动写 new 和属性赋值不仅繁琐,还容易漏字段。AutoMapper 曾经是最主流的选择,但 Mapster 凭借编译时映射、高性能、零依赖的特点,越来越受到开发者青睐。

最近我在维护 SwitchData 项目时,把菜单权限、用户管理等模块的 DTO 转换逐步迁移到了 Mapster。本文结合项目中的真实代码,分享 Mapster 的三种核心配置策略:简单字段映射(Map)复杂对象装配(AfterMapping)自定义转换逻辑(MapWith)

一、为什么选择 Mapster

Mapster 的核心理念是”约定优于配置”。同名同类型字段会自动映射,需要额外干预时才写配置。相比 AutoMapper:

  • 编译期生成映射代码,运行时性能更高;
  • 支持 Adapt 扩展方法,调用简洁;
  • 配置 API 直观,不用写大量 CreateMap
  • 支持 AfterMappingMapWithIgnore 等扩展点

SwitchData.ApiProgram.cs 中,我们在应用启动时统一注册映射配置:

MapsterConfig.Configure();

所有映射规则集中在 SwitchData.Business/Model/MapsterConfig.cs 中管理,避免散落在业务代码里。

二、简单字段映射:bool 与 int 互转

数据库里经常把布尔值存成 tinyintint(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 嵌套对象。

VueMenuMenuTree 的字段命名、结构差异很大,仅靠同名自动映射远远不够。我们使用 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

七、实践建议

  1. 配置集中管理:像 MapsterConfig 一样,把所有 TypeAdapterConfig 放在统一入口,避免业务代码里到处是 NewConfig
  2. 区分 Map / AfterMapping / MapWith
    • 单个字段计算用 Map
    • 多个字段、嵌套对象、语义反转用 AfterMapping
    • 完全自定义创建对象用 MapWith
  3. 注意可空类型和枚举:转换失败时优先检查源/目标字段类型是否一致。
  4. 单元测试覆盖映射配置:迁移到 Mapster 后,建议对关键 DTO 转换写单元测试,防止字段遗漏。

八、总结

Mapster 不是 AutoMapper 的简单替代品,它在配置简洁度和运行时性能上都有明显优势。通过 MapAfterMappingMapWith 三种机制,可以覆盖从简单字段转换到复杂树形结构构建的大部分场景。

SwitchData 项目中,我们用 Mapster 把菜单、用户、网元等模块的 DTO 转换统一起来,既减少了样板代码,也让映射规则变得可维护。如果你的项目还在手写 new Dto { ... },不妨试试 Mapster。