在 .NET 项目中,对象映射是层间数据传递的日常操作。AutoMapper 久负盛名,但 Mapster 以更轻量、更快的性能和更灵活的 API 赢得了不少开发者的青睐。本文从 SwitchData 项目中提取三个真实场景,深入讲解 Mapster 的 AfterMapping 后映射钩子Map 条件映射MapWith 自定义映射函数,最后展示如何将扁平数据一键转为树形结构。

一、Mapster 简介

Mapster 是一个高性能的 .NET 对象映射库,核心特点:

  • 编译时生成映射代码,性能接近手写赋值
  • 链式 API,配置直观易读
  • 支持 AfterMappingMapWithInclude 等高级特性
  • 支持双向映射和自定义转换规则
<!-- NuGet 安装 -->
<PackageReference Include="Mapster" Version="7.4.0" />

二、AfterMapping:映射后的钩子操作

场景说明

在 SwitchData 中,VueMenu 是数据库实体,MenuTree 是前端路由树节点。两者字段名和类型差异较大:MenuTree 需要嵌套的 meta 对象、children 列表,以及从 bool 到显示逻辑的转换。这种复杂的属性组装,正是 AfterMapping 的用武之地。

代码实现

// 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();
        dest.id = src.Id.ToString();
        dest.pid = src.ParentId.ToString();
        dest.alwaysShow = src.MenuType == "M";
        dest.path = src.Path;
    });

要点解析

  1. AfterMapping 在默认映射之后执行,可以覆盖已映射的值,也可以补充默认映射无法处理的属性
  2. meta 是嵌套对象,Mapster 默认不会自动创建,在 AfterMapping 中手动实例化并赋值
  3. hidden = !src.VisiblenoCache = !src.IsCache 这种取反逻辑,在 AfterMapping 中一目了然
  4. link = src.IsFrame ? src.Path : null 条件赋值,比 .Map() 更适合复杂分支

反向映射同理

// 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;
    });

AfterMapping 的本质:当源和目标的属性不是简单的同名对应,或者需要额外的逻辑处理时,AfterMapping 提供了一个”映射后干预点”,让你在默认映射结果上做二次加工。

三、Map 条件映射:类型转换的一对一映射

场景说明

MenuDto 继承自 VueMenu,但数据库中 IsCacheIsDeleteIsFrameVisibleint 类型(0/1),而 VueMenu 中是 bool。Mapster 默认不会做 int <-> bool 转换,需要用 .Map() 显式指定。

正向映射:int -> bool

// MenuDto -> VueMenu:数据库 int 转 bool
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);

反向映射:bool -> int

// VueMenu -> MenuDto:bool 转回 int
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);

要点解析

  1. .Map(dest, src) 是 Mapster 最基础的自定义映射,适合一对一的属性转换
  2. 当源类型和目标类型不兼容(如 int <-> bool),必须显式 .Map()
  3. 链式调用 .Map().Map().Map() 让配置紧凑且可读
  4. 正反两个方向的映射要分别配置,Mapster 不会自动推断反向规则

Map vs AfterMapping 的选择:如果只是单个属性的简单转换,用 .Map();如果涉及多属性组合、嵌套对象创建,用 AfterMapping。

四、MapWith + BuildTree:扁平数据一键转树

场景说明

NeTypeView 是数据库视图返回的扁平记录,每行包含网络类型、厂商、设备类型、网元类型四级信息。前端需要的是树形结构:网络类型 -> 厂商 -> 设备类型,逐级嵌套。Mapster 的 MapWith 可以直接指定整个映射函数。

MapWith 配置

// 将扁平列表映射为树形列表
TypeAdapterConfig<List<NeTypeView>, List<NeTreeDto>>
    .NewConfig()
    .MapWith(dtos => BuildTree(dtos));

一行配置,将 List<NeTypeView>List<NeTreeDto> 的映射委托给 BuildTree 方法。

BuildTree 实现

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
            {
                VendorId = s.VendorId,
                VendorName = 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}",
                Abbrev = em.VendorAddr,
                Children = new List<NeTreeDto>(),
                AbbrevGroup = $"{gnt.NetworkAbbr}_{em.VendorAddr}",
                Type = 0
            };

            // 第三层:当前厂商下的设备类型
            var deviceTypes = dtos
                .Where(s => s.NetworkTypeId == gnt.NetworkId 
                         && s.VendorId == em.VendorId)
                .Select(s => new
                {
                    s.DeviceTypeId,
                    s.DeviceTypeName,
                    DeviceTypeAbbr = s.DeviceTypeAbbreviation
                }).Distinct();

            foreach (var get in deviceTypes)
            {
                var getNode = new NeTreeDto
                {
                    Id = get.DeviceTypeId,
                    Label = get.DeviceTypeName,
                    Route = $"{gntNode.Label}/{emNode.Label}/{get.DeviceTypeName}",
                    Abbrev = get.DeviceTypeAbbr,
                    AbbrevGroup = $"{gnt.NetworkAbbr}_{em.VendorAddr}_{get.DeviceTypeAbbr}",
                    Children = new List<NeTreeDto>(),
                    Type = 1,
                };
                emNode.Children.Add(getNode);
            }
            gntNode.Children.Add(emNode);
        }
        tree.Add(gntNode);
    }
    return tree;
}

要点解析

  1. MapWith 绕过默认映射引擎,直接执行自定义函数,适合整体结构变化的场景
  2. 三层嵌套循环 + LINQ 分组去重:用 Distinct() 对匿名类型去重,避免重复节点
  3. Route 路径累积:子节点 Route 拼接父节点路径,如 5GC/华为/AAU
  4. AbbrevGroup 编码累积:用下划线连接各级缩写,如 5GC_HW_AAU,用于前端快速定位
  5. Type 标记叶子节点Type = 0 表示分支,Type = 1 表示叶子,前端据此渲染不同图标

五、映射配置的注册与使用

在启动时注册

// Program.cs 或 Startup.cs
MapsterConfig.Configure();

使用映射

// 简单映射
var menuTree = vueMenu.Adapt<MenuTree>();

// 列表映射(走 MapWith 配置)
var tree = neTypeViews.Adapt<List<NeTreeDto>>();

// 反向映射
var vueMenu = menuTree.Adapt<VueMenu>();

.Adapt<T>() 是 Mapster 的扩展方法,根据已注册的 TypeAdapterConfig 自动选择映射策略。

六、三种映射方式对比

特性 .Map() AfterMapping MapWith
适用场景 单属性类型转换 多属性组合赋值、嵌套对象 整体结构变换
执行时机 映射过程中 默认映射之后 替代默认映射
性能 编译时生成,最快 默认映射 + 钩子,次之 自定义函数,取决于实现
配置复杂度
典型用例 bool <-> int 创建嵌套对象 扁平转树形

七、总结

Mapster 的三种高级映射特性各有定位:

  • Map:处理属性级的类型不匹配,一行配置搞定 int <-> bool 转换
  • AfterMapping:处理对象级的后置逻辑,适合创建嵌套对象、组合赋值、条件分支
  • MapWith:处理结构级的整体变换,将扁平列表一键转为树形结构

在实际项目中,三者常常组合使用:Map 处理简单类型转换,AfterMapping 处理复杂属性组装,MapWith 处理结构性变换。SwitchData 项目正是这种组合的典型实践——菜单管理用 Map + AfterMapping,网元管理用 MapWith + BuildTree,各取所长,代码清晰且高性能。