使用 ASP.NET Core 構建 ASP.NET Web API

已發表: 2022-03-11

介紹

幾年前,我得到了“Pro ASP.NET Web API”一書。 這篇文章是本書的一些想法、一點 CQRS 和我自己開發客戶端-服務器系統的經驗的分支。

在本文中,我將介紹:

  • 如何使用 .NET Core、EF Core、AutoMapper 和 XUnit 從頭開始創建 REST API
  • 如何確保 API 在更改後正常工作
  • 如何盡可能簡化 REST API 系統的開發和支持

為什麼選擇 ASP.NET Core?

ASP.NET Core 提供了對 ASP.NET MVC/Web API 的許多改進。 首先,它現在是一個框架,而不是兩個。 我真的很喜歡它,因為它很方便並且沒有那麼混亂。 其次,我們有日誌和 DI 容器,沒有任何額外的庫,這節省了我的時間,讓我可以專注於編寫更好的代碼,而不是選擇和分析最好的庫。

什麼是查詢處理器?

當與系統的一個實體相關的所有業務邏輯都封裝在一個服務中並且對該實體的任何訪問或操作都通過該服務執行時,查詢處理器是一種方法。 此服務通常稱為 {EntityPluralName}QueryProcessor。 如有必要,查詢處理器包括該實體的 CRUD(創建、讀取、更新、刪除)方法。 根據要求,並非所有方法都可以實現。 舉個具體的例子,我們來看一下ChangePassword。 如果查詢處理器的方法需要輸入數據,則只應提供所需的數據。 通常,對於每個方法,都會創建一個單獨的查詢類,在簡單的情況下,可以(但不希望)重用查詢類。

我們的目標

在本文中,我將向您展示如何為小型成本管理系統製作 API,包括身份驗證和訪問控制的基本設置,但我不會深入介紹身份驗證子系統。 我將通過模塊化測試涵蓋系統的整個業務邏輯,並在一個實體的示例上為每個 API 方法創建至少一個集成測試。

對開發系統的要求: 用戶可以添加、編輯、刪除自己的費用,並且只能看到自己的費用。

該系統的完整代碼可在 Github 上獲得。

所以,讓我們開始設計我們的小但非常有用的系統。

API 層

顯示 API 層的圖表。

該圖顯示該系統將有四個層次:

  • 數據庫 - 我們在這裡存儲數據,僅此而已,沒有邏輯。
  • DAL - 為了訪問數據,我們使用工作單元模式,在實現中,我們使用 ORM EF Core 和代碼優先和遷移模式。
  • 業務邏輯——封裝業務邏輯,我們使用查詢處理器,只有這一層處理業務邏輯。 例外是最簡單的驗證,例如必填字段,它將通過 API 中的過濾器執行。
  • REST API - 客戶端可以通過其使用我們的 API 的實際接口將通過 ASP.NET Core 實現。 路由配置由屬性決定。

除了描述的層之外,我們還有幾個重要的概念。 首先是數據模型的分離。 客戶端數據模型主要用在 REST API 層。 它將查詢轉換為域模型,反之亦然,從域模型到客戶端數據模型,但查詢模型也可以在查詢處理器中使用。 轉換是使用 AutoMapper 完成的。

項目結構

我使用 VS 2017 Professional 創建項目。 我通常在不同的文件夾中共享源代碼和測試。 很舒服,看起來不錯,CI中的測試運行方便,而且微軟似乎建議這樣做:

VS 2017 Professional 中的文件夾結構。

項目介紹:

項目描述
花費控制器項目,域模型和 API 模型之間的映射,API 配置
費用.Api.Common 此時,有收集到的異常類,通過過濾器以某種方式解釋,向用戶返回正確的帶有錯誤的 HTTP 代碼
費用.Api.Models API 模型項目
費用.數據.訪問工作單元模式的接口和實現項目
費用.數據.模型領域模型項目
費用.查詢查詢處理器和查詢特定類的項目
費用.安全當前用戶的安全上下文的接口和實現的項目

項目之間的參考:

顯示項目之間引用的圖表。

從模板創建的費用:

從模板創建的費用清單。

src文件夾中的其他項目按模板:

