在后端开发中,对象之间的字段搬运是最高频也最枯燥的工作:数据库实体转 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 的三个典型用途:

  1. 初始化集合属性:children 在映射阶段 Mapster 不会自动创建,手动 new 一次,后面构建树时直接往里塞;
  2. 字段改名 + 类型转换:int 类型的 Id 转成前端要的字符串 id;
  3. 组装嵌套对象:把散落的 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&lt;List&lt;NeTreeDto&gt;&gt;| 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 结构级转换(扁平转树、聚合拆分) 整个映射函数
映射后手动修正 依赖运行时上下文的字段 调用处

几点实践建议:

  1. 配置集中注册:所有 TypeAdapterConfig 收敛到一个静态类,Program.cs 一行调用,多宿主共享;
  2. 优先声明式:能用 .Map() 就不用 AfterMapping,能 AfterMapping 就别 MapWith,按复杂度递进选择;
  3. 无映射配置的类型对也要 NewConfig():显式注册一遍,能防止未来字段类型变更时隐式行为变化带来的坑;
  4. 注意 AfterMapping 的执行频率:每次映射都会跑,里面避免查询数据库等重操作。

对象映射本身没有技术难度,难的是在字段名不一致、类型不一致、结构不一致的现实面前,把转换逻辑放对地方。Mapster 提供了从字段到结构的完整工具链,用好这四个层级,映射代码也能写得又快又干净。