AutoMapper 最佳實踐

風靈使發表於2018-10-30

AutoMapper 是一個基於命名約定的物件->物件對映工具。

只要2個物件的屬性具有相同名字(或者符合它規定的命名約定),AutoMapper就可以替我們自動在2個物件間進行屬性值的對映。如果有不符合約定的屬性,或者需要自定義對映行為,就需要我們事先告訴AutoMapper,所以在使用 Map(src,dest)進行對映之前,必須使用 CreateMap() 進行配置。

Mapper.CreateMap<Product, ProductDto>(); // 配置
Product entity = Reop.FindProduct(id); // 從資料庫中取得實體
Assert.AreEqual("挖掘機", entity.ProductName);
ProductDto productDto = Mapper.Map(entity); // 使用AutoMapper自動對映
Assert.AreEqual("挖掘機", productDto.ProductName);

AutoMapper就是這樣一個只有2個常用函式的簡單方便的工具。不過在實際使用時還是有一些細節需要注意,下面將把比較重要的羅列出來。PS:專案的ORM框架是NHibernate。

  1. 在程式啟動時執行所有的AutoMapper配置,並且把對映程式碼放置到一起

下面是一個典型的AutoMapper全域性配置程式碼,裡面的一些細節會在後面逐一解釋。

public class DtoMapping
{
    private readonly IContractReviewMainAppServices IContractReviewMainAppServices;
    private readonly IDictionaryAppService IDictionaryAppService;
    private readonly IProductAppService IProductAppService;
    public DtoMapping(IContractReviewMainAppServices IContractReviewMainAppServices,
          IDictionaryAppService IDictionaryAppService, IProductAppService IProductAppService)
    {
        this.IContractReviewMainAppServices = IContractReviewMainAppServices;
        this.IDictionaryAppService = IDictionaryAppService;
        this.IProductAppService = IProductAppService;
    }

    public void InitMapping()
    {
        #region 合同購買裝置資訊
        Mapper.CreateMap<ContractReviewProduct, ContractReviewProductDto>();
        Mapper.CreateMap<ContractReviewProductDto, ContractReviewProduct>() // DTO 向 Entity 賦值
              .ForMember(entity => entity.ContractReviewMain, opt => LoadEntity(opt,
                                                                                dto => dto.ContractReviewMainId,
                                                                                IContractReviewMainAppServices.Get))
              .ForMember(entity => entity.DeviceCategory, opt => LoadEntity(opt,
                                                                            dto => dto.DeviceCategoryId,
                                                                            IDictionaryAppService.FindDicItem))
              .ForMember(entity => entity.DeviceName, opt => LoadEntity(opt,
                                                                        dto => dto.DeviceNameId,
                                                                        IProductAppService.FindProduct))
              .ForMember(entity => entity.ProductModel, opt => LoadEntity(opt,
                                                                          dto => dto.ProductModelId,
                                                                          IProductAppService.FindProduct))
              .ForMember(entity => entity.Unit, opt => LoadEntity(opt,
                                                                  dto => dto.UnitId,
                                                                  IDictionaryAppService.FindDicItem))
              .ForMember(entity => entity.Creator, opt => opt.Ignore()); // DTO 裡面沒有的屬性直接Ignore
        #endregion 合同購買裝置資訊

        #region 字典配置
        Mapper.CreateMap<DicCategory, DicCategoryDto>();
        Mapper.CreateMap<DicCategoryDto, DicCategory>();
        Mapper.CreateMap<DicItem, DicItemDto>();
        Mapper.CreateMap<DicItemDto, DicItem>()
              .ForMember(entity => entity.Category, opt => LoadEntity(opt,
                                                                      dto => dto.CategoryId,
                                                                                                                                           IDictionaryAppService.FindDicCategory));
        #endregion 字典配置

        // 對於所有的 DTO 到 Entity 的對映,都忽略 Id 和 Version 屬性
        IgnoreDtoIdAndVersionPropertyToEntity();

        // 驗證配置
        Mapper.AssertConfigurationIsValid();
    }

