AutoMapper 最佳實踐


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全局配置代碼,里面的一些細節會在后面逐一解釋。

 1 public class DtoMapping
 2 {
 3     private readonly IContractReviewMainAppServices IContractReviewMainAppServices;
 4     private readonly IDictionaryAppService IDictionaryAppService;
 5     private readonly IProductAppService IProductAppService;
 6     public DtoMapping(IContractReviewMainAppServices IContractReviewMainAppServices,
 7           IDictionaryAppService IDictionaryAppService, IProductAppService IProductAppService)
 8     {
 9         this.IContractReviewMainAppServices = IContractReviewMainAppServices;
10         this.IDictionaryAppService = IDictionaryAppService;
11         this.IProductAppService = IProductAppService;
12     }
13 
14     public void InitMapping()
15     {
16         #region 合同購買設備信息
17         Mapper.CreateMap<ContractReviewProduct, ContractReviewProductDto>();
18         Mapper.CreateMap<ContractReviewProductDto, ContractReviewProduct>() // DTO 向 Entity 賦值
19               .ForMember(entity => entity.ContractReviewMain, opt => LoadEntity(opt,
20                                                                                 dto => dto.ContractReviewMainId,
21                                                                                 IContractReviewMainAppServices.Get))
22               .ForMember(entity => entity.DeviceCategory, opt => LoadEntity(opt,
23                                                                             dto => dto.DeviceCategoryId,
24                                                                             IDictionaryAppService.FindDicItem))
25               .ForMember(entity => entity.DeviceName, opt => LoadEntity(opt,
26                                                                         dto => dto.DeviceNameId,
27                                                                         IProductAppService.FindProduct))
28               .ForMember(entity => entity.ProductModel, opt => LoadEntity(opt,
29                                                                           dto => dto.ProductModelId,
30                                                                           IProductAppService.FindProduct))
31               .ForMember(entity => entity.Unit, opt => LoadEntity(opt,
32                                                                   dto => dto.UnitId,
33                                                                   IDictionaryAppService.FindDicItem))
34               .ForMember(entity => entity.Creator, opt => opt.Ignore()); // DTO 里面沒有的屬性直接Ignore
35         #endregion 合同購買設備信息
36 
37         #region 字典配置
38         Mapper.CreateMap<DicCategory, DicCategoryDto>();
39         Mapper.CreateMap<DicCategoryDto, DicCategory>();
40         Mapper.CreateMap<DicItem, DicItemDto>();
41         Mapper.CreateMap<DicItemDto, DicItem>()
42               .ForMember(entity => entity.Category, opt => LoadEntity(opt,
43                                                                       dto => dto.CategoryId,
44                                                                       IDictionaryAppService.FindDicCategory));
45         #endregion 字典配置
46 
47         // 對於所有的 DTO 到 Entity 的映射,都忽略 Id 和 Version 屬性
48         IgnoreDtoIdAndVersionPropertyToEntity();
49 
50         // 驗證配置
51         Mapper.AssertConfigurationIsValid();
52     }
53 
54     /// <summary>
55     /// 加載實體對象。
56     /// <remarks>Id是null的會被忽略;Id是string.Empty的將被賦值為null;Id是GUID的將從數據庫中加載並賦值。</remarks> 
57     /// </summary>
58     /// <typeparam name="TSource"></typeparam>
59     /// <typeparam name="TMember"></typeparam>
60     /// <param name="opt"></param>
61     /// <param name="getId"></param>
62     /// <param name="doLoad"></param>
63     private void LoadEntity<TSource, TMember>(IMemberConfigurationExpression<TSource> opt,
64         Func<TSource, string> getId, Func<string, TMember> doLoad) where TMember : class
65     {
66         opt.Condition(src => (getId(src) != null));
67         opt.MapFrom(src => getId(src) == string.Empty ? null : doLoad(getId(src)));
68     }
69 
70     /// <summary>
71     /// 對於所有的 DTO 到 Entity 的映射,都忽略 Id 和 Version 屬性
72     /// <remarks>當從DTO向Entity賦值時,要保持從數據庫中加載過來的Entity的Id和Version屬性不變!</remarks>
73     /// </summary>
74     private void IgnoreDtoIdAndVersionPropertyToEntity()
75     {
76         PropertyInfo idProperty = typeof(Entity).GetProperty("Id");
77         PropertyInfo versionProperty = typeof(Entity).GetProperty("Version");
78         foreach (TypeMap map in Mapper.GetAllTypeMaps())
79         {
80             if (typeof(Dto).IsAssignableFrom(map.SourceType)
81                 && typeof(Entity).IsAssignableFrom(map.DestinationType))
82             {
83                 map.FindOrCreatePropertyMapFor(new PropertyAccessor(idProperty)).Ignore();
84                 map.FindOrCreatePropertyMapFor(new PropertyAccessor(versionProperty)).Ignore();
85             }
86         }
87     }
88 }
DTO 與 Entity 之間的 AutoMapper全局配置代碼

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

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

3. 指定DTO向Entity映射時的通用行為
從DTO對象向Entity對象映射時,應該是先從數據庫中加載Entity對象,然后把DTO對象的屬性值覆蓋到Entity對象中。Entity對象的Id和Version屬性要么是從數據庫中加載的(更新時),要么是由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()函數可以統一設置所有DTO向Entity的映射都忽略Id和Version屬性。

