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 계층을 보여주는 다이어그램.

다이어그램은 시스템에 4개의 레이어가 있음을 보여줍니다.

  • 데이터베이스 - 여기에 데이터만 저장하고 논리도 없습니다.
  • DAL - 데이터에 액세스하기 위해 작업 단위 패턴을 사용하고 구현에서 ORM EF Core를 코드 우선 및 마이그레이션 패턴과 함께 사용합니다.
  • 비즈니스 논리 - 비즈니스 논리를 캡슐화하기 위해 쿼리 프로세서를 사용하며 이 계층만 비즈니스 논리를 처리합니다. API의 필터를 통해 실행되는 필수 필드와 같은 가장 간단한 유효성 검사는 예외입니다.
  • REST API - 클라이언트가 API로 작업할 수 있는 실제 인터페이스는 ASP.NET Core를 통해 구현됩니다. 경로 구성은 속성에 의해 결정됩니다.

설명된 레이어 외에도 몇 가지 중요한 개념이 있습니다. 첫 번째는 데이터 모델의 분리입니다. 클라이언트 데이터 모델은 주로 REST API 계층에서 사용됩니다. 쿼리를 도메인 모델로 또는 그 반대로 도메인 모델에서 클라이언트 데이터 모델로 변환하지만 쿼리 모델은 쿼리 프로세서에서도 사용할 수 있습니다. 변환은 AutoMapper를 사용하여 수행됩니다.

프로젝트 구조

VS 2017 Professional을 사용하여 프로젝트를 만들었습니다. 나는 보통 소스 코드와 테스트를 다른 폴더에 공유합니다. 편안하고 보기 좋고 CI 테스트가 편리하게 실행되며 Microsoft에서 다음과 같이 권장하는 것 같습니다.

VS 2017 Professional의 폴더 구조.

프로젝트 설명:

프로젝트 설명
경비 컨트롤러용 프로젝트, 도메인 모델과 API 모델 간의 매핑, API 구성
비용.Api.Common 이 시점에서 사용자에게 오류가 있는 올바른 HTTP 코드를 반환하기 위해 필터에 의해 특정 방식으로 해석되는 수집된 예외 클래스가 있습니다.
Expenses.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 속성을 통해 "소프트 삭제"를 지원하며 미래에 유용할 특정 사용자의 비용에 대한 모든 데이터를 포함합니다.

User , RoleUserRole 클래스는 액세스 하위 시스템을 참조합니다. 이 시스템은 올해의 시스템인 것처럼 가장하지 않으며 이 하위 시스템에 대한 설명은 이 기사의 목적이 아닙니다. 따라서 데이터 모델과 구현의 일부 세부 사항은 생략됩니다. 접근 조직의 체계는 비즈니스 로직을 바꾸지 않고도 보다 완벽한 체계로 대체될 수 있습니다.

다음으로, 작업 단위 템플릿이 Expenses.Data.Access 어셈블리에서 구현되었으며 이 프로젝트의 구조가 표시됩니다.

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

또한 이 단계에서 단위 테스트를 위해 API의 현재 사용자를 정의하는 ISecurityContext 인터페이스가 필요합니다(프로젝트는 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 어셈블리를 구성하는 것입니다. 다음 라이브러리를 설치했습니다.

  • 모크
  • FluentAssertions

그런 다음 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. 너겟 패키지 설치:
    • FluentAssertions
    • 모크
    • 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 { 공개 const 문자열 사용자 이름 = "관리자"; 공개 const 문자열 암호 = "관리자";

 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 비용에 대해 작성하겠습니다.

URL 방법 체형 결과 유형 설명
비용 가져 오기 - 데이터 결과<비용 모델> 쿼리 매개변수 "commands"에서 필터 및 분류기를 사용할 수 있는 모든 비용 가져오기
비용/{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" } }

로깅 섹션은 자동으로 생성됩니다. 데이터베이스와 내 ApplicationInsights 키에 대한 연결 문자열을 저장하기 위해 Data 섹션을 추가했습니다.

애플리케이션 구성

애플리케이션에서 사용할 수 있는 다양한 서비스를 구성해야 합니다.

ApplicationInsights 켜기: services.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>(); }

이 메서드는 도우미 클래스를 사용하여 모델과 엔터티 간의 모든 매핑을 찾고 그 반대의 경우도 마찬가지이며 컨트롤러에서 사용할 IAutoMapper 래퍼를 만들기 위해 IMapper 인터페이스를 가져옵니다. 이 래퍼에는 특별한 것이 없습니다. 단지 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를 구성하기 위해 특정 네임스페이스 클래스에 대한 매핑을 검색하는 작업을 수행하는 도우미 클래스가 사용됩니다. 모든 매핑은 Expenses/Maps 폴더에 있습니다.

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

다른 ASP.net 개발자를 위한 훌륭한 API 설명을 얻으려면 Swagger 를 잊지 않는 것이 중요합니다.

 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 폴더에 있으며 조건부로 Controller와 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 를 확인하세요.