在 .NET 项目开发中,实体模型(Entity)数据传输对象(DTO)前端视图模型(ViewModel) 之间的转换几乎是家常便饭。手写 new + 逐个字段赋值不仅繁琐,还容易遗漏。此时,一款轻量、高性能的对象映射库就显得尤为重要。

今天介绍的是 Mapster,它比 AutoMapper 更轻量、编译时性能更高,并且支持丰富的映射策略。下面以 SwitchData 项目中的实际用法为例,带你从基础配置走到复杂树形结构转换。

一、为什么选择 Mapster?

Mapster 的核心优势可以概括为三点:

  1. 高性能:基于表达式树编译,运行时性能接近手写代码。
  2. 零依赖:单个 NuGet 包即可工作,不需要额外的 DI 扩展包(虽然也有)。
  3. 配置灵活:支持 MapAfterMappingMapWithIgnore 等多种映射方式,复杂场景也能覆盖。

在 SwitchData.Api 的 Program.cs 中,对象映射的初始化只有一行:

// 6. 对象映射
MapsterConfig.Configure();

所有的映射规则都被收敛到 MapsterConfig 静态类中,便于集中管理和维护。

二、集中式配置:一个类管理所有映射

SwitchData 采用了集中式配置的方式。通过 TypeAdapterConfig<TSource, TDestination>.NewConfig() 定义源类型到目标类型的映射规则。

2.1 简单字段同名映射

当源类型和目标类型的字段名称、类型基本一致时,Mapster 可以自动完成映射:

// User -> UserDto
TypeAdapterConfig<User, UserDto>.NewConfig();

调用时只需要一行:

var userDto = user.Adapt<UserDto>();

2.2 字段类型不一致时的显式映射

在实际业务中,数据库常以 int 存储布尔标志位(0 和 1),而 DTO 层希望直接使用 bool。Mapster 的 Map 方法可以轻松处理这种转换:

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

反过来,boolint 也同样简单:

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

这种方式比手写 if 判断要清晰得多,也更容易维护。

三、AfterMapping:映射完成后的二次加工

有些字段无法通过简单的名称对应或表达式完成,例如需要初始化集合、拼接字符串,或者组合多个源字段生成目标字段。这时可以使用 AfterMapping

在 SwitchData 中,Vue 前端需要的菜单树 MenuTree 与后端实体 VueMenu 结构差异较大:

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

这里的关键点:

  • children 必须初始化为空列表,否则后续构建树形结构时会报空引用异常。
  • hiddennoCache 等字段是语义反转的,需要在映射时取反。
  • meta 是嵌套对象,需要单独构造。

AfterMapping 相当于映射完成后的”收尾工作”,非常适合处理这种结构差异大的场景。

四、MapWith:完全自定义的映射逻辑

当源集合和目标结构根本不在同一个维度上时,例如把数据库查询出来的扁平列表转换成前端需要的多级树形结构,简单的字段映射已经不够用了。Mapster 提供了 MapWith 来自定义整个转换逻辑。

在 SwitchData 中,NeTypeView 是一个扁平列表,每个记录包含网络类型、厂商、设备类型等信息。前端组件需要的是一棵树:

graph TD
    A[网络类型] --> B[厂商]
    B --> C[设备类型]

Mapster 配置如下:

TypeAdapterConfig<List<NeTypeView>, List<NeTreeDto>>
    .NewConfig()
    .MapWith(dtos => BuildTree(dtos));

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,
            Children = new List<NeTreeDto>(),
            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}",
                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)
            {
                emNode.Children.Add(new NeTreeDto
                {
                    Id = get.DeviceTypeId,
                    Label = get.DeviceTypeName,
                    Route = $"{gnt.NetworkName}/{em.VendorName}/{get.DeviceTypeName}",
                    Type = 1
                });
            }

            gntNode.Children.Add(emNode);
        }

        tree.Add(gntNode);
    }

    return tree;
}

使用时依然是一行调用:

var tree = neTypeViews.Adapt<List<NeTreeDto>>();

通过 MapWith,我们把”映射规则”和”业务组装逻辑”解耦:Mapster 负责触发映射,而复杂的树形构建交给专门的业务方法处理。

五、使用建议与注意事项

  1. 配置尽早执行MapsterConfig.Configure() 应在应用启动时调用一次,确保所有映射规则在第一次 Adapt 前已注册。
  2. 双向映射分别配置A -> BB -> A 的映射规则需要分别声明,Mapster 不会自动推导反向映射。
  3. 避免在 AfterMapping 中做重型计算:后处理适合字段赋值和初始化,复杂计算建议用 MapWith 或外部服务。
  4. 善用 Adapt 扩展方法:只要引用了 Mapster 命名空间,任何对象都可以直接调用 .Adapt<T>(),非常自然。

六、总结

Mapster 是一款值得在 .NET 项目中认真考虑的映射工具。从简单的同名字段映射,到 int/bool 类型转换,再到 AfterMapping 后处理,以及 MapWith 自定义复杂映射,它都能胜任。

在 SwitchData 项目中,菜单模型转换和网元类型树构建都得益于 Mapster 的灵活配置。如果你的项目还在为 DTO/实体/视图模型之间的转换写大量样板代码,不妨试试 Mapster。