替換映射工具 - 使用 Mapster

  • 303
  • 0
  • C#
  • 2025-04-13

其實四年前就已經將手邊專案由原本所使用的 AutoMapper 以 Mapster 取代了。

起初的原因是效能考量,因為 AutoMapper 的效能一直被人詬病,但也因為 AutoMapper 的優點在於功能豐富、配置設定靈活,能夠處理複雜的 Mapping 需求,以致於我在帶新人的時候還是以 AutoMapper 為主,但是前些日子得知 AutoMapper 在之後也將走上商業化(跟 Fluent Assertions 一樣),所以就藉此來寫篇文章簡單介紹 Mapster。

AutoMapper 要轉為商業授權

某天在一堆專案的商業邏輯連番轟炸後的下班歸途中,開啟手機滑著 Facebook 的訊息,看看最近同溫層有沒有什麼新鮮事,然後就在「Cash Wu Geek」看到了這一篇

AutoMapper 作者 Jimmy Board  的文章

相關連結:

也因此現在應該有很多人開始準備要將手邊專案裡所使用的 AutoMapper 給替換掉,但 Mapping  工具有很多種,可以從以下的連結查到有很多種

例如:Mapster, Mapperly, AgileMapper, ExpressMapper, TinyMapper, ValueInjecter, EmitMapper 等

當然還有很多人是認為使用 Mapping 工具才是拖垮性能的根本原因之一(當然還有其他原因),所以就使用手動寫 Mapping (Manaul Map)  的方式。

而我當時從 AutoMapper 要做轉換也試過了很多種套件,在各種評估與考量後就選擇使用 Mapster,以下就簡單介紹怎麼使用 Mapster。

 

使用 Mapster

README  下方有個效能的比較表(不過這張表也看了好多年,不知道目前是否還是這樣呢?)

Mapster 的基本使用方式,就請各位自己去看 Wiki  文件:

其實 Ian Chen 早在 2020/03 的時候就有寫過一篇文章做介紹

另外也列幾篇簡體中文的文章(因為正體中文有介紹 Mapster 實在不多)

其他連結:

 

我大部分的 Mapping 轉換設定都不是很複雜,所以大部分都是簡單的使用:

using Mapster;
using Sample.Domain.Entities;
using Sample.Service.Dto;

namespace Sample.Service.MapConfig;

public class ServiceMapRegister : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<ShipperModel, ShipperDto>().TwoWays();
    }
}
using Mapster;
using Sample.Service.Dto;
using Sample.WebApplication.Models.InputParameters;
using Sample.WebApplication.Models.OutputModels;

namespace Sample.WebApplication.Infrastructure.MapConfig;

/// <summary>
/// class WebApplicationMapRegister
/// </summary>
public class WebApplicationMapRegister : IRegister
{
    /// <summary>
    /// Register
    /// </summary>
    /// <param name="config">config</param>
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<ShipperDto, ShipperOutputModel>();

        config.NewConfig<ShipperParameter, ShipperDto>()
              .Map(d => d.CompanyName, s => s.CompanyName)
              .Map(d => d.Phone, s => s.Phone);
    }
}

以一般情境的設定使用方式其實與 AutoMapper 差不了多少。

 

Mapster 的 Dependency Injection 設定

雖然 Mapper.DependencyInjection 有個 AddMapster() 靜態擴充方法,但這個方法就只是個簡單的方法

僅靠這個方法是無法有效處理 Mapster 的相依性注入設定,所以 Mapster 有在 Wiki 裡寫了怎麼設定的文件:

記得要安裝 Mapster.DependencyInjection 這個 NuGet 套件

而我們要安裝 Mapster.DependencyInjection 套件,最主要是要註冊 TypeAdapterConfigServiceMapper,例如我在專案裡的設定如下:

