愛流浪的小風

技術隨手寫

使用Asp.Net MVC打造Web Api (10) - 透過AutoMapper處理資料轉換

| Comments

在我們開發專案時,總是會希望可以讓網站架構保持彈性,並且讓程式碼具有可讀性,如此一來接手維護的人就可以比較容易切入處理系統問題。而根據開發的經驗,通常一個Function中都會有兩種類型的邏輯最干擾閱讀,分別就是資料轉換和資料驗證這兩個部分。

由於我們不可能直接將資料庫的欄位名稱直接曝露在給使用者接觸的最前端(可能會有安全性疑慮),因此我們通常都還會多墊一層ViewModel,而進入系統之後才會轉為系統使用的Data Model,寫入資料庫時在轉換為對應到資料庫欄位的Entity,而若是按照一般的方法開發,就會造成BL和DA層中有很多的程式碼在處理資料類型之間的轉換,隨著資料的複雜程度,閱讀困難程度成等比增加。

今天就要像大家介紹,如何透過AutoMapper來處理資料模型之間的轉換,可以將資料轉換的邏輯封裝起來,讓不同地方可以同時共用轉換邏輯,更讓程式碼清爽具有可讀性!

大家可以從Github ApiSample - Tag Day09開始今天的練習

建立一個新增商品的Api

為了示範如何使用AutoMapper,我們先增加一組Api用來新增商品,它可以同時新增一個商品和買這個商品時要贈送的多個贈品

  1. 我們首先修改一下我們的資料設計,多增加一個Gift資料表,並且和Product的關係是多對多

  2. 新增Gift和修改Product

    public class Product : EntityBase
    {        
        public ICollection<Gift> Gifts { get; set; }
    }
    
    public class Gift : EntityBase
    {
        [Key]
        public int Id { get; set; }
    
        [Required]
        [StringLength(100)]
        public string Name { get; set; }
    
        [StringLength(1000)]
        public string Description { get; set; }
    
        public ICollection<Product> Products { get; set; }
    }
    
  3. 修改DA層,IProductRepository和ProductRepository

    public interface IProductRepository
    {
        IEnumerable<ProductForCategoryModel> GetProductByCategoryId(int categoryId);
    
        void InsertProduct(ProductModel productModel);
    }
    
    public class ProductRepository
    {
        public void InsertProduct(ProductModel productModel)
        {            
            var prodcut = new Product();
            prodcut.Name = productModel.Name;
            prodcut.Price = productModel.Price;
            prodcut.Cost = productModel.Cost;
            prodcut.Description = productModel.Description;
            prodcut.ListingStartTime = productModel.ListingStartTime;
            prodcut.ListingEndTime = productModel.ListingEndTime;
            prodcut.SellingStartTime = productModel.SellingStartTime;
            prodcut.SellingEndTime = productModel.SellingEndTime;
            prodcut.CategoryId = productModel.CategoryId;
    
            prodcut.Gifts = new List<Gift>();
            foreach (var giftModel in productModel.Gifts)
            {
                var gift = new Gift();
                gift.Name = giftModel.Name;
                gift.Description = giftModel.Description;
    
                prodcut.Gifts.Add(gift);
            }
    
            this.ShopContext.Products.Add(prodcut);
            this.ShopContext.SaveChanges();
        }
    }
    
  4. 在Models建立一個ViewModels專案,專門用來作為UI層的DTO(Data Transfer Object),並新增InsertProductModel,這邊設計可以一次新增一件商品和多個贈品,並且在這邊做了一個錯誤的示範,新增贈品的部分是使用串接字串的方式新增,例如Gift1:AAA;Gift2:BBB代表新增兩個贈品Gift1和Gift2,分別包含贈品描述AAA和BB,但在真實世界滿常遇到各種千奇百怪的Case,所以在這邊用了這個例子來介紹如何處理,請大家開發時不要學習!!!

    public class InsertProductModel
    {        
        public string Name { get; set; }
    
        public decimal Price { get; set; }
    
        public decimal Cost { get; set; }
    
        public string Introduction { get; set; }
    
        public DateTime StartListingAt { get; set; }
    
        public DateTime FinishListingAt { get; set; }
    
        public DateTime StartSellAt { get; set; }
    
        public DateTime FinishSellAt { get; set; }
    
        public int CategoryId { get; set; }
    
        /// <summary>
        /// This is not good design, just for example
        /// </summary>
        public string Gifts { get; set; }
    }
    
  5. 修改BL層,IProductService和ProductService

    public interface IProductService
    {
        IEnumerable<ProductForCategoryModel> GetProductByCategoryId(int categoryId);
    
        void InsertProduct(InsertProductModel insertProductModel);
    }
    
    public class ProductService : IProductService
    {           
        public void InsertProduct(InsertProductModel insertProductModel)
        {                
            //// Product
            ProductModel productModel = new ProductModel();
            productModel.Name = insertProductModel.Name;
            productModel.Price = insertProductModel.Price;
            productModel.Cost = insertProductModel.Cost;
            productModel.Description = insertProductModel.Introduction;
            productModel.ListingStartTime = insertProductModel.StartListingAt;
            productModel.ListingEndTime = insertProductModel.FinishListingAt;
            productModel.SellingStartTime = insertProductModel.StartSellAt;
            productModel.SellingEndTime = insertProductModel.FinishSellAt;
            productModel.CategoryId = insertProductModel.CategoryId;
    
            //// Gift
            var giftList = new List<GiftModel>();
            if (!string.IsNullOrWhiteSpace(insertProductModel.Gifts))
            {
                var giftStringList = insertProductModel.Gifts.Split(';');
                foreach (var giftString in giftStringList)
                {
                    var giftData = giftString.Split(':');
                    var gift = new GiftModel();
                    gift.Name = giftData[0];
                    gift.Description = giftData[1];
    
                    giftList.Add(gift);
                }
    
                productModel.Gifts = giftList;
            }
    
            this.ProductRepository.InsertProduct(productModel);
        }
    }
    
  6. 修改網站,ProductController

    public class ProductController : Controller
    {            
        [HttpPost]
        public ActionResult Create(InsertProductModel product)
        {
            this.ProductService.InsertProduct(product);
    
            return Json(ApiStatusEnum.Success.ToString());
        }
    }
    
  7. 我們可以使用PostMan測試我們的API,新增資料成功