    /// <summary>
    /// 載入實體物件。
    /// <remarks>Id是null的會被忽略;Id是string.Empty的將被賦值為null;Id是GUID的將從資料庫中載入並賦值。</remarks> 
    /// </summary>
    /// <typeparam name="TSource"></typeparam>
    /// <typeparam name="TMember"></typeparam>
    /// <param name="opt"></param>
    /// <param name="getId"></param>
    /// <param name="doLoad"></param>
    private void LoadEntity<TSource, TMember>(IMemberConfigurationExpression<TSource> opt,
        Func<TSource, string> getId, Func<string, TMember> doLoad) where TMember : class
    {
        opt.Condition(src => (getId(src) != null));
        opt.MapFrom(src => getId(src) == string.Empty ? null : doLoad(getId(src)));
    }

    /// <summary>
    /// 對於所有的 DTO 到 Entity 的對映,都忽略 Id 和 Version 屬性
    /// <remarks>當從DTO向Entity賦值時,要保持從資料庫中載入過來的Entity的Id和Version屬性不變!</remarks>
    /// </summary>
    private void IgnoreDtoIdAndVersionPropertyToEntity()
    {
        PropertyInfo idProperty = typeof(Entity).GetProperty("Id");
        PropertyInfo versionProperty = typeof(Entity).GetProperty("Version");
        foreach (TypeMap map in Mapper.GetAllTypeMaps())
        {
            if (typeof(Dto).IsAssignableFrom(map.SourceType)
                && typeof(Entity).IsAssignableFrom(map.DestinationType))
            {
                map.FindOrCreatePropertyMapFor(new PropertyAccessor(idProperty)).Ignore();
                map.FindOrCreatePropertyMapFor(new PropertyAccessor(versionProperty)).Ignore();
            }
        }
    }
}

雖然AutoMapper並不強制要求在程式啟動時一次性提供所有配置,但是這樣做有如下好處:
a) 可以在程式啟動時對所有的配置進行嚴格的驗證(後文詳述)。
b) 可以統一指定DTO向Entity對映時的通用行為(後文詳述)。
c) 邏輯內聚:新增配置時方便模仿以前寫過的配置;對專案中一共有多少DTO以及它們與實體的對映關係也容易有直觀的把握。

  1. 在程式啟動時對所有的配置進行嚴格的驗證
    AutoMapper並不強制要求執行 Mapper.AssertConfigurationIsValid() 驗證目標物件的所有屬性都能找到源屬性(或者在配置時指定了預設對映行為)。換句話說,即使執行 Mapper.AssertConfigurationIsValid() 驗證失敗了呼叫 Mapper() 也能成功對映(找不到源屬性的目標屬性將被賦預設值)。但是我們仍然應該在程式啟動時對所有的配置進行嚴格的驗證,並且在驗證失敗時立即找出原因並進行處理。因為我們在建立DTO時有可能因為手誤造成DTO的屬性與Entity的屬性名稱不完全一樣;或者當Entity被重構,造成EntityDTO不完全匹配,這將造成許多隱性Bug,難以察覺,難以全部根除,這也是DTO經常被人詬病的一大缺點。使用AutoMapper的驗證機制可以從根本上消除這一隱患,所以即使麻煩一點也要一直堅持進行驗證。

  2. 指定DTO向Entity對映時的通用行為
    從DTO物件向Entity物件對映時,應該是先從資料庫中載入Entity物件,然後把DTO物件的屬性值覆蓋到Entity物件中。Entity物件的IdVersion屬性要麼是從資料庫中載入的(更新時),要麼是由Entity物件自主獲取的預設值(新增時),無論哪種情況,都不應該讓DTO裡的屬性值覆蓋到Entity裡的這2個屬性。

 Mapper.CreateMap<DicCategoryDto, DicCategory>()
       .ForMember(entity => entity.Id, opt => opt.Ignore())
       .ForMember(entity => entity.Version, opt => opt.Ignore());

