使用 .NET Core 3.x 構建 RESTFUL Api (續)

關於Entity Model vs 面向外部的Model

Entity Framework Core 使用 Entity Model 用來表示資料庫裡面的記錄。

面向外部的Model 則表示要傳輸的東西,有時候被稱為 Dto,有時候被稱為 ViewModel。

關於Dto,API消費者通過Dto,僅提供給用戶需要的數據起到隔離的作用,防止API消費者直接接觸到核心的Entity Model。

可能你會覺得有點多餘,但是仔細想想你會發現,Dto的存在是很有必要的。

Entity Model 與資料庫實際上應該是有種依賴的關係,資料庫某一項功能發生改變,Entity Model也應該會做出相應的動作,那麼這個時候 API消費者在請求伺服器介面數據時,如果直接接觸到了 Entity Model數據,那麼它也就無法預測到底是哪一項功能做出了改變。這個時候可能在做 API 請求的時候發生不可預估的錯誤。Dto的存在一定程度上解決了這一問題。

那麼它的作用是?

  • 系統更加健壯
  • 系統更加可靠
  • 系統易於進化

編寫Company的 Dto:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Routine.Api.Models
{
    public class CompanyDto
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
    }
}

對比Company的 Entity Model:

using System;
using System.Collections.Generic;
namespace Routine.Api.Entities
{
    /// <summary>
    /// 公司
    /// </summary>
    public class Company
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Introduction { get; set; }
        public ICollection<Employee> Employees { get; set; }
    }
}

Id和Name屬性是一致的,對於 Employees集合 以及 Introduction 字元串為了區分,這裡不提供給 Dto

如何使用?

這裡就涉及到了如何從 Entity Model 的數據轉化到 Dto

分析:我們給API消費者提供的數據肯定是一個集合,那麼可以先將Company的Dto定義為一個List集合,再通過循環 Entity Model 的數據,將數據添加到集合併且賦值給 Dto 對應的屬性。

控制器程式碼:

[HttpGet]
        //IActionResult定義了一些合約,它可以代表ActionResult返回的結果
public async Task<ActionResult<IEnumerable<CompanyDto>>> GetCompanies()
{
      var companies =await _companyRepository.GetCompaniesAsync();//讀取出來的是List
      var companyDtos = new List<CompanyDto>();
      foreach (var company in companies)
      {
           companyDtos.Add(new CompanyDto
           {
                Id = company.Id,
                Name = company.Name
               });
           };
           return Ok(companyDtos); 
       }
}

這裡你可能注意到了 返回的是 ActionResult<T>

關於 ActionResult<T>,好處就是讓 API 消費者意識到此介面的返回類型,就是將介面的返回類型進一步的明確,可以方便調用,讓程式碼的可讀性也更高。

你可以返回IEnumerable類型,也可以直接返回List,當然這兩者並沒有什麼區別,因為List也實現了 IEnumerable 這個介面!

那麼這樣做會面臨又一個問題。如果 Dto 需要的數據又20甚至50條往上,那麼這樣寫會顯得非常的笨拙而且也很容易出錯。

如何處理呢? dotnet生態給我們提供了一個很好的對象屬性映射器 AutoMapper!!!

關於 AutoMapper,官方解釋:基於約定的對象屬性映射器。

它還存在一個作用,在處理映射關係時出現如果出現空引用異常,就是映射的目標類型出現了與源類型不匹配的屬性欄位,那麼就會自動忽略這一異常。

如何下載?

打開 nuget 工具包,搜索 AutoMapper ,下載第二個!!! 原因是這個更好的實現依賴注入,可以看到它也依賴於 AutoMapper,相當於把第一個也一併下載了。

如何使用 AutoMapper?

第一步進入 Startup類 註冊AutoMapper服務!

public void ConfigureServices(IServiceCollection services)
        {
            //services.AddMvc(); core 3.0以前是這樣寫的,這個服務包括了TageHelper等 WebApi不需要的東西,所有3.0以後可以不這樣寫
            services.AddControllers();

            //註冊AutoMapper服務
            services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

            //配置介面服務:涉及到這個服務註冊的生命周期這裡採用AddScoped,表示每次的Http請求
            services.AddScoped<ICompanyRepository, CompanyRepository>();

            //獲取配置文件中的資料庫字元串連接
            var sqlConnection = Configuration.GetConnectionString("SqlServerConnection");

            //配置上下文類DbContext,因為它本身也是一套服務
            services.AddDbContext<RoutineDbContext>(options =>
            {
                options.UseSqlServer(sqlConnection);
            });
        }