我分別在 Service 與 WebApplication 專案裡會去繼承實作 IRegister 的 ServiceMapRegister 與 WebApplicationMapRegister 類別,那麼就需要將這兩個類別的 Assembly 讓 TypeAdapterConfig 去掃描以抓出有繼承實作 IRegister 的類別,然後必須將 TypeAdapterConfig instance 注入設定的 Lifetime 必須為 Singleton

/// <summary>
/// Class MapsterServiceCollectionExtensions
/// </summary>
public static class MapsterServiceCollectionExtensions
{
    /// <summary>
    /// Add Mapster
    /// </summary>
    /// <param name="services">services</param>
    /// <returns></returns>
    public static IServiceCollection AddMapster(this IServiceCollection services)
    {
        var config = new TypeAdapterConfig();

        var serviceAssembly = typeof(ServiceMapRegister).Assembly;
        var webAssembly = typeof(WebApplicationMapRegister).Assembly;

        config.Scan(serviceAssembly);
        config.Scan(webAssembly);

        services.AddSingleton(config);
        services.AddScoped<IMapper, ServiceMapper>();

        return services;
    }
}

為什麼需要 Mapster.DependencyInjection?

在一個使用相依性注入(DI)的 ASP.NET Core 專案中,原生 Mapster 只有提供了簡單的 AddMapster() 方法(基本上只是註冊 IMapper 的實作),而 Mapster.DependencyInjection 則提供了更進一步的支援,使我們能夠利用 DI 容器注入映射器,同時方便地管理映射配置。

ServiceMapper 是什麼?有何功能?

ServiceMapper 是 Mapster.DependencyInjection 提供的 IMapper 實作,它的主要功能如下:

  1. 封裝映射配置
    ServiceMapper 持有一個 TypeAdapterConfig 實例,該配置集中管理所有映射規則,並依據這些規則執行對象轉換。
  2. 整合依賴注入
    透過 DI 容器註冊 ServiceMapper(通常採用 Scoped 生命週期),使得在專案中可以直接注入並使用 IMapper,並且 ServiceMapper 可借助 DI 解決映射過程中其他服務的依賴問題
  3. 提高維護性與一致性
    將映射配置與轉換邏輯集中管理,降低了映射規則散佈在各個地方的風險,這對於大型專案及持續演進的系統來說非常重要。

 

改寫 MapsterServiceCollectionExtensions  靜態擴充類別的 AddMapSter 方法

上面的 MapsterServiceCollectionExtensions 的 AddMapSter 方法實際上已經是可以使用了,但是會想要改寫的其中一個原因是方法名稱與 Mapster 所提供的 AddMapster 方法名稱相近而容易造成混淆,另外就是希望將方法裡直接指定 Assembly 的部分改為從 Assemblies 裡掃描載入,並且也想要保留可以指定過濾 Assembly 名稱條件的方式。

實作 AssemblyHelper 類別以及 GetReferencedAssemblies 方法

using System.Reflection;
using Microsoft.Extensions.DependencyModel;

namespace Sample.WebApplication.Infrastructure.Helpers;

/// <summary>
/// class AssemblyHelper
/// </summary>
public static class AssemblyHelper
{
    /// <summary>
    /// 取得符合指定前綴或後綴關鍵字的 Assembly 清單
    /// </summary>
    /// <param name="prefixNames">Assembly 名稱前綴字元陣列(如果為 null 或空陣列,則不做前綴篩選)</param>
    /// <param name="suffixNames">Assembly 名稱後綴字元陣列(如果為 null 或空陣列,則不做後綴篩選)</param>
    /// <returns>符合條件的 Assembly 清單</returns>
    public static IEnumerable<Assembly> GetReferencedAssemblies(
        IEnumerable<string> prefixNames = null,
        IEnumerable<string> suffixNames = null)
    {
        var assemblies = DependencyContext.Default.RuntimeLibraries
                                          .Where(library => IsMatch(library.Name, prefixNames, suffixNames))
                                          .Select(library =>
                                          {
                                              try
                                              {
                                                  return Assembly.Load(new AssemblyName(library.Name));
                                              }
                                              catch
                                              {
                                                  // 若無法載入,則忽略此 Assembly
                                                  return null;
                                              }
                                          })
                                          .Where(assembly => assembly is not null)
                                          .ToList();

        return assemblies;
    }