但是每個DTO到Entity的配置都這麼寫一遍的話,麻煩不說,萬一忘了後果不堪設想。通過在配置的最後呼叫IgnoreDtoIdAndVersionPropertyToEntity()函式可以統一設定所有DTOEntity的對映都忽略IdVersion屬性。

 /// <summary>
 /// 對於所有的 DTO 到 Entity 的對映,都忽略 Id 和 Version 屬性
 /// <remarks>當從DTO向Entity賦值時,要保持從資料庫中載入過來的Entity的Id和Version屬性不變!</remarks>
 /// </summary>
 private void IgnoreDtoIdAndVersionPropertyToEntity()
 {
     PropertyInfo idProperty = typeof(Entity).GetProperty("Id");
     PropertyInfo versionProperty = typeof(Entity).GetProperty("Version");
     foreach (TypeMap map in Mapper.GetAllTypeMaps())
     {
         if (typeof(Dto).IsAssignableFrom(map.SourceType)
             && typeof(Entity).IsAssignableFrom(map.DestinationType))
         {
             map.FindOrCreatePropertyMapFor(new PropertyAccessor(idProperty)).Ignore();
             map.FindOrCreatePropertyMapFor(new PropertyAccessor(versionProperty)).Ignore();
         }
     }
 }

另一方案:下面這種寫法是官方推薦的,可讀性更好,但是實測Ignore()選項並沒有生效!不知道是不是Bug。

Mapper.CreateMap<Dto, Entity>()
      .ForMember(entity => entity.Id, opt => opt.Ignore())
      .ForMember(entity => entity.Version, opt => opt.Ignore())
      .Include<ContractReviewProductDto, ContractReviewProduct>()
      .Include<DicCategoryDto, DicCategory>()
      .Include<DicItemDto, DicItem>();
  1. 通過配置實現DTO向Entity對映時載入實體
    DTOEntity對映時,如果Entity有關聯的屬性,需要呼叫NHibernate的LoadEntity()根據Client傳過來的關聯屬性Id載入實體物件。這項工作很適合放到AutoMapper的配置程式碼裡。進一步地,我們可以約定:關聯屬性Idnull時,表示忽略此屬性;如果關聯屬性Idstring.Empty,表示要把此屬性置空;如果關聯屬性IdGUID,則載入實體物件。然後,把這個邏輯抽取出來形成 LoadEntity() 函式以避免冗餘程式碼。
/// <summary>
/// 載入實體物件。
/// <remarks>Id是null的會被忽略;Id是string.Empty的將被賦值為null;Id是GUID的將從資料庫中載入並賦值。</remarks> 
/// </summary>
private void LoadEntity<TSource, TMember>(IMemberConfigurationExpression<TSource> opt,
    Func<TSource, string> getId, Func<string, TMember> doLoad) where TMember : class
{
    opt.Condition(src => (getId(src) != null));
    opt.MapFrom(src => getId(src) == string.Empty ? null : doLoad(getId(src)));
}

這樣在配置的時候就可以使用宣告式的程式碼了:

Mapper.CreateMap<ContractReviewProductDto, ContractReviewProduct>() // DTO 向 Entity 賦值
     .ForMember(entity => entity.DeviceCategory, opt => LoadEntity(opt,
                                                                   dto => dto.DeviceCategoryId,
                                                                   IDictionaryAppService.FindDicItem))
  1. 讓AutoMapper合併2個物件而不是建立新物件
    Map()方法有2種使用方式。一種是由AutoMapper建立目標物件:
ProductDto dto = Mapper.Map<Product, ProductDto>(entity);

另一種是讓AutoMapper把源物件中的屬性值合併/覆蓋到目標物件:

ProductDto dto = new ProductDto();
Maper.Map(entity, dto);