src 文件夾中其他項目的模板列表。

測試文件夾中的所有項目按模板:

按模板列出測試文件夾中的項目。

執行

本文不會描述與 UI 相關的部分,儘管它已實現。

第一步是開發一個位於程序集Expenses.Data.Model中的數據模型:

角色關係圖

Expense類包含以下屬性:

 public class Expense { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public virtual User User { get; set; } public bool IsDeleted { get; set; } }

此類通過IsDeleted屬性支持“軟刪除”,並包含特定用戶的一筆費用的所有數據,這些數據將來對我們有用。

UserRoleUserRole類是指訪問子系統; 該系統不偽裝為年度系統,對該子系統的描述不是本文的目的; 因此,將省略數據模型和一些實現細節。 在不改變業務邏輯的情況下,可以用更完善的訪問組織系統來代替。

接下來,在Expenses.Data.Access程序集中實現工作單元模板,該項目的結構如下所示:

費用.數據.訪問項目結構

組裝需要以下庫:

  • Microsoft.EntityFrameworkCore.SqlServer

有必要實現一個EF上下文,它將自動在特定文件夾中查找映射:

 public class MainDbContext : DbContext { public MainDbContext(DbContextOptions<MainDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { var mappings = MappingsHelper.GetMainMappings(); foreach (var mapping in mappings) { mapping.Visit(modelBuilder); } } }

映射是通過MappingsHelper類完成的:

 public static class MappingsHelper { public static IEnumerable<IMap> GetMainMappings() { var assemblyTypes = typeof(UserMap).GetTypeInfo().Assembly.DefinedTypes; var mappings = assemblyTypes // ReSharper disable once AssignNullToNotNullAttribute .Where(t => t.Namespace != null && t.Namespace.Contains(typeof(UserMap).Namespace)) .Where(t => typeof(IMap).GetTypeInfo().IsAssignableFrom(t)); mappings = mappings.Where(x => !x.IsAbstract); return mappings.Select(m => (IMap) Activator.CreateInstance(m.AsType())).ToArray(); } }

到類的映射位於Maps文件夾中,並映射為Expenses

 public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }

接口IUnitOfWork

 public interface IUnitOfWork : IDisposable { ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot); void Add<T>(T obj) where T: class ; void Update<T>(T obj) where T : class; void Remove<T>(T obj) where T : class; IQueryable<T> Query<T>() where T : class; void Commit(); Task CommitAsync(); void Attach<T>(T obj) where T : class; }

它的實現是EF DbContext的包裝器:

 public class EFUnitOfWork : IUnitOfWork { private DbContext _context; public EFUnitOfWork(DbContext context) { _context = context; } public DbContext Context => _context; public ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot) { return new DbTransaction(_context.Database.BeginTransaction(isolationLevel)); } public void Add<T>(T obj) where T : class { var set = _context.Set<T>(); set.Add(obj); } public void Update<T>(T obj) where T : class { var set = _context.Set<T>(); set.Attach(obj); _context.Entry(obj).State = EntityState.Modified; } void IUnitOfWork.Remove<T>(T obj) { var set = _context.Set<T>(); set.Remove(obj); } public IQueryable<T> Query<T>() where T : class { return _context.Set<T>(); } public void Commit() { _context.SaveChanges(); } public async Task CommitAsync() { await _context.SaveChangesAsync(); } public void Attach<T>(T newUser) where T : class { var set = _context.Set<T>(); set.Attach(newUser); } public void Dispose() { _context = null; } }

此應用程序中實現的接口ITransaction將不會被使用:

 public interface ITransaction : IDisposable { void Commit(); void Rollback(); }

它的實現只是簡單地包裝了EF事務:

 public class DbTransaction : ITransaction { private readonly IDbContextTransaction _efTransaction; public DbTransaction(IDbContextTransaction efTransaction) { _efTransaction = efTransaction; } public void Commit() { _efTransaction.Commit(); } public void Rollback() { _efTransaction.Rollback(); } public void Dispose() { _efTransaction.Dispose(); } }

同樣在這個階段,對於單元測試,需要ISecurityContext接口,它定義了 API 的當前用戶(項目是Expenses.Security ):

 public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }

接下來,您需要定義查詢處理器的接口和實現,其中將包含處理成本的所有業務邏輯——在我們的例子中是IExpensesQueryProcessorExpensesQueryProcessor

 public interface IExpensesQueryProcessor { IQueryable<Expense> Get(); Expense Get(int id); Task<Expense> Create(CreateExpenseModel model); Task<Expense> Update(int id, UpdateExpenseModel model); Task Delete(int id); } public class ExpensesQueryProcessor : IExpensesQueryProcessor { public IQueryable<Expense> Get() { throw new NotImplementedException(); } public Expense Get(int id) { throw new NotImplementedException(); } public Task<Expense> Create(CreateExpenseModel model) { throw new NotImplementedException(); } public Task<Expense> Update(int id, UpdateExpenseModel model) { throw new NotImplementedException(); } public Task Delete(int id) { throw new NotImplementedException(); } }

下一步是配置Expenses.Queries.Tests程序集。 我安裝了以下庫:

  • 起訂量
  • 流利的斷言

然後在Expenses.Queries.Tests程序集中,我們定義單元測試的夾具並描述我們的單元測試:

 public class ExpensesQueryProcessorTests { private Mock<IUnitOfWork> _uow; private List<Expense> _expenseList; private IExpensesQueryProcessor _query; private Random _random; private User _currentUser; private Mock<ISecurityContext> _securityContext; public ExpensesQueryProcessorTests() { _random = new Random(); _uow = new Mock<IUnitOfWork>(); _expenseList = new List<Expense>(); _uow.Setup(x => x.Query<Expense>()).Returns(() => _expenseList.AsQueryable()); _currentUser = new User{Id = _random.Next()}; _securityContext = new Mock<ISecurityContext>(MockBehavior.Strict); _securityContext.Setup(x => x.User).Returns(_currentUser); _securityContext.Setup(x => x.IsAdministrator).Returns(false); _query = new ExpensesQueryProcessor(_uow.Object, _securityContext.Object); } [Fact] public void GetShouldReturnAll() { _expenseList.Add(new Expense{UserId = _currentUser.Id}); var result = _query.Get().ToList(); result.Count.Should().Be(1); } [Fact] public void GetShouldReturnOnlyUserExpenses() { _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get().ToList(); result.Count().Should().Be(1); result[0].UserId.Should().Be(_currentUser.Id); } [Fact] public void GetShouldReturnAllExpensesForAdministrator() { _securityContext.Setup(x => x.IsAdministrator).Returns(true); _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get(); result.Count().Should().Be(2); } [Fact] public void GetShouldReturnAllExceptDeleted() { _expenseList.Add(new Expense { UserId = _currentUser.Id }); _expenseList.Add(new Expense { UserId = _currentUser.Id, IsDeleted = true}); var result = _query.Get(); result.Count().Should().Be(1); } [Fact] public void GetShouldReturnById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); var result = _query.Get(expense.Id); result.Should().Be(expense); } [Fact] public void GetShouldThrowExceptionIfExpenseOfOtherUser() { var expense = new Expense { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfItemIsNotFoundById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); Action get = () => { _query.Get(_random.Next()); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfUserIsDeleted() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id, IsDeleted = true}; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public async Task CreateShouldSaveNew() { var model = new CreateExpenseModel { Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now }; var result = await _query.Create(model); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); result.UserId.Should().Be(_currentUser.Id); _uow.Verify(x => x.Add(result)); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task UpdateShouldUpdateFields() { var user = new Expense {Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); var model = new UpdateExpenseModel { Comment = _random.Next().ToString(), Description = _random.Next().ToString(), Amount = _random.Next(), Date = DateTime.Now }; var result = await _query.Update(user.Id, model); result.Should().Be(user); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); _uow.Verify(x => x.CommitAsync()); } [Fact] public void UpdateShoudlThrowExceptionIfItemIsNotFound() { Action create = () => { var result = _query.Update(_random.Next(), new UpdateExpenseModel()).Result; }; create.ShouldThrow<NotFoundException>(); } [Fact] public async Task DeleteShouldMarkAsDeleted() { var user = new Expense() { Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); await _query.Delete(user.Id); user.IsDeleted.Should().BeTrue(); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task DeleteShoudlThrowExceptionIfItemIsNotBelongTheUser() { var expense = new Expense() { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action execute = () => { _query.Delete(expense.Id).Wait(); }; execute.ShouldThrow<NotFoundException>(); } [Fact] public void DeleteShoudlThrowExceptionIfItemIsNotFound() { Action execute = () => { _query.Delete(_random.Next()).Wait(); }; execute.ShouldThrow<NotFoundException>(); }

在描述了單元測試之後,描述了查詢處理器的實現:

 public class ExpensesQueryProcessor : IExpensesQueryProcessor { private readonly IUnitOfWork _uow; private readonly ISecurityContext _securityContext; public ExpensesQueryProcessor(IUnitOfWork uow, ISecurityContext securityContext) { _uow = uow; _securityContext = securityContext; } public IQueryable<Expense> Get() { var query = GetQuery(); return query; } private IQueryable<Expense> GetQuery() { var q = _uow.Query<Expense>() .Where(x => !x.IsDeleted); if (!_securityContext.IsAdministrator) { var userId = _securityContext.User.Id; q = q.Where(x => x.UserId == userId); } return q; } public Expense Get(int id) { var user = GetQuery().FirstOrDefault(x => x.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } return user; } public async Task<Expense> Create(CreateExpenseModel model) { var item = new Expense { UserId = _securityContext.User.Id, Amount = model.Amount, Comment = model.Comment, Date = model.Date, Description = model.Description, }; _uow.Add(item); await _uow.CommitAsync(); return item; } public async Task<Expense> Update(int id, UpdateExpenseModel model) { var expense = GetQuery().FirstOrDefault(x => x.Id == id); if (expense == null) { throw new NotFoundException("Expense is not found"); } expense.Amount = model.Amount; expense.Comment = model.Comment; expense.Description = model.Description; expense.Date = model.Date; await _uow.CommitAsync(); return expense; } public async Task Delete(int id) { var user = GetQuery().FirstOrDefault(u => u.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } if (user.IsDeleted) return; user.IsDeleted = true; await _uow.CommitAsync(); } }

一旦業務邏輯準備就緒,我就開始編寫 API 集成測試以確定 API 契約。

第一步是準備一個項目Expenses.Api.IntegrationTests

  1. 安裝 nuget 包:
    • 流利的斷言
    • 起訂量
    • Microsoft.AspNetCore.TestHost
  2. 設置項目結構
    項目結構
  3. 創建一個 CollectionDefinition ,借助它我們確定將在每次測試運行開始時創建並在每次測試運行結束時銷毀的資源。
 [CollectionDefinition("ApiCollection")] public class DbCollection : ICollectionFixture<ApiServer> { } ~~~ And define our test server and the client to it with the already authenticated user by default:

公共類 ApiServer : IDisposable { public const string Username = “admin”; 公共常量字符串密碼=“管理員”;

 private IConfigurationRoot _config; public ApiServer() { _config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); Server = new TestServer(new WebHostBuilder().UseStartup<Startup>()); Client = GetAuthenticatedClient(Username, Password); } public HttpClient GetAuthenticatedClient(string username, string password) { var client = Server.CreateClient(); var response = client.PostAsync("/api/Login/Authenticate", new JsonContent(new LoginModel {Password = password, Username = username})).Result; response.EnsureSuccessStatusCode(); var data = JsonConvert.DeserializeObject<UserWithTokenModel>(response.Content.ReadAsStringAsync().Result); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + data.Token); return client; } public HttpClient Client { get; private set; } public TestServer Server { get; private set; } public void Dispose() { if (Client != null) { Client.Dispose(); Client = null; } if (Server != null) { Server.Dispose(); Server = null; } } } ~~~