    /// <summary>
    /// 檢查是否符合
    /// </summary>
    /// <param name="libraryName">libraryName</param>
    /// <param name="prefixNames">prefixNames</param>
    /// <param name="suffixNames">suffixNames</param>
    /// <returns></returns>
    private static bool IsMatch(string libraryName, IEnumerable<string> prefixNames, IEnumerable<string> suffixNames)
    {
        // 檢查前綴條件:若未指定則視同通過
        var matchPrefix = prefixNames is null
                          || !prefixNames.Any()
                          || prefixNames.Any(prefix => libraryName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));

        // 檢查後綴條件:若未指定則視同通過
        var matchSuffix = suffixNames is null
                          || !suffixNames.Any()
                          || suffixNames.Any(suffix => libraryName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));

        return matchPrefix && matchSuffix;
    }
}

這裡的 GetReferencedAssemblies 方法利用 DependencyContext.Default.RuntimeLibraries 來取得目前參考的 Assembly,再根據設定的前綴與後綴篩選出特定的組件(如果有設定)。

為何不是使用 AppDomain.CurrentDomain.GetAssemblies()  而是使用 DependencyContext.Default.RuntimeLibraries?

AppDomain.CurrentDomain.GetAssemblies() 的限制:

當使用 AppDomain.CurrentDomain.GetAssemblies() 時,只有目前已經載入到 AppDomain 中的 Assembly 會被回傳。如果某個專案(例如 Sample.Service)雖被 Sample.WebApplication 參照,但在執行期間沒有任何程式碼使用到它,則該 Assembly 可能不會被自動載入,就不會出現在 GetAssemblies() 的結果中。

DependencyContext.Default.RuntimeLibraries 的特性:

DependencyContext.Default.RuntimeLibraries 是從編譯產生的 .deps.json 檔案裡讀取參考資訊,會列出所有專案所依賴的類別庫,無論它們是否實際被載入。因此,使用 DependencyContext 可以得到全部參考(包括 Sample.Service 以及所有 NuGet 套件)的列表(但這也可能造成你擷取到許多不必要的外部組件)。

相關連結:

修改後的 MapsterServiceCollectionExtensions  靜態擴充類別與 AddMapsterDependencyInjection 方法

using System.Reflection;
using Mapster;
using MapsterMapper;
using Sample.WebApplication.Infrastructure.Helpers;

namespace Sample.WebApplication.Infrastructure.ServiceCollections;

/// <summary>
/// Class MapsterServiceCollectionExtensions
/// </summary>
public static class MapsterServiceCollectionExtensions
{
    /// <summary>
    /// Add the Mapster Dependency Injection
    /// </summary>
    /// <param name="services">services</param>
    /// <returns></returns>
    public static IServiceCollection AddMapsterDependencyInjection(this IServiceCollection services)
    {
        var config = new TypeAdapterConfig();
        
        // 利用 AssemblyHelper 取得符合指定前綴與後綴的 Assembly
        var assemblies = AssemblyHelper.GetReferencedAssemblies(
            prefixNames: ["Sample"],
            suffixNames: ["Service", "WebApplication"]);
        
        // 也可以不指定前綴字、後綴字,直接取得
        // var assemblies = AssemblyHelper.GetReferencedAssemblies();

        // 進一步過濾出至少包含一個實作 IRegister 的 Assembly
        var containsIRegisterImplementationAssemblies = assemblies.Where(ContainsIRegisterImplementation).ToArray();

        // 掃描這些 Assembly 以注冊 Mapster 的映射設定
        config.Scan(containsIRegisterImplementationAssemblies);

        services.AddSingleton(config);
        services.AddScoped<IMapper, ServiceMapper>();

        return services;
    }