應該總是使用後一種。對於EntityDTO對映的情況,由於有時候需要把2個Entity物件對映到一個DTO物件中,所以應該使用後一種方式。對於DTOEntity對映的情況,需要先從資料庫中載入Entity物件,再把DTO物件中的部分屬性值覆蓋到Entity物件中。

  1. 考慮通過封裝讓AutoMapper可被取消和可替換

當我們使用外部工具的時候,一般總要想寫辦法儘量使這些工具容易被取消和替換,以避免技術風險,同時還能保證以更統一的方式使用工具。由於DTOEntity是不可見的,所以EntityDTO的對映和DTOEntity的對映方法都要新增到DTO的基類中。注意我們沒有使用Map()方法的泛型版本,這樣便於增加新的抽象DTO基類,例如業務物件的DTO基類BizInfoDto


 /// <summary>
 /// 資料傳輸物件抽象類
 /// </summary>
 public abstract class Dto
 {
     /// <summary>
     /// 從實體中取得屬性值
     /// </summary>
     /// <param name="entity"></param>
     public virtual void FetchValuesFromEntity<TEntity>(TEntity entity)
     {
         Mapper.Map(entity, this, entity.GetType(), this.GetType());
     }
 
     /// <summary>
     /// 將DTO中的屬性值賦值到實體物件中
     /// </summary>
     /// <param name="entity"></param>
     public virtual void AssignValuesToEntity<TEntity>(TEntity entity)
     {
         Mapper.Map(this, entity, this.GetType(), entity.GetType());
     }
 
     [Description("主鍵Id")]
     public string Id { get; set; }
 
     [Description("版本號")]
     public int Version { get; set; }
 }
 
 /// <summary>
 /// 業務DTO基類
 /// </summary>
 public abstract class BizInfoDto : Dto
 {
     [Description("刪除標識")]
     public bool Del { get; set; }
 
     [Description("最後更新時間")]
     public DateTime? UpdateTime { get; set; }
 
     [Description("資料產生時間")]
     public DateTime? CreateTime { get; set; }
 }

然後像這樣使用:

dto.AssignValuesToEntity(entity);
dto.FetchValuesFromEntity(entity);

再為IList新增用於對映的擴充套件方法,用於將Entity列表對映為DTO列表:

public static class AutoMapperCollectionExtension
{
    public static IList<TDto> ToDtoList<TEntity, TDto>(this IList<TEntity> entityList)
    {
        return Mapper.Map<IList<TEntity>, IList<TDto>>(entityList);
    } 
}
  1. 使用扁平化的雙向DTO

AutoMapper能夠非常便利地根據命名約定生成扁平化的DTO。從DTOEntity對映時,需要配置根據屬性Id載入實體的方法,在前文[4. 通過配置實現DTO向Entity對映時載入實體]有詳細描述。

粒度過細的DTO不利於管理。一般一個扁平化的雙向DTO就可以應付大多數場景了。扁平化的DTO不但可以讓Client端得到更為簡單的資料結構,節省流量,同時也是非常棒的解除迴圈引用的方案,方便Json序列化(後文詳述)。

  1. 使用扁平化消除迴圈引用

AutoMapper在技術上是支援把帶有迴圈引用的Entity物件對映為同樣具有迴圈引用關係的DTO物件的。但是帶有迴圈應用的DicCategoryDto物件在進一步Json序列化時,DicItemDtoCategory屬性就會因為迴圈引用而被丟棄了。而像上圖那樣把多端扁平化,就可以仍然保留我們感興趣的Category屬性的資訊了。

  1. 將DTO放置在Service

原則上Entity應該不知道DTO,所以物理上也最好把DTO放置在Service層裡面。但是有一個技術問題:有時候需要在Repository層裡面讓NHibernate執行原生SQL語句,然後就需要利用NHibernateAliasToBean()方法將查詢結果對映到DTO物件裡面。如果DTO放置在Service層裡面,該怎麼把DTO的型別傳遞給Repository層呢?下面將給出2種解決方案。

9.1 利用泛型將Service層的DTO型別傳遞給Repository

