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。
- 在程式啟動時執行所有的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以及它們與實體的對映關係也容易有直觀的把握。
-
在程式啟動時對所有的配置進行嚴格的驗證
AutoMapper並不強制要求執行Mapper.AssertConfigurationIsValid()
驗證目標物件的所有屬性都能找到源屬性(或者在配置時指定了預設對映行為)。換句話說,即使執行Mapper.AssertConfigurationIsValid()
驗證失敗了呼叫Mapper()
也能成功對映(找不到源屬性的目標屬性將被賦預設值)。但是我們仍然應該在程式啟動時對所有的配置進行嚴格的驗證,並且在驗證失敗時立即找出原因並進行處理。因為我們在建立DTO時有可能因為手誤造成DTO的屬性與Entity的屬性名稱不完全一樣;或者當Entity
被重構,造成Entity
與DTO
不完全匹配,這將造成許多隱性Bug,難以察覺,難以全部根除,這也是DTO經常被人詬病的一大缺點。使用AutoMapper
的驗證機制可以從根本上消除這一隱患,所以即使麻煩一點也要一直堅持進行驗證。 -
指定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>();
- 通過配置實現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))
- 讓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
物件中。
- 考慮通過封裝讓
AutoMapper
可被取消和可替換
當我們使用外部工具的時候,一般總要想寫辦法儘量使這些工具容易被取消和替換,以避免技術風險,同時還能保證以更統一的方式使用工具。由於DTO
對Entity
是不可見的,所以Entity
到DTO
的對映和DTO
到Entity
的對映方法都要新增到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);
}
}
- 使用扁平化的雙向DTO
AutoMapper能夠非常便利地根據命名約定生成扁平化的DTO
。從DTO
向Entity
對映時,需要配置根據屬性Id
載入實體的方法,在前文[4. 通過配置實現DTO向Entity對映時載入實體]有詳細描述。
粒度過細的DTO
不利於管理。一般一個扁平化的雙向DTO
就可以應付大多數場景了。扁平化的DTO
不但可以讓Client
端得到更為簡單的資料結構,節省流量,同時也是非常棒的解除迴圈引用的方案,方便Json
序列化(後文詳述)。
- 使用扁平化消除迴圈引用
AutoMapper
在技術上是支援把帶有迴圈引用的Entity
物件對映為同樣具有迴圈引用關係的DTO
物件的。但是帶有迴圈應用的DicCategoryDto
物件在進一步Json
序列化時,DicItemDto
的Category
屬性就會因為迴圈引用而被丟棄了。而像上圖那樣把多端扁平化,就可以仍然保留我們感興趣的Category
屬性的資訊了。
- 將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()
方法是一個自定義的擴充套件方法:
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#
向著動態語言大踏步前進時,反而有些感到害怕了。
相關文章
- 《.NET最佳實踐》
- Django 最佳實踐Django
- metaq最佳實踐
- springDataJpa 最佳實踐Spring
- KeyPath 最佳實踐
- Pika最佳實踐
- JavaScript 最佳實踐JavaScript
- SnapKit 最佳實踐APK
- JDBC 最佳實踐JDBC
- Kafka最佳實踐Kafka
- Iptables 最佳實踐 !
- Serilog 最佳實踐
- Flutter 最佳實踐Flutter
- Java最佳實踐Java
- MongoDB 最佳實踐MongoDB
- Gradle最佳實踐Gradle
- 【譯】VueJS 最佳實踐VueJS
- App瘦身最佳實踐APP
- Android MVP 最佳實踐AndroidMVP
- OpenResty 最佳實踐 (1)REST
- Android SharedPreferences最佳實踐Android
- mysqldump的最佳實踐MySql
- [筆記]最佳實踐筆記
- OpenResty 最佳實踐 (2)REST
- RESTful API 最佳實踐RESTAPI
- HTTPS安全最佳實踐HTTP
- Go HttpServer 最佳實踐GoHTTPServer
- Android Emoji 最佳實踐Android
- Rocketmq原理&最佳實踐MQ
- restful api最佳實踐RESTAPI
- Dockerfile 安全最佳實踐Docker
- MongoDB最佳安全實踐MongoDB
- RocketMQ的最佳實踐MQ
- Java null最佳實踐JavaNull
- 冪等最佳實踐
- Kubernetes Deployment 最佳實踐
- flutter + getx 最佳實踐Flutter
- Code Review最佳實踐View