    /// <summary>
    /// 過濾至少包含一個實作 IRegister 的類別的 Assembly
    /// </summary>
    /// <param name="assembly">The assembly</param>
    /// <returns></returns>
    private static bool ContainsIRegisterImplementation(Assembly assembly)
    {
        try
        {
            return assembly.GetTypes()
                           .Any(t => typeof(IRegister).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
        }
        catch (ReflectionTypeLoadException ex)
        {
            return ex.Types
                     .Where(t => t is not null)
                     .Any(t => typeof(IRegister).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
        }
    }
}

在 AddMapsterDependencyInjection 方法裡可以使用 AssemblyHelper.GetReferencedAssemblies 方法取得 Assemblies (不論是有使用過濾條件或不輸入條件),會再透過 ContainsIRegisterImplementation 方法進一步確定這些組件裡至少有一個類別有實作 IRegister 介面,最後只對有包含實作 IRegister 的 Assembly 去做掃描和註冊 Mapping 設定。

最後在 Program.cs 裡使用 AddMapsterDependencyInjection 方法就完成 DI 設定

 

測試專案的 Mapster 處理

在過去的文章「使用 AutoFixture.AutoData 來改寫以前的的測試程式碼」裡就曾經有介紹過怎麼在使用 AutoFixture.AutoData 去解決 Mapster 的相依注入,可以在文章裡尋找關鍵字「MapsterMapperCustomization」就可以看到,這邊也直接貼出程式碼

using AutoFixture;
using Mapster;
using MapsterMapper;
using Sample.Service.MapConfig;

namespace Sample.ServiceTests.AutoFixtureConfigurations;

/// <summary>
/// class MapsterMapperCustomization
/// </summary>
public class MapsterMapperCustomization : ICustomization
{
    /// <summary>
    /// Customizes the fixture
    /// </summary>
    /// <param name="fixture">The fixture</param>
    public void Customize(IFixture fixture)
    {
        fixture.Register(() => this.Mapper);
    }

    private IMapper _mapper;

    private IMapper Mapper
    {
        get
        {
            if (this._mapper is not null)
            {
                return this._mapper;
            }

            var typeAdapterConfig = new TypeAdapterConfig();
            typeAdapterConfig.Scan(typeof(ServiceMapRegister).Assembly);
            this._mapper = new Mapper(typeAdapterConfig);
            return this._mapper;
        }
    }
}

 

對於 Mapping Tools 的思考

儘管 Mapster、AutoMapper 等工具能夠大幅減少手寫 Mapping 程式碼,但也有不少聲音主張不要過度依賴映射工具。以下是一些常見觀點:

  • 性能隱憂
    Mapping Tools 透過反射或動態產生程式碼來實現,當處理大量資料或在高併發環境下,可能會成為系統瓶頸。
  • 隱性錯誤
    Mapping Tools 將邏輯隱藏在配置與預設規則後,當 Model  類別變更時,常常在編譯期間無法捕捉錯誤,而只能在執行期間才出現映射失敗或資料錯亂的狀況。
  • 可讀性與維護性
    自動映射雖然能夠節省程式碼,但映射規則常常分散且有時難以閱讀,使得新團隊成員理解業務邏輯時會需要花費較多心力。

所以對於要不要使用 AutoMapper 或 Mapster 這類的 Mapping 工具,團隊與開發者要自己考量利弊。

 

後記

有關各個 Mapping 工具的效能比較,除了 Mapster 在 Github Repo 裡所提供那張表之外,也另外找到了一篇文章,而且還有提供原始碼,所以有興趣的可以去做個瞭解。

source code   專案為 .NET 5,專案裡面所使用的 AutoMapper 版本為 11.0.1,Mapster 的版本為 7.3.0,Expressmapper 為 1.9.1

由上面的表來看,Mapster 搭配 Code Generation  的版本所跑出來的效能還比手動來得快

問了 ChatGPT 後,給我以下的回覆

然後這是另外一篇 Performance 的比較,把幾個主要的 Mapping 工具都做了比較

 

看了上面的比較後,或許也可以參考看看 Mapperly

一開始就直接說「Mapperly is a .NET source generator for generating object mappings.」

但不知道用 Mapster.Tool 採用 Code Ganarator 與 Mapperly  的比較會是誰的效能會比較好呢?

以後再來好好研究研究…

 

最後補個 AssemblyHelper 類別裡 GetReferencedAssemblies 方法的單元測試

using FluentAssertions;
using Sample.WebApplication.Infrastructure.Helpers;

namespace Sample.WebApplicationTests.Infrastructure.Helpers;

public class AssemblyHelperTests
{
    [Fact]
    public void GetReferencedAssemblies_方法未未帶入前綴與後綴關鍵字_應回傳所有參考的組件()
    {
        // act
        var actual = AssemblyHelper.GetReferencedAssemblies();

        // assert
        actual.Should().NotBeNull();
        actual.Should().NotBeEmpty();
    }

    [Fact]
    public void GetReferencedAssemblies_帶入不存在的前綴名稱_應回傳空的集合()
    {
        // arrange
        const string prefixName = "Impossible";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [prefixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().BeEmpty();
    }

    [Fact]
    public void GetReferencedAssemblies_帶入不存在的後綴名稱_應回傳空的集合()
    {
        // arrange
        const string suffixName = "Impossible";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [suffixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().BeEmpty();
    }

    [Fact]
    public void GetReferencedAssemblies_前綴字帶入System_應回傳名稱前綴符合System的組件()
    {
        // arrange
        const string prefixName = "System";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [prefixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().NotBeEmpty();
        actual.All(x => x.GetName().Name.StartsWith(prefixName, StringComparison.OrdinalIgnoreCase)).Should().BeTrue();
    }

    [Fact]
    public void GetReferencedAssemblies_後綴字帶入Core_應回傳名稱結尾符合Core的組件()
    {
        // arrange
        const string suffixName = "Core";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(suffixNames: [suffixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().NotBeEmpty();
        actual.All(x => x.GetName().Name.EndsWith(suffixName, StringComparison.OrdinalIgnoreCase)).Should().BeTrue();
    }

    [Fact]
    public void GetReferencedAssemblies_前綴字帶入System_後綴字帶入Core_應回傳空的集合()
    {
        // arrange
        const string prefixName = "System";
        const string suffixName = "Core";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [prefixName], suffixNames: [suffixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().BeEmpty();
    }

    [Fact]
    public void GetReferencedAssemblies_前綴字帶入Sample_後綴字帶入Service_集合應只有一個組件()
    {
        // arrange
        const string prefixName = "Sample";
        const string suffixName = "Service";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [prefixName], suffixNames: [suffixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().NotBeEmpty();
        actual.Should().HaveCount(1);
        actual.All(x =>
              {
                  var name = x.GetName().Name;
                  return name.StartsWith(prefixName, StringComparison.OrdinalIgnoreCase)
                         && name.EndsWith(suffixName, StringComparison.OrdinalIgnoreCase);
              })
              .Should().BeTrue();
    }
    
    [Fact]
    public void GetReferencedAssemblies_前綴字帶入Sample_後綴字帶入WebApplication_集合應只有一個組件()
    {
        // arrange
        const string prefixName = "Sample";
        const string suffixName = "WebApplication";

        // act
        var actual = AssemblyHelper.GetReferencedAssemblies(prefixNames: [prefixName], suffixNames: [suffixName]);

        // assert
        actual.Should().NotBeNull();
        actual.Should().NotBeEmpty();
        actual.Should().HaveCount(1);
        actual.All(x =>
              {
                  var name = x.GetName().Name;
                  return name.StartsWith(prefixName, StringComparison.OrdinalIgnoreCase)
                         && name.EndsWith(suffixName, StringComparison.OrdinalIgnoreCase);
              })
              .Should().BeTrue();
    }
}

以上

純粹是在寫興趣的,用寫程式、寫文章來抒解工作壓力