下面是一個在Repository層使用NHibernate執行原生SQL的例子,利用泛型指定DTO的型別。


public IList<TDto> GetRawSqlList<TDto>()
{
    var query = Session.CreateSQLQuery(@"SELECT max(cg.TEXT) as ProductCategory, sum(p.COUNT_NUM) as TotalNum
                                          FROM CNT_RW_PRODUCT p
                                          left join SYS_DIC_ITEM cg on p.CATEGORY = cg.DIC_ITEM_ID
                                         where p.DEL = :DEL
                                         group by p.CATEGORY")
                            .SetBoolean("DEL", false);
    query.SetResultTransformer(NHibernate.Transform.Transformers.AliasToBean<TDto>());
    return query.List<TDto>();
}

然後,在Service層建立一個與查詢結果匹配的DTO

public class ProductCategorySummaryDto : Dto
{
    [Description("產品類別")]
    public string ProductCategory { get; set; }

    [Description("總數量")]
    public int TotalNum { get; set; }
}

Service層的GetRawSQLResult()方法的定義:

public IList<ProductCategorySummaryDto> GetRawSQLResult()
{
    return IContractReviewProductRepository.GetRawSqlList<ProductCategorySummaryDto>();
}

9.2 另一方案:使用ExpandoObject物件返回查詢結果

如果查詢結果只使用一次,單獨為它建立一個DTO成本似乎有些過高。下面同樣是在Repository利用NHibernate執行原生SQL,但是返回值是一個動態物件的列表。

public IList<dynamic> GetExpandoObjectList(string contractReviewMainId)
{
    var query = Session.CreateQuery(@"select t.Id as Id,
                                             t.Version as Version,
                                             t.Place as Place,
                                             t.DeviceName.Text as DeviceNameText,
                                             t.DeviceName.Id as DeviceNameId
                                        from ContractReviewProduct t
                                       where t.ContractReviewMain.Id = :ContractReviewMainId")
                            .SetAnsiString("ContractReviewMainId", contractReviewMainId);
    return query.DynamicList();
}

注意DynamicList()方法是一個自定義的擴充套件方法:

 public static class NHibernateExtensions
 {
     public static IList<dynamic> DynamicList(this IQuery query)
     {
         return query.SetResultTransformer(NhTransformers.ExpandoObject)
                     .List<dynamic>();
     }
 }
 
 public static class NhTransformers
 {
     public static readonly IResultTransformer ExpandoObject;
 
     static NhTransformers()
     {
         ExpandoObject = new ExpandoObjectResultSetTransformer();
     }
 
     private class ExpandoObjectResultSetTransformer : IResultTransformer
     {
         public IList TransformList(IList collection)
         {
             return collection;
         }
 
         public object TransformTuple(object[] tuple, string[] aliases)
         {
             var expando = new ExpandoObject();
             var dictionary = (IDictionary<string, object>)expando;
             for (int i = 0; i < tuple.Length; i++)
             {
                 string alias = aliases[i];
                 if (alias != null)
                 {
                     dictionary[alias] = tuple[i];
                 }
             }
             return expando;
         }
     }
 }

Service層使用返回的動態物件的程式碼與使用普通程式碼看上去一樣。也可以直接把返回的動態物件利用Json.Net序列化。

[TestMethod]
public void TestGetExpandoObject()
{
    IList<dynamic> result = IContractReviewProductRepository().GetExpandoObjectList("5AB17F4D-803E-4641-8FCF-660662458BAA");

    Assert.AreEqual("刮板機", result[0].DeviceNameText);
    Assert.AreEqual(4, result[0].Version);
}

但是本質上ExpandoObject只是一個IDictionary。目前AutoMapper3.1還不支援把ExpandoObject物件對映成普通物件。沒有編譯期的語法檢查,沒有型別資訊,沒有靜態的屬性資訊,將來想重構都十分不便。曾經非常羨慕Ruby等動態語言的靈活和便利,但是當C#向著動態語言大踏步前進時,反而有些感到害怕了。