為了方便在集成測試中處理HTTP請求,我編寫了一個幫助程序:

 public class HttpClientWrapper { private readonly HttpClient _client; public HttpClientWrapper(HttpClient client) { _client = client; } public HttpClient Client => _client; public async Task<T> PostAsync<T>(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } public async Task PostAsync(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); } public async Task<T> PutAsync<T>(string url, object body) { var response = await _client.PutAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } }

在這個階段,我需要為每個實體定義一個 REST API 合約,我會為 REST API 費用編寫它:

網址方法體型結果類型描述
費用得到- 數據結果<ExpenseModel> 在查詢參數“命令”中獲取所有可能使用過濾器和排序器的費用
費用/{id} 得到- 費用模型通過 id 獲取費用
花費郵政創建費用模型費用模型創建新的費用記錄
費用/{id}更新費用模型費用模型更新現有費用

當您請求成本列表時,您可以使用 AutoQueryable 庫應用各種過濾和排序命令。 帶有過濾和排序的示例查詢:

/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date

解碼命令參數值為take=25&amount>=12&orderbydesc=date 。 因此我們可以在查詢中找到分頁、過濾和排序部分。 所有查詢選項都與 OData 語法非常相似,但不幸的是,OData 還沒有為 .NET Core 做好準備,所以我正在使用另一個有用的庫。