關於 AddAutoMapper() 方法,實際上它需要返回一個 程式集數組,就是AutoMapper的運行配置文件,那麼通過 GetAssemblies 去掃描AutoMapper下的所有配置文件即可。

第二步:建立處理 AutoMapper 映射類

using AutoMapper;
using Routine.Api.Entities;
using Routine.Api.Models;

namespace Routine.Api.Profiles
{
    public class CompanyProfiles:Profile
    {
        public CompanyProfiles()
        {
            //添加映射關係,處理源類型與映射目標類型屬性名稱不一致的問題
            //參數一:源類型,參數二:目標映射類型
            CreateMap<Company, CompanyDto>()
                .ForMember(target=>target.CompanyName,
                    opt=> opt.MapFrom(src=>src.Name));
        }
    }
}

分析:通過CreateMap,對於參數一:源類型,參數二:目標映射類型。

關於 ForMember方法的作用,有時候你得考慮一個情況,前面已經說過,AutoMapper 是基於約定的對象到對象(Object-Object)的屬性映射器,如果所映射的屬性欄位不一致一定是無法映射成功的!

約定即屬性欄位與源類型屬性名稱須一致!!!但是你也可以處理這一情況的發生,通過lambda表達式,將目標映射類型和源類型關係重映射即可。

第三步:開始數據映射

先來看映射前的程式碼:通過集合循環賦值:

[HttpGet]
        //IActionResult定義了一些合約,它可以代表ActionResult返回的結果
        public async Task<ActionResult<IEnumerable<CompanyDto>>> GetCompanies()
        {
            var companies =await _companyRepository.GetCompaniesAsync();//讀取出來的是List

            var companyDtos = new List<CompanyDto>();
            foreach (var company in companies)
            {
                companyDtos.Add(new CompanyDto
                {
                    Id = company.Id,
                    Name = company.Name
                });
            }
            return Ok(companyDtos); 
        }

通過 AutoMapper映射:

[HttpGet]
        //IActionResult定義了一些合約,它可以代表ActionResult返回的結果
        public async Task<ActionResult<IEnumerable<CompanyDto>>> GetCompanies()
        {
            var companies =await _companyRepository.GetCompaniesAsync();//讀取出來的是List

            var companyDtos = _mapper.Map<IEnumerable<CompanyDto>>(companies);
            return Ok(companyDtos); 
        }

分析:Map()方法處理需要返回的目標映射類型,然後帶入源類型。

關於獲取父子關係的資源:

所謂 父:Conmpany(公司)、子:Employees(員工)

可能你注意到了基本上就是主從表的引用關係

那麼我們在設計AP uri 的時候也需要考慮到這一點

 

需求案例 1:查詢某一公司下的所有員工資訊

分析:設計到員工資訊,也需要需要實現 Entity Model 對 EmployeeDtos 的轉換,所以需要建立 EmployeeDto

對比 Employee 的 Entity Model和EmployeeDto

Entity Model 程式碼:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Routine.Api.Entities
{
    /// <summary>
    /// 員工
    /// </summary>
    public class Employee
    {
        public Guid Id { get; set; }
        //公司外鍵
        public Guid CompanyId { get; set; }
        //公司表導航屬性
        public Company Company { get; set; }
        public string EmployeeNo { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        //性別枚舉
        public Gender Gender { get; set; }
        public DateTime DateOfBirth { get; set; }
    }
}

EmployeeDto 程式碼:

分析:對性別 Gender 枚舉類型做了處理,改成了string類型,方便調用。另外對於姓名 Name 也是將 FirstName 和 LastName合併,年齡 Age 改成了 int類型

那麼,這些改動我們都需要在 EmployeeProfile類中在映射時進行標註,不然由於對象屬性映射器的約定,無法進行映射!!!

using System;

namespace Routine.Api.Models
{
    public class EmployeeDto
    {
        public Guid Id { get; set; }
        public Guid CompanyId { get; set; }
        public string EmployeeNo { get; set; }
        public string Name { get; set; }
        public string GenderDispaly { get; set; }
        public int Age { get; set; }
    }
}

EmployeeProfile類程式碼:

邏輯和 CompanyProfile類的映射是一樣的

using AutoMapper;
using Routine.Api.Entities;
using Routine.Api.Models;
using System;

namespace Routine.Api.Profiles
{
    public class EmployeeProfile:Profile
    {
        public EmployeeProfile()
        {
            CreateMap<Employee, EmployeeDto>()
                .ForMember(target => target.Name,
                    opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
                .ForMember(target=>target.GenderDispaly,
                    opt=>opt.MapFrom(src=>src.Gender.ToString()))
                .ForMember(target=>target.Age,
                    opt=>opt.MapFrom(src=>DateTime.Now.Year-src.DateOfBirth.Year));
        }
    }
}

接下來開始建立 EmployeeController 控制器,來通過映射器實現映射關係

EmployeeController :

需要注意 uir 的設計,我們查詢的是某一個公司下的所有員工資訊,所以也需要是 Entity Model 對 EmployeeDtos的轉換,同樣是藉助 對象屬性映射器。

using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using Routine.Api.Models;
using Routine.Api.Service;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Routine.Api.Controllers
{

    [ApiController]
    [Route("api/companies/{companyId}/employees")]
    public class EmployeesController:ControllerBase
    {
        private readonly IMapper _mapper;
        private readonly ICompanyRepository _companyRepository; 
        public EmployeesController(IMapper mapper, ICompanyRepository companyRepository)
        {
            _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
            _companyRepository = companyRepository ?? throw new ArgumentNullException(nameof(companyRepository));
        }
        [HttpGet]
        public async Task<ActionResult<IEnumerable<EmployeeDto>>> GetEmployeesForCompany(Guid companyId)
        {
            if (! await _companyRepository.CompanyExistsAsync(companyId))
            {
                return NotFound();
            }
            var employees =await _companyRepository.GetEmployeesAsync(companyId);
            var employeeDtos = _mapper.Map<IEnumerable<EmployeeDto>>(employees);
            return Ok(employeeDtos);
        }
    }
}

介面測試(某一公司下的所有員工資訊):

 

需求案例 2:查詢某一公司下的某一員工資訊

來想想相比需求案例1哪些地方需要進行改動的?

既然是某一個員工,說明 uir 需要加個員工的參數 Id進去。

還有除了判斷該公司是否存在,還需要判斷該員工是否存在。

另外,既然是某一個員工,所以返回的應該是個對象而非IEnumable集合。

程式碼:

[HttpGet("{employeeId}")]
        public async Task<ActionResult<EmployeeDto>> GetEmployeeForCompany(Guid companyId,Guid employeeId)
        {
            //判斷公司存不存在
            if (!await _companyRepository.CompanyExistsAsync(companyId))
            {
                return NotFound();
            }
            //判斷員工存不存在
            var employee = await _companyRepository.GetEmployeeAsync(companyId, employeeId);
            if (employee==null)
            {
                return NotFound();
            }
            //映射到 Dto
            var employeeDto = _mapper.Map<EmployeeDto>(employee);
            return Ok(employeeDto);
        }

介面測試(某一公司下的某一員工資訊):

可以看到測試成功!

關於故障處理:

這裡的「故障」主要是指伺服器故障或者是拋出異常的故障,ASP.NET Core 對於 伺服器故障一般會引發 500 狀態碼錯誤,對於這種錯誤,會導致一種後果就是在出現故障後

故障資訊會將程式異常細節顯示出來,這就對API消費者不夠友好,而且也造成一定的安全隱患。但此後果是在開發環境下產生也就是 Development。

當然ASP.NET Core開發團隊也意識到了這種問題!

偽造程式異常:

引發異常後介面測試:

 

可以看到此異常已經暴露了程式細節給 API 消費者 ,這種做法欠妥。

怎麼辦呢? 試試改一下開發的環境狀態!

 

 

 

重新測試介面:

問題解決!

 

 

但是你可能想根據這些異常拋出一些自定義的資訊給 API 消費者 實際上也可以。

回到 Stratup 類:添加一個中間件 app.UseExceptionHandler即可

分析:意思是如果有未處理的異常發生的時候就會走 else 裡面的程式碼,實際項目中這一塊需要記錄一下日誌

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler(appBulider =>
                {
                    appBulider.Run(async context =>
                    {
                        context.Response.StatusCode = 500
                        await context.Response.WriteAsync("The program Error!");
                    });
                });
            }
            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }

再來測試一下介面是否成功返回自定義異常資訊:

 

測試成功!!!