註: 修改完資料結構記得更新資料庫結構

AutoMapper可以做什麼?

從上面的程式碼可以看到,光是僅僅包含兩個Table的資料轉換,程式碼就已經如此冗長,若是今天一次有7~8個資料表,而且資料表之前還具有關聯,加上許多的欄位,那麼光是資料轉換的邏輯可能就是數百行起跳了!

AutoMapper讓我們可以預先定義好兩個Model之間的轉換邏輯,並透過Profile將邏輯封裝為一個一個的Class,而我們會再網站啟動時就將這些Profile註冊到AutoMapper中。

而AutoMapper就可以在遇到符合的Model時,自動使用我們定義好的邏輯來轉換,就不會到處都看到資料轉換的程式碼,而是統一集中在Profile Class,甚至我們可以更輕易的對這些資料轉換邏輯來做單元測試!

延伸閱讀:

開始整合AutoMapper

  1. 在DA層增加Mappings專案,並將DA.Tables和Models加入作為參考
  2. 建立ProductMappingProfile,裡面將包含所有資料轉為Product的邏輯

    public class ProductMappingProfile : Profile
    {
        public override string ProfileName
        {
            get
            {
                return "ProductMappingProfile";
            }
        }
    
        protected override void Configure()
        {
            Mapper.CreateMap<ProductModel, Product>()
                  .ForMember(i => i.Name, s => s.MapFrom(i => i.Name))
                  .ForMember(i => i.Price, s => s.MapFrom(i => i.Price))
                  .ForMember(i => i.Cost, s => s.MapFrom(i => i.Cost))
                  .ForMember(i => i.Description, s => s.MapFrom(i => i.Description))
                  .ForMember(i => i.ListingStartTime, s => s.MapFrom(i => i.ListingStartTime))
                  .ForMember(i => i.ListingEndTime, s => s.MapFrom(i => i.ListingEndTime))
                  .ForMember(i => i.SellingStartTime, s => s.MapFrom(i => i.SellingStartTime))
                  .ForMember(i => i.SellingEndTime, s => s.MapFrom(i => i.ListingEndTime))
                  .ForMember(i => i.CategoryId, s => s.MapFrom(i => i.CategoryId))
                  .ForMember(i => i.Gifts, s => s.MapFrom(i => i.Gifts));
    
        }
    }
    
  3. 建立GiftMappingProfile,裡面將包含所有資料轉為Product的邏輯

    public class GiftMappingProfile : Profile
    {
        public override string ProfileName
        {
            get
            {
                return "GiftMappingProfile";
            }
        }
    
        protected override void Configure()
        {
            Mapper.CreateMap<GiftModel, Gift>()
                  .ForMember(i => i.Name, s => s.MapFrom(i => i.Name))
                  .ForMember(i => i.Description, s => s.MapFrom(i => i.Description));
        }
    }
    
  4. 在DA.Modules中建立MappingModule,向Autofac註冊轉換邏輯,這邊比較特別的是我們都只找出MappingProfile結尾的Class,並註冊到它的BaseClass(也就是AutoMapper.Profile)

    public class MappingModule : Autofac.Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            var mappings = Assembly.Load("ApiSample.DA.Mappings");
    
            builder.RegisterAssemblyTypes(mappings)
                   .Where(i => i.Name.EndsWith("MappingProfile"))
                   .As(i => i.BaseType);
        }
    }    
    

    註: 記得還要在WebSite加入Mapping的參考,還有在config增加此Module

  5. 在BL層增加Mappings專案,並將ViewModels和Models加入作為參考

  6. 建立ProductModelMappingProfile,裡面將包含所有資料轉為ProductModel的邏輯

    public class ProductModelMappingProfile : Profile
    {
        public override string ProfileName
        {
            get
            {
                return "InsertProductModelProfile";
            }
        }
    
        protected override void Configure()
        {
            //// To ProductModel
            Mapper.CreateMap<InsertProductModel, ProductModel>()
                  .ForMember(i => i.Name, s => s.MapFrom(i => i.Name))
                  .ForMember(i => i.Price, s => s.MapFrom(i => i.Price))
                  .ForMember(i => i.Cost, s => s.MapFrom(i => i.Cost))
                  .ForMember(i => i.Description, s => s.MapFrom(i => i.Introduction))
                  .ForMember(i => i.ListingStartTime, s => s.MapFrom(i => i.StartListingAt))
                  .ForMember(i => i.ListingEndTime, s => s.MapFrom(i => i.FinishListingAt))
                  .ForMember(i => i.SellingStartTime, s => s.MapFrom(i => i.StartSellAt))
                  .ForMember(i => i.SellingEndTime, s => s.MapFrom(i => i.FinishSellAt))
                  .ForMember(i => i.CategoryId, s => s.MapFrom(i => i.CategoryId))
                  .ForMember(i => i.Gifts, s => s.MapFrom(i => i.Gifts));            
        }
    }
    
  7. 建立GiftModelMappingProfile,裡面將包含所有資料轉為GiftModel的邏輯

    public class GiftModelMappingProfile : Profile
    {
        public override string ProfileName
        {
            get
            {
                return "GiftModelMappingProfile";
            }
        }
    
        protected override void Configure()
        {
            //// To GiftModel
            Mapper.CreateMap<string, GiftModel>()
                  .ConvertUsing(i =>
                  {
                      var giftData = i.Split(':');
                      var giftModel = new GiftModel();
                      giftModel.Name = giftData[0];
                      giftModel.Description = giftData[1];
    
                      return giftModel;
                  });
    
            Mapper.CreateMap<string, IEnumerable<GiftModel>>()
                  .ConvertUsing(i =>
                  {
                      var giftSourceList = i.Split(';');
    
                      return Mapper.Map<List<GiftModel>>(giftSourceList);
                  });
        }
    }
    
  8. 在BL.Modules中建立MappingModule,向Autofac註冊轉換邏輯

    public class MappingModule : Autofac.Module
    {
        protected override void Load(ContainerBuilder builder)
        {            
            var mappings = Assembly.Load("ApiSample.BL.Mappings");
    
            builder.RegisterAssemblyTypes(mappings)
                   .Where(i => i.Name.EndsWith("MappingProfile"))
                   .As(i => i.BaseType);
        }
    }
    
  9. 在WebSite的App_Start新增AutoMapper.config,在網站啟動時註冊所有的Profile

    public class AutoMapperConfig
    {
        public static void Initialize()
        {
            var profiles = DependencyResolver.Current.GetServices<Profile>();
    
            Mapper.Initialize(
                i =>
                {
                    foreach (var profile in profiles)
                    {
                        i.AddProfile(profile);
                    }
                }
            );
        }
    }
    
  10. 在Global.ascx加上啟動

    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
    
            AutofacConfig.Initialize();
            AutoMapperConfig.Initialize();
            WebApiConfig.Register(GlobalConfiguration.Configuration);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
    
  11. 將AutoMapper設定完畢之後,我們就可以改寫原本的資料轉換邏輯,首先改寫ProductService

    public class ProductService : IProductService
    {
        public void InsertProduct(InsertProductModel insertProductModel)
        {
            var productModel = Mapper.Map<ProductModel>(insertProductModel);           
    
            this.ProductRepository.InsertProduct(productModel);
        }
    }
    
  12. 接著改寫ProductRepository

    public class ProductRepository : IProductRepository
    {            
        public void InsertProduct(ProductModel productModel)
        {
            var prodcut = Mapper.Map<Product>(productModel);
    
            this.ShopContext.Products.Add(prodcut);
            this.ShopContext.SaveChanges();
        }
    }
    
  13. 重新測試新增資料,執行成功