/// <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>();
不好用的代碼


4. 通過配置實現DTO向Entity映射時加載實體
從DTO向Entity映射時,如果Entity有關聯的屬性,需要調用NHibernate的LoadEntity()根據Client傳過來的關聯屬性Id加載實體對象。這項工作很適合放到AutoMapper的配置代碼里。進一步地,我們可以約定:關聯屬性Id是null時,表示忽略此屬性;如果關聯屬性Id是string.Empty,表示要把此屬性置空;如果關聯屬性Id是GUID,則加載實體對象。然后,把這個邏輯抽取出來形成 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))

 
5. 讓AutoMapper合並2個對象而不是創建新對象
Map()方法有2種使用方式。一種是由AutoMapper創建目標對象:
ProductDto dto = Mapper.Map<Product, ProductDto>(entity);

另一種是讓AutoMapper把源對象中的屬性值合並/覆蓋到目標對象:
ProductDto dto = new ProductDto();
Maper.Map(entity, dto);

應該總是使用后一種。對於Entity向DTO映射的情況,由於有時候需要把2個Entity對象映射到一個DTO對象中,所以應該使用后一種方式。對於DTO向Entity映射的情況,需要先從數據庫中加載Entity對象,再把DTO對象中的部分屬性值覆蓋到Entity對象中。

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

 1 /// <summary>
 2 /// 數據傳輸對象抽象類
 3 /// </summary>
 4 public abstract class Dto
 5 {
 6     /// <summary>
 7     /// 從實體中取得屬性值
 8     /// </summary>
 9     /// <param name="entity"></param>
10     public virtual void FetchValuesFromEntity<TEntity>(TEntity entity)
11     {
12         Mapper.Map(entity, this, entity.GetType(), this.GetType());
13     }
14 
15     /// <summary>
16     /// 將DTO中的屬性值賦值到實體對象中
17     /// </summary>
18     /// <param name="entity"></param>
19     public virtual void AssignValuesToEntity<TEntity>(TEntity entity)
20     {
21         Mapper.Map(this, entity, this.GetType(), entity.GetType());
22     }
23 
24     [Description("主鍵Id")]
25     public string Id { get; set; }
26 
27     [Description("版本號")]
28     public int Version { get; set; }
29 }
30 
31 /// <summary>
32 /// 業務DTO基類
33 /// </summary>
34 public abstract class BizInfoDto : Dto
35 {
36     [Description("刪除標識")]
37     public bool Del { get; set; }
38 
39     [Description("最后更新時間")]
40     public DateTime? UpdateTime { get; set; }
41 
42     [Description("數據產生時間")]
43     public DateTime? CreateTime { get; set; }
44 }
DTO基類代碼

然后像這樣使用:

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

 
7. 使用扁平化的雙向DTO

AutoMapper能夠非常便利地根據命名約定生成扁平化的DTO。從DTO向Entity映射時,需要配置根據屬性Id加載實體的方法,在前文[4. 通過配置實現DTO向Entity映射時加載實體]有詳細描述。
  粒度過細的DTO不利於管理。一般一個扁平化的雙向DTO就可以應付大多數場景了。扁平化的DTO不但可以讓Client端得到更為簡單的數據結構,節省流量,同時也是非常棒的解除循環引用的方案,方便Json序列化(后文詳述)。

8. 使用扁平化消除循環引用

AutoMapper在技術上是支持把帶有循環引用的Entity對象映射為同樣具有循環引用關系的DTO對象的。但是帶有循環應用的DicCategoryDto對象在進一步Json序列化時,DicItemDto的Category屬性就會因為循環引用而被丟棄了。而像上圖那樣把多端扁平化,就可以仍然保留我們感興趣的Category屬性的信息了。

9. 將DTO放置在Service層
原則上Entity應該不知道DTO,所以物理上也最好把DTO放置在Service層里面。但是有一個技術問題:有時候需要在Repository層里面讓NHibernate執行原生SQL語句,然后就需要利用NHibernate的AliasToBean()方法將查詢結果映射到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()方法是一個自定義的擴展方法:

 1 public static class NHibernateExtensions
 2 {
 3     public static IList<dynamic> DynamicList(this IQuery query)
 4     {
 5         return query.SetResultTransformer(NhTransformers.ExpandoObject)
 6                     .List<dynamic>();
 7     }
 8 }
 9 
10 public static class NhTransformers
11 {
12     public static readonly IResultTransformer ExpandoObject;
13 
14     static NhTransformers()
15     {
16         ExpandoObject = new ExpandoObjectResultSetTransformer();
17     }
18 
19     private class ExpandoObjectResultSetTransformer : IResultTransformer
20     {
21         public IList TransformList(IList collection)
22         {
23             return collection;
24         }
25 
26         public object TransformTuple(object[] tuple, string[] aliases)
27         {
28             var expando = new ExpandoObject();
29             var dictionary = (IDictionary<string, object>)expando;
30             for (int i = 0; i < tuple.Length; i++)
31             {
32                 string alias = aliases[i];
33                 if (alias != null)
34                 {
35                     dictionary[alias] = tuple[i];
36                 }
37             }
38             return expando;
39         }
40     }
41 }
DynamicList()擴展方法和ExpandoObjectResultSetTransformer

在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#向着動態語言大踏步前進時,反而有些感到害怕了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM