Преди няколко години получих книгата „Pro ASP.NET Web API“. Тази статия е издънка на идеи от тази книга, малко CQRS и моят собствен опит в разработването на клиент-сървърни системи.
В тази статия ще разгледам:
ASP.NET Core предоставя много подобрения в сравнение с ASP.NET MVC / Web API. Първо, сега това е една рамка, а не две. Много ми харесва, защото е удобен и има по-малко объркване. На второ място, имаме регистриране и DI контейнери без допълнителни библиотеки, което ми спестява време и ми позволява да се концентрирам върху писането на по-добър код, вместо да избирам и анализирам най-добрите библиотеки.
Процесорът за заявки е подход, когато цялата бизнес логика, свързана с един обект на системата, е капсулирана в една услуга и всеки достъп или действия с този обект се извършват чрез тази услуга. Тази услуга обикновено се нарича {EntityPluralName} QueryProcessor. Ако е необходимо, процесорът за заявки включва CRUD (създаване, четене, актуализиране, изтриване) методи за този обект. В зависимост от изискванията, не всички методи могат да бъдат приложени. За да дадем конкретен пример, нека да разгледаме ChangePassword. Ако методът на процесор на заявки изисква входни данни, тогава трябва да се предоставят само необходимите данни. Обикновено за всеки метод се създава отделен клас на заявка и в прости случаи е възможно (но не е желателно) повторното използване на класа на заявката.
В тази статия ще ви покажа как да направите API за система за управление на малки разходи, включително основни настройки за удостоверяване и контрол на достъпа, но няма да влизам в подсистемата за удостоверяване. Ще обхвана цялата бизнес логика на системата с модулни тестове и ще създам поне един интеграционен тест за всеки метод на API на пример за един обект.
Изисквания към разработената система: Потребителят може да добавя, редактира, изтрива своите разходи и може да вижда само техните разходи.
Целият код на тази система е достъпен на на Github .
И така, нека започнем да проектираме нашата малка, но много полезна система.
Диаграмата показва, че системата ще има четири слоя:
В допълнение към описаните слоеве имаме няколко важни концепции. Първото е разделянето на модели на данни. Моделът на клиентските данни се използва главно в слоя REST API. Той преобразува заявки в модели на домейни и обратно от модел на домейн в модел на клиентски данни, но модели за заявки могат да се използват и в процесори за заявки. Преобразуването се извършва с помощта на AutoMapper.
Използвах VS 2017 Professional за създаване на проекта. Обикновено споделям изходния код и тестове в различни папки. Удобно е, изглежда добре, тестовете в CI се изпълняват удобно и изглежда, че Microsoft препоръчва да се направи по този начин:
Описание на проекта:
Проект | Описание |
---|---|
Разходи | Проект за контролери, картографиране между домейн модел и API модел, API конфигурация |
Разходи.Api.Често | В този момент има събрани класове изключения, които се интерпретират по определен начин от филтри, за да върнат правилните HTTP кодове с грешки на потребителя |
Разходи.Api.Models | Проект за API модели |
Expenses.Data.Access | Проект за интерфейси и изпълнение на модела на единица работа |
Разходи.Дата.Модел | Проект за модел на домейн |
Разходи. Запитвания | Проект за процесори за заявки и специфични за заявката класове |
Разходи. Сигурност | Проект за интерфейс и изпълнение на текущия контекст на защита на потребителя |
Препратки между проекти:
Разходи, създадени от шаблона:
Други проекти в папката src по шаблон:
Всички проекти в папката за тестове по шаблон:
Тази статия няма да описва частта, свързана с потребителския интерфейс, въпреки че е внедрена.
Първата стъпка беше да се разработи модел на данни, който се намира в сборката 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
, Role
И UserRole
класовете се отнасят до подсистемата за достъп; тази система не се представя за система на годината и описанието на тази подсистема не е целта на тази статия; следователно моделът на данни и някои подробности за изпълнението ще бъдат пропуснати. Системата за организация на достъпа може да бъде заменена с по-съвършена, без да се променя бизнес логиката.
След това шаблонът за единица работа беше внедрен в Expenses.Data.Access
събрание, структурата на този проект е показана:
За сглобяване са необходими следните библиотеки:
Microsoft.EntityFrameworkCore.SqlServer
Необходимо е да се приложи EF
контекст, който автоматично ще намери съпоставянията в определена папка:
public class MainDbContext : DbContext { public MainDbContext(DbContextOptions 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 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() .ToTable('Expenses') .HasKey(x => x.Id); } }
Интерфейс IUnitOfWork
:
public interface IUnitOfWork : IDisposable { ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot); void Add(T obj) where T: class ; void Update(T obj) where T : class; void Remove(T obj) where T : class; IQueryable Query() where T : class; void Commit(); Task CommitAsync(); void Attach(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 obj) where T : class { var set = _context.Set(); set.Add(obj); } public void Update(T obj) where T : class { var set = _context.Set(); set.Attach(obj); _context.Entry(obj).State = EntityState.Modified; } void IUnitOfWork.Remove(T obj) { var set = _context.Set(); set.Remove(obj); } public IQueryable Query() where T : class { return _context.Set(); } public void Commit() { _context.SaveChanges(); } public async Task CommitAsync() { await _context.SaveChangesAsync(); } public void Attach(T newUser) where T : class { var set = _context.Set(); 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; } }
След това трябва да дефинирате интерфейса и изпълнението на процесора за заявки, който ще съдържа цялата бизнес логика за работа с разходи - в нашия случай, IExpensesQueryProcessor
и ExpensesQueryProcessor
:
public interface IExpensesQueryProcessor { IQueryable Get(); Expense Get(int id); Task Create(CreateExpenseModel model); Task Update(int id, UpdateExpenseModel model); Task Delete(int id); } public class ExpensesQueryProcessor : IExpensesQueryProcessor { public IQueryable Get() { throw new NotImplementedException(); } public Expense Get(int id) { throw new NotImplementedException(); } public Task Create(CreateExpenseModel model) { throw new NotImplementedException(); } public Task 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 _uow; private List _expenseList; private IExpensesQueryProcessor _query; private Random _random; private User _currentUser; private Mock _securityContext; public ExpensesQueryProcessorTests() { _random = new Random(); _uow = new Mock(); _expenseList = new List(); _uow.Setup(x => x.Query()).Returns(() => _expenseList.AsQueryable()); _currentUser = new User{Id = _random.Next()}; _securityContext = new Mock(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(); } [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(); } [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(); } [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(); } [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(); } [Fact] public void DeleteShoudlThrowExceptionIfItemIsNotFound() { Action execute = () => { _query.Delete(_random.Next()).Wait(); }; execute.ShouldThrow(); }
След като се опишат модулните тестове, се описва изпълнението на процесор на заявки:
public class ExpensesQueryProcessor : IExpensesQueryProcessor { private readonly IUnitOfWork _uow; private readonly ISecurityContext _securityContext; public ExpensesQueryProcessor(IUnitOfWork uow, ISecurityContext securityContext) { _uow = uow; _securityContext = securityContext; } public IQueryable Get() { var query = GetQuery(); return query; } private IQueryable GetQuery() { var q = _uow.Query() .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 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 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
[CollectionDefinition('ApiCollection')] public class DbCollection : ICollectionFixture { } ~~~ And define our test server and the client to it with the already authenticated user by default:
публичен клас ApiServer: IDisposable {public const string Username = “admin”; публичен const низ Парола = “admin”;
private IConfigurationRoot _config; public ApiServer() { _config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile('appsettings.json') .Build(); Server = new TestServer(new WebHostBuilder().UseStartup()); 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(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 PostAsync(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(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 PutAsync(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(respnoseText); return data; } }
На този етап трябва да дефинирам договор за REST API за всеки обект, ще го напиша за разходите за REST API:
URL | Метод | Тип тяло | Тип резултат | Описание |
---|---|---|---|---|
Разход | ВЗЕМЕТЕ | - | DataResult | Вземете всички разходи с възможно използване на филтри и сортиращи в параметър на заявката „команди“ |
Разходи / {id} | ВЗЕМЕТЕ | - | ExpenseModel | Вземете разход чрез id |
Разходи | ПОСТ | CreateExpenseModel | ExpenseModel | Създайте нов разходен запис |
Разходи / {id} | СЛАГАМ | UpdateExpenseModel | ExpenseModel | Актуализирайте съществуващ разход |
Когато поискате списък с разходи, можете да приложите различни команди за филтриране и сортиране с помощта на Библиотека AutoQueryable . Примерна заявка с филтриране и сортиране:
/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date
Стойността на параметъра на команди за декодиране е take=25&amount>=12&orderbydesc=date
. Така че можем да намерим страници, филтриране и сортиране на части в заявката. Всички опции за заявки са много подобни на синтаксиса на OData, но за съжаление OData все още не е готов за .NET Core, така че използвам друга полезна библиотека.
В долната част са показани всички модели, използвани в този API:
public class DataResult { 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; } }
Модели CreateExpenseModel
и UpdateExpenseModel
използвайте атрибути за анотиране на данни, за да извършвате прости проверки на ниво 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 Get(HttpClient client) { var response = await client.GetAsync($'api/Expenses'); response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync(); var items = JsonConvert.DeserializeObject(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 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(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 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('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($'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.
Подгответе проекта Разходи. За това трябва да инсталирам следните библиотеки:
След това трябва да започнете да създавате първоначална миграция за базата данни, като отворите конзолата на Package Manager, превключвайки към 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
ключ.
Трябва да конфигурирате различни услуги, налични в нашето приложение:
Включване на ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
Регистрирайте услугите си чрез обаждане: ContainerSetup.Setup(services, Configuration);
ContainerSetup
е клас, създаден, така че не е нужно да съхраняваме всички регистрации на услуги в Startup
клас. Класът се намира в папката IoC на проекта Expenses:
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(); services.AddScoped(); services.AddScoped(); } private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient(); } private static void AddUow(IServiceCollection services, IConfigurationRoot configuration) { var connectionString = configuration['Data:main']; services.AddEntityFrameworkSqlServer(); services.AddDbContext(options => options.UseSqlServer(connectionString)); services.AddScoped(ctx => new EFUnitOfWork(ctx.GetRequiredService())); services.AddScoped(); services.AddScoped(); } 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() == 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(); }
Този метод използва помощния клас, за да намери всички съпоставяния между модели и обекти и обратно и получава 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(object objectToMap) { return _mapper.Map(objectToMap); } public TResult[] Map(IEnumerable sourceQuery) { return sourceQuery.Select(x => _mapper.Map(x)).ToArray(); } public IQueryable Map(IQueryable sourceQuery) { return sourceQuery.ProjectTo(_mapper.ConfigurationProvider); } public void Map(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; } } }
Всички съпоставяния трябва да изпълняват специфичен интерфейс:
public interface IAutoMapperTypeConfigurator { void Configure(IMapperConfigurationExpression configuration); }
Пример за картографиране от обект на модел:
public class ExpenseMap : IAutoMapperTypeConfigurator { public void Configure(IMapperConfigurationExpression configuration) { var map = configuration.CreateMap(); map.ForMember(x => x.Username, x => x.MapFrom(y => y.User.FirstName + ' ' + y.User.LastName)); } }
Също така в Startup.ConfigureServices
метод, конфигурирането на удостоверяване чрез токени JWT Bearer:
services.AddAuthorization(auth => { auth.AddPolicy('Bearer', new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser().Build()); });
И услугите регистрираха изпълнението на ISecurityContext
, което всъщност ще се използва за определяне на текущия потребител:
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() .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); } } }
Също така променихме малко регистрацията по подразбиране за MVC, за да използваме персонализиран филтър за грешки, за да преобразуваме изключенията в правилните кодове за грешки:
services.AddMvc(options => { options.Filters.Add(new ApiExceptionFilter()); });
Прилагане на ApiExceptionFilter
филтър:
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
, за да получите отлично описание на API за други https://www.toptal.com/api :
services.AddSwaggerGen(c => { c.SwaggerDoc('v1', new Info {Title = 'Expenses', Version = 'v1'}); c.OperationFilter(); });
Startup.Configure
метод добавя повикване към InitDatabase
метод, който автоматично мигрира базата данни до последната миграция:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService().CreateScope()) { var context = serviceScope.ServiceProvider.GetService(); 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 { }
След това конфигурирайте маршрутизацията на ~ / api / Expenses
тип, като маркирате класа с атрибута [Route ('api / [controller]')]
.
За достъп до бизнес логиката и картографирането трябва да инжектирате следните услуги:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
На този етап можете да започнете да прилагате методи. Първият метод е да се получи списък с разходи:
[HttpGet] [QueryableResult] public IQueryable Get() { var result = _query.Get(); var models = _mapper.Map(result); return models; }
Внедряването на метода е много просто, получаваме заявка към базата данни, която е картографирана в IQueryable
от ExpensesQueryProcessor
, което от своя страна се връща в резултат.
Персонализираният атрибут тук е QueryableResult
, който използва AutoQueryable
библиотека за обработка на пейджинг, филтриране и сортиране от страна на сървъра. Атрибутът се намира в папката Expenses/Filters
. В резултат този филтър връща данни от тип DataResult
към клиента на 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 Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map(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 Get() { var result = _query.Get(); var models = _mapper.Map(result); return models; } [HttpGet('{id}')] public ExpenseModel Get(int id) { var item = _query.Get(id); var model = _mapper.Map(item); return model; } [HttpPost] [ValidateModel] public async Task Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map(item); return model; } [HttpPut('{id}')] [ValidateModel] public async Task Put(int id, [FromBody]UpdateExpenseModel requestModel) { var item = await _query.Update(id, requestModel); var model = _mapper.Map(item); return model; } [HttpDelete('{id}')] public async Task Delete(int id) { await _query.Delete(id); } }
Ще започна с проблеми: Основният проблем е сложността на първоначалната конфигурация на решението и разбирането на слоевете на приложението, но с нарастващата сложност на приложението сложността на системата е почти непроменена, което е голям плюс, когато придружава такава система. И е много важно да имаме API, за който има набор от тестове за интеграция и пълен набор от модулни тестове за бизнес логика. Бизнес логиката е напълно отделена от използваната сървърна технология и може да бъде напълно тествана. Това решение е подходящо за системи със сложен API и сложна бизнес логика.
Ако искате да създадете Angular приложение, което консумира вашия API, разгледайте Angular 5 и ASP.NET Core от колегата ApeeScapeer Пабло Албела.
Обект за прехвърляне на данни (DTO) е представяне на един или повече обекти в база данни. Един обект на база данни може да бъде представен с или без произволен брой DTO
Уеб API осигурява интерфейс за достъп до бизнес логиката на системата до базата данни и основната логика е капсулирана в API.
Действителният интерфейс, чрез който клиентите могат да работят с уеб API. Работи само по протокол HTTP (s).
Unit testing е съвкупност от малки, специфични, много бързи тестове, обхващащи малка единица код, напр. класове. За разлика от интеграционното тестване, модулното тестване гарантира, че всички аспекти на модула се тестват изолирано от други компоненти на цялостното приложение.
Интеграционното тестване е набор от тестове спрямо определена крайна точка на API. За разлика от модулното тестване, интеграционното тестване проверява дали всички единици код, които захранват API, работят както се очаква. Тези тестове може да са по-бавни от единичните тестове.
ASP.NET Core е пренаписване и следващото поколение на ASP.NET 4.x. Той е междуплатформен и съвместим с Windows, Linux и Docker контейнери.
Токенът на носител JWT (JSON Web Token) е обект без гражданство и подписан JSON обект, който се използва широко в съвременните уеб и мобилни приложения за предоставяне на достъп до API. Тези символи съдържат свои собствени претенции и се приемат, докато подписът е валиден.
Swagger е документ, използван от библиотека, REST API. Самата документация може да се използва и за автоматично генериране на клиент за API за различни платформи.