從使用AutoMapper前和使用後的差別,你可以發現在BL和DA方法中處理資料轉換的程式碼都減少了許多,取而代之你可以在BL和DA的Mappings中找到處理資料轉換的邏輯,而在BL和DA的程式碼則更專注在執行商業邏輯或資料處理了,這也是SOC(Separation Of Concern)的精神,我們可以只專注在一種邏輯(不會同時要處理資料轉換、驗證和商業邏輯等等),也可以讓程式碼撰寫出錯的機率降低,帶來可讀性增高等好處。

延伸閱讀:

對資料轉換邏輯進行單元測試

如果我們的程式碼和一開始時一樣,那麼要獨立測試資料轉換的邏輯就是相當困難的一件事情,只能連同商業邏輯一起測試,而為了反應真實世界的各種挑戰,這次的資料輸入還包含了一組特別的設計,就是必須使用"解析字串"的方式來將資料對應到Model中,若是已經被大量實作在程式碼的各處,那麼修改資料規格就是一個地獄了!

接下來就要像大家介紹怎麼測試資料轉換的邏輯 (這邊以BL為例,其它測試可以參考Github上的原始碼)

  1. 在BL建立Mapping.Test測試專案,新增字串轉換為Gift功能.feature

    #language: zh-TW
    功能: 字串轉換為Gift功能
        提供給 BL層
        使用者輸入Gift格式為字串,將字串轉換為GiftModel
    
  2. 建立背景,註冊AutoMapper使用Profile

    背景: 
       假設 使用GiftModelMappingProfile
    
    [Given(@"使用GiftModelMappingProfile")]
    public void 假設使用GiftModelMappingProfile()
    {
        Mapper.AddProfile<GiftModelMappingProfile>();
        Mapper.AssertConfigurationIsValid();
    }
    
  3. 建立測試案例,分別測試單一Model和Model清單的轉換

    場景: 字串轉換為Gift Model
        假設 輸入字串為Gift1:AAA
        當 執行轉換字串為Gift Model
        那麼 Gift Model為
            | Name  | Description |
            | Gift1 | AAA         |
    
    場景: 字串轉換為Gift List
        假設 輸入字串為Gift1:AAA;Gift2:BBB;Gift3:CCC
        當 執行轉換字串為Gift List
        那麼 Gift List為
            | Name  | Description |
            | Gift1 | AAA         |
            | Gift2 | BBB         |
            | Gift3 | CCC         |
    
    private string giftString = string.Empty;
    private GiftModel gift;
    private IEnumerable<GiftModel> giftList;
    
    [Given(@"輸入字串為(.*)")]
    public void 假設輸入字串為(string giftString)
    {
        this.giftString = giftString;
    }
    
    [When(@"執行轉換字串為Gift Model")]
    public void 當執行轉換字串為GiftModel()
    {
        this.gift = Mapper.Map<GiftModel>(this.giftString);
    }
    
    [Then(@"Gift Model為")]
    public void 那麼GiftModel為(Table table)
    {
        table.CompareToInstance(this.gift);
    }
    
    [When(@"執行轉換字串為Gift List")]
    public void 當執行轉換字串為GiftList()
    {
        this.giftList = Mapper.Map<IEnumerable<GiftModel>>(this.giftString);
    }
    
    [Then(@"Gift List為")]
    public void 那麼GiftList為(Table table)
    {
        table.CompareToSet(this.giftList);
    }    
    
  4. 接下來撰寫ProductModel的轉換測試,新增InsertProductModel轉換為ProductModel功能.feature

    #language: zh-TW
    功能: InsertProductModel轉換為ProductModel功能
        提供給 BL層
        使用者輸入InsertProductModel資料,將資料轉換為ProductModel
    
  5. 新增背景來註冊AutoMapper的Profile

    背景: 
        假設 使用GiftModelMappingProfile
        假設 使用ProductModelMappingProfile 
    
    [Given(@"使用ProductModelMappingProfile")]
    public void 假設使用ProductModelMappingProfile()
    {
        Mapper.AddProfile<ProductModelMappingProfile>();
        Mapper.AssertConfigurationIsValid();
    }
    

    註: 這邊不需要再撰寫假設使用GiftModelMappingProfile,因為Specflow的Step可以跨Feature使用

  6. 補上測試案例

    場景: 輸入InsertProductModel資料, 轉換為Product Model
        假設 輸入資料為
            | Name    | Price | Cost | Introduction | StartListingAt | FinishListingAt | StartSellAt | FinishSellAt | CategoryId | Gifts     |
            | Product | 200   | 100  | Description  | 2013-10-01     | 2014-10-01      | 2013-10-02  | 2014-09-30   | 1          | Gift1:AAA |
        當 執行轉換為Product Model
        那麼 Product Model為
            | Name    | Price | Cost | Description | ListingStartTime | ListingEndTime | SellingStartTime | SellingEndTime | CategoryId |
            | Product | 200   | 100  | Description | 2013-10-01       | 2014-10-01     | 2013-10-02       | 2014-09-30     | 1          |
        並且 包含Gift List
            | Name  | Description |
            | Gift1 | AAA         |
    
    private InsertProductModel inputModel;
    private ProductModel model;
    
    [Given(@"輸入資料為")]
    public void 假設輸入資料為(Table table)
    {
        this.inputModel = table.CreateInstance<InsertProductModel>();
    }
    
    [When(@"執行轉換為Product Model")]
    public void 當執行轉換為ProductModel()
    {
        this.model = Mapper.Map<ProductModel>(this.inputModel);
    }
    
    [Then(@"Product Model為")]
    public void 那麼ProductModel為(Table table)
    {
        table.CompareToInstance(this.model);
    }
    
    [Then(@"包含Gift List")]
    public void 那麼包含GiftList(Table table)
    {
        table.CompareToSet(this.model.Gifts);
    }
    
  7. 執行測試,測試成功

補上了測試案例之後,我們就可以驗證所有轉換的邏輯是否正確,就連像是字串轉換成Model這種情境我們都可以用最單純的情況來測試,並且可以用最小的成本來增加測試案例(因為不需要再寫程式碼,只需要在Feature當中補上案例),而且一但有錯誤產生時,我們還可以透過增加測試案例,來讓錯誤不會再發生第二次,大大的增加程式碼的可靠度。

本日小結

AutoMapper的好處是讓我們可以更加輕鬆的封裝資料轉換的邏輯,並且我們透過AutoFac來掛載Profile到AutoMapper之中,讓我們在處理資料轉換邏輯時更有彈性,也大大的增加程式碼的可讀性。另外因為我們已經將資料轉換邏輯獨立出來,因此在以往比較難進行的單元測試也可以輕鬆的完成,確保資料轉換前後不會發生錯誤!關於今天的內容歡迎大家一起討論 ^_^

Comments

comments powered by Disqus