作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Damir Imangulov's profile image

Damir Imangulov

Damir是一位勤奋的架构师,也是一位经验丰富的全栈开发人员 .NET, .NET Core, and front-end technologies.

Expertise

Previously At

KPMG
Share

Introduction

Several years ago, I got the “Pro ASP.NET Web API” book. 这篇文章是这本书思想的分支, a little CQRS, and my own experience developing client-server systems.

In this article, I’ll be covering:

  • How to create a REST API from scratch using .NET Core, EF Core, AutoMapper, and XUnit
  • 如何确保API在更改后工作
  • 如何尽可能简化REST API系统的开发和支持

Why ASP.NET Core?

ASP.. NET Core在asp.net基础上提供了许多改进.NET MVC/Web API. Firstly, it is now one framework and not two. I really like it because it is convenient and there is less confusion. Secondly, we have logging and DI containers without any additional libraries, 这样可以节省我的时间,让我专注于编写更好的代码,而不是选择和分析最好的库.

What Are Query Processors?

查询处理器是这样一种方法:与系统的一个实体相关的所有业务逻辑都封装在一个服务中,对该实体的任何访问或操作都通过该服务执行. This service is usually called {EntityPluralName}QueryProcessor. 如有必要,查询处理器包括该实体的CRUD(创建、读取、更新、删除)方法. Depending on the requirements, not all methods may be implemented. To give a specific example, let’s take a look at ChangePassword. 如果查询处理器的方法需要输入数据, 然后只提供所需的数据. Usually, for each method, a separate query class is created, and in simple cases, it is possible (but not desirable) to reuse the query class.

Our Aim

In this article, I’ll show you how to make an API for a small cost management system, including basic settings for authentication and access control, 但我不会深入到认证子系统. 我将用模块化测试涵盖系统的整个业务逻辑,并在一个实体的示例上为每个API方法创建至少一个集成测试.

Requirements for the developed system: 用户可以添加、编辑、删除他的费用,并且只能看到他们的费用.

该系统的全部代码可在网站上找到 Github.

So, let’s start designing our small but very useful system.

API Layers

A diagram showing API layers.

The diagram shows that the system will have four layers:

  • Database - Here we store data and nothing more, no logic.
  • DAL - To access the data, we use the Unit of Work pattern and, in the implementation, we use the ORM EF Core with code first and migration patterns.
  • 业务逻辑——封装业务逻辑, we use query processors, only this layer processes business logic. The exception is the simplest validation such as mandatory fields, 哪些将通过API中的过滤器来执行.
  • REST API——客户端可以使用我们的API的实际接口将通过ASP实现.NET Core. 路由配置由属性决定.

除了所描述的层之外,我们还有几个重要的概念. The first is the separation of data models. The client data model is mainly used in the REST API layer. 它将查询转换为域模型,反之亦然,从域模型转换为客户端数据模型, 但是查询模型也可以在查询处理器中使用. The conversion is done using AutoMapper.

Project Structure

我使用VS 2017 Professional来创建项目. I usually share the source code and tests on different folders. It’s comfortable, it looks good, the tests in CI run conveniently, and it seems that Microsoft recommends doing it this way:

Folder structure in VS 2017 Professional.

Project Description:

ProjectDescription
Expenses项目控制器,域模型和API模型之间的映射,API配置
Expenses.Api.CommonAt this point, 过滤器以某种方式解释收集到的异常类,以便向用户返回带有错误的正确HTTP代码
Expenses.Api.ModelsProject for API models
Expenses.Data.Access项目的接口和工作单元模式的实现
Expenses.Data.ModelProject for domain model
Expenses.QueriesProject for query processors and query-specific classes
Expenses.Security当前用户安全上下文的接口和实现的项目

References between projects:

Diagram showing references between projects.

Expenses created from the template:

List of expenses created from the template.

src目录下的其他项目:

按模板列出src文件夹中的其他项目.

测试文件夹中的所有项目按模板:

按模板列出测试文件夹中的项目列表.

Implementation

本文将不描述与UI相关的部分,尽管它已经实现了.

第一步是开发位于程序集中的数据模型 Expenses.Data.Model:

Diagram of the relationship between roles

The Expense class contains the following attributes:

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 属性,并包含将来对我们有用的特定用户的一次支出的所有数据.

The User, Role, and UserRole classes refer to the access subsystem; this system does not pretend to be the system of the year and the description of this subsystem is not the purpose of this article; therefore, 数据模型和实现的一些细节将被省略. 在不改变业务逻辑的情况下,可以将访问组织系统替换为更完善的访问组织系统.

接下来,工作单元模板在 Expenses.Data.Access 装配后,本工程结构如下图所示:

Expenses.Data.Access project structure

汇编程序需要以下库:

  • Microsoft.EntityFrameworkCore.SqlServer

It is necessary to implement an 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);
            }
        }
}

Mapping is done through the MappingsHelper class:

public static class MappingsHelper
    {
        public static IEnumerable GetMainMappings()
        {
            var assemblyTypes = typeof(UserMap).GetTypeInfo().Assembly.DefinedTypes;
            var mappings = assemblyTypes
                // ReSharper一旦禁用assignnulltonotnulattribute
                .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();
        }
}

The mapping to the classes is in the Maps folder, and mapping for Expenses:

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

Interface IUnitOfWork:

public interface IUnitOfWork : IDisposable
    {
        ittransactionbegintransaction (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;
}

Its implementation is a wrapper for EF DbContext:

public class EFUnitOfWork : IUnitOfWork
    {
        private DbContext _context;
 
        public EFUnitOfWork(DbContext context)
        {
            _context = context;
        }
 
        public DbContext Context => _context;
 
        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;
        }
}

The interface ITransaction 在此应用程序中实现的将不会被使用:

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

Its implementation simply wraps the EF transaction:

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

Also at this stage, for the unit tests, the ISecurityContext 接口,它定义了API的当前用户(项目是) Expenses.Security):

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

Next, 您需要定义查询处理器的接口和实现, 哪一个将包含处理成本的所有业务逻辑—在我们的示例中, IExpensesQueryProcessor and 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();
        }
}

The next step is to configure the Expenses.Queries.Tests assembly. I installed the following libraries:

  • Moq
  • FluentAssertions

Then in the Expenses.Queries.Tests 我们定义了单元测试的fixture,并描述了我们的单元测试:

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]
        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]
        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]
        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]
        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 deleteesshouldmarkasdeleted ()
        {
            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 deleteeshoudlthrowexceptionifitemisnotbelongtheuser ()
        {
            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(iunitofworkow, issecuritycontext, 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)
            {
                抛出新的NotFoundException("费用未找到");
            }
 
            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)
            {
                抛出新的NotFoundException("费用未找到");
            }
 
            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)
            {
                抛出新的NotFoundException("费用未找到");
            }
 
            if (user.IsDeleted) return;
 
            user.IsDeleted = true;
            await _uow.CommitAsync();
    }
}

Once the business logic is ready, 我开始编写API集成测试以确定API契约.

The first step is to prepare a project Expenses.Api.IntegrationTests

  1. Install nuget packages:
    • FluentAssertions
    • Moq
    • Microsoft.AspNetCore.TestHost
  2. Set up a project structure
    Project structure
  3. Create a CollectionDefinition 在它的帮助下,我们确定将在每次测试运行开始时创建并将在每次测试运行结束时销毁的资源.
[CollectionDefinition("ApiCollection")]
    public class DbCollection : ICollectionFixture
    {   }
 ~~~

并定义我们的测试服务器和客户端,默认使用已经认证的用户:

public class ApiServer : IDisposable { public const string Username = “admin”; public const string Password = “admin”;

    private IConfigurationRoot _config;
 
    public ApiServer()
    {
        _config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .Build();
 
        服务器=新TestServer(新WebHostBuilder().UseStartup());
        Client = GetAuthenticatedClient(Username, Password);
    }
 
    public HttpClient GetAuthenticatedClient(字符串用户名,字符串密码)
    {
        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;
        }
    }
}  ~~~

For the convenience of working with 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 responseText = await response.Content.ReadAsStringAsync();
            var data = JsonConvert.DeserializeObject(responseText);
            return data;
        }
 
        public async Task PostAsync(字符串url,对象体)
        {
            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 responseText = await response.Content.ReadAsStringAsync();
            var data = JsonConvert.DeserializeObject(responseText);
            return data;
        }
}

At this stage, 我需要为每个实体定义一个REST API契约, I’ll write it for REST API expenses:

URLMethodBody typeResult typeDescription
ExpenseGET-DataResult在查询参数“commands”中通过可能使用的过滤器和排序器获取所有费用
Expenses/{id}GET-ExpenseModelGet an expense by id
ExpensesPOSTCreateExpenseModelExpenseModelCreate new expense record
Expenses/{id}PUTUpdateExpenseModelExpenseModelUpdate an existing expense

方法应用各种筛选和排序命令 AutoQueryable library. 一个带有过滤和排序的查询示例:

/expenses?命令= = 25% 26 12% % 3 e = 26 orderbydesc =日期

A decode commands parameter value is take=25&amount>=12&orderbydesc=date. So we can find paging, filtering, and sorting parts in the query. 所有查询选项都非常类似于OData语法, but unfortunately, OData is not yet ready for .. 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; }
}

Models CreateExpenseModel and UpdateExpenseModel 使用数据注释属性在REST API级别通过属性执行简单检查.

Next, for each HTTP 方法,则在项目中创建一个单独的文件夹,其中的文件由fixture为每个文件夹创建 HTTP method that is supported by the resource:

Expenses folder structure

获取费用列表的集成测试的实现:

[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}").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);
        }
}

Implementation of the integration test for creating an expense:

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

Implementation of the integration test for changing an expense:

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

At this point, 我们已经完全定义了REST API契约,现在我可以开始在ASP的基础上实现它了.NET Core.

API Implementation

Prepare the project Expenses. 为此,我需要安装以下库:

  • AutoMapper
  • AutoQueryable.AspNetCore.Filter
  • Microsoft.ApplicationInsights.AspNetCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.SqlServer.Design
  • Microsoft.EntityFrameworkCore.Tools
  • Swashbuckle.AspNetCore

After that, 您需要通过打开Package Manager Console开始为数据库创建初始迁移, switching to the Expenses.Data.Access project (because the EF context lies there) and running the Add-Migration InitialCreate command:

Package manager console

In the next step, prepare the configuration file appsettings.json in advance, 哪些准备完成后还需要复制到项目中 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"
  }
}

The logging section is created automatically. I added the Data section to store the connection string to the database and my ApplicationInsights key.

Application Configuration

You must configure different services available in our application:

Turning on of ApplicationInsights: services.AddApplicationInsightsTelemetry(配置);

Register your services through a call: ContainerSetup.Setup(services, Configuration);

ContainerSetup 是否创建了一个类,以便我们不必将所有服务注册存储在 Startup class. The class is located in the IoC folder of the Expenses project:

public static class ContainerSetup
    {
        设置(IServiceCollection服务,iconfigationroot配置)
        {
            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();
        }
 
        AddUow(IServiceCollection服务,iconfigationroot配置)
        {
            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 method a little more.

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

此方法使用helper类查找模型和实体之间的所有映射,反之亦然,并获取 IMapper interface to create the IAutoMapper wrapper that will be used in controllers. 这个包装器没有什么特别之处——它只是提供了一个方便的接口 AutoMapper methods.

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

To configure AutoMapper, the helper class is used, whose task is to search for mappings for specific namespace classes. 所有映射都位于费用/地图文件夹中:

public static class AutoMapperConfigurator
    {
        私有静态只读对象锁=新对象();
        私有静态MapperConfiguration _configuration;
 
        配置MapperConfiguration ()
        {
            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一旦禁用assignnulltonotnulattribute
                    .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);
}

An example of mapping from entity to model:

公共类ExpenseMap: iautomappertypeconfigator
    {
        public void Configure(IMapperConfigurationExpression configuration)
        {
            var map = configuration.CreateMap();
            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());
            });

并实现了服务注册 ISecurityContext, which will actually be used to determine the current user:

公共类SecurityContext: issecuritycontext
{
        private readonly IHttpContextAccessor _contextAccessor;
        private readonly IUnitOfWork _uow;
        private User _user;
 
        public SecurityContext(IHttpContextAccessor contextAccessor, iunitofworkow)
        {
            _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); }
        }
}

Also, 为了使用自定义错误过滤器将异常转换为正确的错误代码,我们稍微改变了默认的MVC注册:

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

It’s important not to forget about Swagger, in order to get an excellent API description for other ASP.net developers:

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

API Documentation

The Startup.Configure method adds a call to the InitDatabase 方法,该方法将自动迁移数据库,直到最后一次迁移:

private void InitDatabase(IApplicationBuilder应用程序)
        {
            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);

At this point, 您可以运行集成测试,并确保所有内容都已编译,但没有任何工作,然后转到控制器 ExpensesController.

注意:所有控制器都位于Expenses/Server文件夹中,并有条件地分为两个文件夹:controllers和RestApi. 在文件夹中,控制器是在旧的好MVC-i中作为控制器工作的控制器.e.,返回标记,在RestApi中,返回REST控制器.

你必须创建Expenses/Server/RestApi/ExpensesController类,并从Controller类继承它:

公共类ExpensesController: Controller
{
}

Next, configure the routing of the ~ / api / Expenses 通过用属性标记类来键入 [Route ("api / [controller]")].

要访问业务逻辑和映射器,需要注入以下服务:

private readonly IExpensesQueryProcessor _query;
private readonly IAutoMapper _mapper;
 
公共expensesqueryprocessor查询,IAutoMapper映射器
{
_query = query;
_mapper = mapper;
}

在这个阶段,您可以开始实现方法. 第一种方法是获取费用清单:

[HttpGet]
        [QueryableResult]
        public IQueryable Get()
        {
            var result = _query.Get();
            var models = _mapper.Map(result);
            return models;
        }

该方法的实现非常简单, 得到对数据库的查询,该查询映射到 IQueryable from ExpensesQueryProcessor, which in turn returns as a result.

The custom attribute here is QueryableResult, which uses the AutoQueryable library to handle paging, filtering, and sorting on the server side. The attribute is located in the folder Expenses/Filters. 因此,此过滤器返回类型为的数据 DataResult to the API client.

queryablerresult: ActionFilterAttribute
    {
        public override void OnActionExecuted(ActionExecutedContext context)
        {
            if (context.Exception != null) return;
 
            dynamic query = ((ObjectResult)context.Result).Value;
            if (query == null)抛出新的异常("无法从上下文结果中检索IQueryable值").");
            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
    {
        onactionexecution (ActionExecutingContext)
        {
            if (!context.ModelState.IsValid)
            {
                context.Result = new BadRequestObjectResult(context.ModelState);
            }
    }
}

Full code of ExpensesController:

[Route("api/[controller]")]
公共类ExpensesController: Controller
{
        private readonly IExpensesQueryProcessor _query;
        private readonly IAutoMapper _mapper;
 
        公共expensesqueryprocessor查询,IAutoMapper映射器
        {
            _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);
        }
}

Conclusion

我将从问题开始:主要问题是解决方案初始配置的复杂性和对应用程序层的理解, 但是随着应用程序的日益复杂, 系统的复杂性几乎没有改变, 当伴随这样一个系统时,哪一个是一个很大的优势. 非常重要的是,我们有一个API,它有一组集成测试和一组完整的业务逻辑单元测试. 业务逻辑与所使用的服务器技术完全分离,可以完全测试. 此解决方案非常适合具有复杂API和复杂业务逻辑的系统.

如果你想构建一个使用你的API的Angular应用,那就去看看吧 Angular 5 and ASP.NET Core by fellow Toptaler Pablo Albella. Toptal also now supports mission-critical Blazor development projects.

Understanding the basics

  • What is a Data Transfer Object?

    数据传输对象(DTO)是数据库中一个或多个对象的表示形式. 单个数据库实体可以使用或不使用任意数量的dto来表示

  • What is a web API?

    web API为系统的业务逻辑访问数据库提供了一个接口,底层逻辑被封装在API中.

  • What is a REST API?

    The actual interface through which clients can work with a Web API. It works over HTTP(s) protocol only.

  • What is unit testing?

    单元测试是一组小的、特定的、非常快速的测试,覆盖一个小的代码单元.g. classes. Unlike integration testing, 单元测试确保单元的所有方面都与整个应用程序的其他组件隔离进行测试.

  • What is Web API integration testing?

    Integration testing is a set of tests against a specific API endpoint. Unlike unit testing, 集成测试检查驱动API的所有代码单元是否按预期工作. These tests may be slower than unit-tests.

  • What is ASP.NET Core?

    ASP.. NET Core是一个重写的下一代asp.net.NET 4.x. 它是跨平台的,与Windows、Linux和Docker容器兼容.

  • What is a JWT Bearer token?

    JWT (JSON Web令牌)承载令牌是一种无状态和签名的JSON对象,在现代Web中广泛使用 & 移动应用程序提供对API的访问. 这些令牌包含它们自己的声明,只要签名有效就可以接受.

  • What is Swagger?

    Swagger是一个用于文档REST API的库. 文档本身也可以用于为不同平台的API生成客户端, automatically.

Hire a Toptal expert on this topic.
Hire Now
Damir Imangulov's profile image
Damir Imangulov

Located in Sofia, Bulgaria

Member since June 6, 2017

About the author

Damir是一位勤奋的架构师,也是一位经验丰富的全栈开发人员 .NET, .NET Core, and front-end technologies.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

Previously At

KPMG

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Toptal Developers

Join the Toptal® community.