底部顯示了此 API 中使用的所有模型:

 public class DataResult<T> { public T[] Data { get; set; } public int Total { get; set; } } public class ExpenseModel { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public string Username { get; set; } } public class CreateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } } public class UpdateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } }

模型CreateExpenseModelUpdateExpenseModel使用數據註釋屬性通過屬性在 REST API 級別執行簡單檢查。

接下來,對於每個HTTP方法,在項目中創建一個單獨的文件夾,其中的文件由夾具為資源支持的每個HTTP方法創建:

費用文件夾結構

實現獲取費用列表的集成測試:

 [Collection("ApiCollection")] public class GetListShould { private readonly ApiServer _server; private readonly HttpClient _client; public GetListShould(ApiServer server) { _server = server; _client = server.Client; } public static async Task<DataResult<ExpenseModel>> Get(HttpClient client) { var response = await client.GetAsync($"api/Expenses"); response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync(); var items = JsonConvert.DeserializeObject<DataResult<ExpenseModel>>(responseText); return items; } [Fact] public async Task ReturnAnyList() { var items = await Get(_client); items.Should().NotBeNull(); } }

通過id獲取費用數據的集成測試實現:

 [Collection("ApiCollection")] public class GetItemShould { private readonly ApiServer _server; private readonly HttpClient _client; private Random _random; public GetItemShould(ApiServer server) { _server = server; _client = _server.Client; _random = new Random(); } [Fact] public async Task ReturnItemById() { var item = await new PostShould(_server).CreateNew(); var result = await GetById(_client, item.Id); result.Should().NotBeNull(); } public static async Task<ExpenseModel> GetById(HttpClient client, int id) { var response = await client.GetAsync(new Uri($"api/Expenses/{id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject<ExpenseModel>(result); } [Fact] public async Task ShouldReturn404StatusIfNotFound() { var response = await _client.GetAsync(new Uri($"api/Expenses/-1", UriKind.Relative)); response.StatusCode.ShouldBeEquivalentTo(HttpStatusCode.NotFound); } }

實施用於創建費用的集成測試:

 [Collection("ApiCollection")] public class PostShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private Random _random; public PostShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task<ExpenseModel> CreateNew() { var requestItem = new CreateExpenseModel() { Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now.AddMinutes(-15), Description = _random.Next().ToString() }; var createdItem = await _client.PostAsync<ExpenseModel>("api/Expenses", requestItem); createdItem.Id.Should().BeGreaterThan(0); createdItem.Amount.Should().Be(requestItem.Amount); createdItem.Comment.Should().Be(requestItem.Comment); createdItem.Date.Should().Be(requestItem.Date); createdItem.Description.Should().Be(requestItem.Description); createdItem.Username.Should().Be("admin admin"); return createdItem; } }

更改費用的集成測試的實施:

 [Collection("ApiCollection")] public class PutShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private readonly Random _random; public PutShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task UpdateExistingItem() { var item = await new PostShould(_server).CreateNew(); var requestItem = new UpdateExpenseModel { Date = DateTime.Now, Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString() }; await _client.PutAsync<ExpenseModel>($"api/Expenses/{item.Id}", requestItem); var updatedItem = await GetItemShould.GetById(_client.Client, item.Id); updatedItem.Date.Should().Be(requestItem.Date); updatedItem.Description.Should().Be(requestItem.Description); updatedItem.Amount.Should().Be(requestItem.Amount); updatedItem.Comment.Should().Contain(requestItem.Comment); } }

實施用於去除費用的集成測試:

 [Collection("ApiCollection")] public class DeleteShould { private readonly ApiServer _server; private readonly HttpClient _client; public DeleteShould(ApiServer server) { _server = server; _client = server.Client; } [Fact] public async Task DeleteExistingItem() { var item = await new PostShould(_server).CreateNew(); var response = await _client.DeleteAsync(new Uri($"api/Expenses/{item.Id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); } }

至此,我們已經完全定義了 REST API 契約,現在我可以開始在 ASP.NET Core 的基礎上實現它了。

API 實現

準備項目費用。 為此,我需要安裝以下庫:

  • 自動映射器
  • AutoQueryable.AspNetCore.Filter
  • Microsoft.ApplicationInsights.AspNetCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.SqlServer.Design
  • Microsoft.EntityFrameworkCore.Tools
  • Swashbuckle.AspNetCore

之後,您需要通過打開包管理器控制台開始為數據庫創建初始遷移,切換到Expenses.Data.Access項目(因為EF上下文位於那裡)並運行Add-Migration InitialCreate命令:

包管理器控制台

在下一步中,提前準備配置文件 appsettings.json,準備好之後仍然需要將其複製到項目Expenses.Api.IntegrationTests中,因為從那裡,我們將運行測試實例 API。

 { "Logging": { "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } }, "Data": { "main": "Data Source=.; Initial Catalog=expenses.main; Integrated Security=true; Max Pool Size=1000; Min Pool Size=12; Pooling=True;" }, "ApplicationInsights": { "InstrumentationKey": "Your ApplicationInsights key" } }

日誌記錄部分是自動創建的。 我添加了Data部分以將連接字符串存儲到數據庫和我的ApplicationInsights密鑰。

應用程序配置

您必須配置我們應用程序中可用的不同服務:

開啟ApplicationInsightsservices.AddApplicationInsightsTelemetry(Configuration);

通過調用註冊您的服務: ContainerSetup.Setup(services, Configuration);

ContainerSetup是一個創建的類,因此我們不必在Startup類中存儲所有服務註冊。 該類位於 Expenses 項目的 IoC 文件夾中:

 public static class ContainerSetup { public static void Setup(IServiceCollection services, IConfigurationRoot configuration) { AddUow(services, configuration); AddQueries(services); ConfigureAutoMapper(services); ConfigureAuth(services); } private static void ConfigureAuth(IServiceCollection services) { services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddScoped<ITokenBuilder, TokenBuilder>(); services.AddScoped<ISecurityContext, SecurityContext>(); } private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); } private static void AddUow(IServiceCollection services, IConfigurationRoot configuration) { var connectionString = configuration["Data:main"]; services.AddEntityFrameworkSqlServer(); services.AddDbContext<MainDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped<IUnitOfWork>(ctx => new EFUnitOfWork(ctx.GetRequiredService<MainDbContext>())); services.AddScoped<IActionTransactionHelper, ActionTransactionHelper>(); services.AddScoped<UnitOfWorkFilterAttribute>(); } private static void AddQueries(IServiceCollection services) { var exampleProcessorType = typeof(UsersQueryProcessor); var types = (from t in exampleProcessorType.GetTypeInfo().Assembly.GetTypes() where t.Namespace == exampleProcessorType.Namespace && t.GetTypeInfo().IsClass && t.GetTypeInfo().GetCustomAttribute<CompilerGeneratedAttribute>() == null select t).ToArray(); foreach (var type in types) { var interfaceQ = type.GetTypeInfo().GetInterfaces().First(); services.AddScoped(interfaceQ, type); } } }

這個類中的幾乎所有代碼都是不言自明的,但我想進一步介紹一下ConfigureAutoMapper方法。

 private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }

此方法使用輔助類查找模型和實體之間的所有映射,反之亦然,並獲取IMapper接口來創建將在控制器中使用的IAutoMapper包裝器。 這個包裝器沒有什麼特別之處——它只是為AutoMapper方法提供了一個方便的接口。

 public class AutoMapperAdapter : IAutoMapper { private readonly IMapper _mapper; public AutoMapperAdapter(IMapper mapper) { _mapper = mapper; } public IConfigurationProvider Configuration => _mapper.ConfigurationProvider; public T Map<T>(object objectToMap) { return _mapper.Map<T>(objectToMap); } public TResult[] Map<TSource, TResult>(IEnumerable<TSource> sourceQuery) { return sourceQuery.Select(x => _mapper.Map<TResult>(x)).ToArray(); } public IQueryable<TResult> Map<TSource, TResult>(IQueryable<TSource> sourceQuery) { return sourceQuery.ProjectTo<TResult>(_mapper.ConfigurationProvider); } public void Map<TSource, TDestination>(TSource source, TDestination destination) { _mapper.Map(source, destination); } }

要配置 AutoMapper,需要使用輔助類,其任務是搜索特定命名空間類的映射。 所有映射都位於文件夾費用/地圖中:

 public static class AutoMapperConfigurator { private static readonly object Lock = new object(); private static MapperConfiguration _configuration; public static MapperConfiguration Configure() { lock (Lock) { if (_configuration != null) return _configuration; var thisType = typeof(AutoMapperConfigurator); var configInterfaceType = typeof(IAutoMapperTypeConfigurator); var configurators = thisType.GetTypeInfo().Assembly.GetTypes() .Where(x => !string.IsNullOrWhiteSpace(x.Namespace)) // ReSharper disable once AssignNullToNotNullAttribute .Where(x => x.Namespace.Contains(thisType.Namespace)) .Where(x => x.GetTypeInfo().GetInterface(configInterfaceType.Name) != null) .Select(x => (IAutoMapperTypeConfigurator)Activator.CreateInstance(x)) .ToArray(); void AggregatedConfigurator(IMapperConfigurationExpression config) { foreach (var configurator in configurators) { configurator.Configure(config); } } _configuration = new MapperConfiguration(AggregatedConfigurator); return _configuration; } } }

All mappings must implement a specific interface:

 public interface IAutoMapperTypeConfigurator { void Configure(IMapperConfigurationExpression configuration); }

An example of mapping from entity to model:

 public class ExpenseMap : IAutoMapperTypeConfigurator { public void Configure(IMapperConfigurationExpression configuration) { var map = configuration.CreateMap<Expense, ExpenseModel>(); map.ForMember(x => x.Username, x => x.MapFrom(y => y.User.FirstName + " " + y.User.LastName)); } }

Also, in the Startup.ConfigureServices method, authentication through JWT Bearer tokens is configured:

 services.AddAuthorization(auth => { auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser().Build()); });

And the services registered the implementation of ISecurityContext , which will actually be used to determine the current user:

 public class SecurityContext : ISecurityContext { private readonly IHttpContextAccessor _contextAccessor; private readonly IUnitOfWork _uow; private User _user; public SecurityContext(IHttpContextAccessor contextAccessor, IUnitOfWork uow) { _contextAccessor = contextAccessor; _uow = uow; } public User User { get { if (_user != null) return _user; var username = _contextAccessor.HttpContext.User.Identity.Name; _user = _uow.Query<User>() .Where(x => x.Username == username) .Include(x => x.Roles) .ThenInclude(x => x.Role) .FirstOrDefault(); if (_user == null) { throw new UnauthorizedAccessException("User is not found"); } return _user; } } public bool IsAdministrator { get { return User.Roles.Any(x => x.Role.Name == Roles.Administrator); } } }

Also, we changed the default MVC registration a little in order to use a custom error filter to convert exceptions to the right error codes:

services.AddMvc(options => { options.Filters.Add(new ApiExceptionFilter()); });

Implementing the ApiExceptionFilter filter:

public class ApiExceptionFilter : ExceptionFilterAttribute { public override void OnException(ExceptionContext context) { if (context.Exception is NotFoundException) { // handle explicit 'known' API errors var ex = context.Exception as NotFoundException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; } else if (context.Exception is BadRequestException) { // handle explicit 'known' API errors var ex = context.Exception as BadRequestException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else if (context.Exception is UnauthorizedAccessException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; } else if (context.Exception is ForbiddenException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } base.OnException(context); } }

重要的是不要忘記Swagger ,以便為其他 ASP.net 開發人員獲得出色的 API 描述:

 services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); }); 

API 文檔

Startup.Configure方法添加了對InitDatabase方法的調用,該方法會自動遷移數據庫,直到最後一次遷移:

 private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }

僅當應用程序在開發環境中運行並且不需要身份驗證即可訪問它時,才打開Swagger

 app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });

接下來,我們連接身份驗證(可以在存儲庫中找到詳細信息):

ConfigureAuthentication(app);

此時,您可以運行集成測試並確保所有內容都已編譯但沒有任何工作,然後轉到控制器ExpensesController

注意:所有控制器都位於Expenses/Server文件夾中,並且有條件地分為兩個文件夾:Controllers和RestApi。 在文件夾中,控制器是在舊的良好 MVC 中作為控制器工作的控制器——即,返回標記,在 RestApi 中,是 REST 控制器。

您必須創建 Expenses/Server/RestApi/ExpensesController 類並從 Controller 類繼承它:

 public class ExpensesController : Controller { }

接下來,通過使用屬性[Route ("api / [controller]")]標記類來配置~ / api / Expenses類型的路由。

要訪問業務邏輯和映射器,您需要注入以下服務:

 private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }

在這個階段,您可以開始實現方法。 第一種方法是獲取費用清單:

 [HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }

該方法的實現非常簡單,我們從ExpensesQueryProcessor獲取映射到IQueryable <ExpenseModel>中的數據庫的查詢,然後返回結果。

這裡的自定義屬性是QueryableResult ,它使用AutoQueryable庫來處理服務器端的分頁、過濾和排序。 該屬性位於文件夾Expenses/Filters中。 因此,此過濾器將DataResult <ExpenseModel>類型的數據返回給 API 客戶端。

 public class QueryableResult : ActionFilterAttribute { public override void OnActionExecuted(ActionExecutedContext context) { if (context.Exception != null) return; dynamic query = ((ObjectResult)context.Result).Value; if (query == null) throw new Exception("Unable to retreive value of IQueryable from context result."); Type entityType = query.GetType().GenericTypeArguments[0]; var commands = context.HttpContext.Request.Query.ContainsKey("commands") ? context.HttpContext.Request.Query["commands"] : new StringValues(); var data = QueryableHelper.GetAutoQuery(commands, entityType, query, new AutoQueryableProfile {UnselectableProperties = new string[0]}); var total = System.Linq.Queryable.Count(query); context.Result = new OkObjectResult(new DataResult{Data = data, Total = total}); } }

另外,讓我們看看 Post 方法的實現,創建一個流程:

 [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }

在這裡,您應該注意屬性ValidateModel ,它根據數據註釋屬性執行輸入數據的簡單驗證,這是通過內置的 MVC 檢查完成的。

 public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }

ExpensesController的完整代碼:

 [Route("api/[controller]")] public class ExpensesController : Controller { private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; } [HttpGet] [QueryableResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; } [HttpGet("{id}")] public ExpenseModel Get(int id) { var item = _query.Get(id); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPut("{id}")] [ValidateModel] public async Task<ExpenseModel> Put(int id, [FromBody]UpdateExpenseModel requestModel) { var item = await _query.Update(id, requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpDelete("{id}")] public async Task Delete(int id) { await _query.Delete(id); } }

結論

我先從問題說起:主要問題是解決方案的初始配置和理解應用層的複雜度,但是隨著應用程序複雜度的增加,系統的複雜度幾乎沒有變化,這是一個很大的問題再加上伴隨這樣的系統。 非常重要的是,我們有一個 API,它有一套集成測試和一套完整的業務邏輯單元測試。 業務邏輯與所使用的服務器技術完全分離,可以進行全面測試。 該解決方案非常適合具有復雜 API 和復雜業務邏輯的系統。

如果您希望構建一個使用您的 API 的 Angular 應用程序,請查看 Toptaler Pablo Albella 的Angular 5 和 ASP.NET Core