个性化阅读
专注于IT技术分析

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

本文概述

介绍

几年前, 我收到了” Pro ASP.NET Web API”一书。本文是本书的主要内容, 一些CQRS和我自己开发客户端-服务器系统的经验的一部分。

在本文中, 我将介绍:

  • 如何使用.NET Core, EF Core, AutoMapper和XUnit从头开始创建REST API
  • 更改后如何确保API正常工作
  • 如何尽可能简化REST API系统的开发和支持

为什么选择ASP.NET Core?

ASP.NET Core在ASP.NET MVC / Web API的基础上提供了许多改进。首先, 它现在是一个框架, 而不是两个。我真的很喜欢它, 因为它很方便而且混乱的程度也较小。其次, 我们拥有没有任何其他库的日志记录和DI容器, 这节省了我的时间, 使我可以专注于编写更好的代码, 而不必选择和分析最佳的库。

什么是查询处理器?

当与系统的一个实体相关的所有业务逻辑都封装在一个服务中, 并且对该实体的任何访问或操作都通过该服务执行时, 查询处理器就是一种方法。该服务通常称为{EntityPluralName} QueryProcessor。如有必要, 查询处理器将为此实体包括CRUD(创建, 读取, 更新, 删除)方法。根据要求, 并非所有方法都可以实施。举一个具体的例子, 让我们看一下ChangePassword。如果查询处理器的方法需要输入数据, 则仅应提供所需的数据。通常, 对于每种方法, 都会创建一个单独的查询类, 在简单情况下, 有可能(但不希望)重用查询类。

我们的目的

在本文中, 我将向你展示如何为小型成本管理系统制作API, 包括身份验证和访问控制的基本设置, 但我不会涉及身份验证子系统。我将使用模块化测试涵盖系统的整个业务逻辑, 并在一个实体的示例上为每种API方法创建至少一个集成测试。

对已开发系统的要求:用户可以添加, 编辑, 删除其费用, 并且只能查看其费用。

该系统的完整代码可在Github上获得。

因此, 让我们开始设计一个小型但非常有用的系统。

图层API

该图显示了API层。

该图显示该系统将具有四层:

  • 数据库-这里我们存储数据, 仅此而已, 没有逻辑。
  • DAL-要访问数据, 我们使用工作单元模式, 在实现中, 我们将ORM EF Core与代码优先和迁移模式一起使用。
  • 业务逻辑-为了封装业务逻辑, 我们使用查询处理器, 只有这一层处理业务逻辑。例外是最简单的验证, 例如必填字段, 它将通过API中的过滤器执行。
  • REST API-客户端可以通过我们的API使用的实际接口将通过ASP.NET Core实现。路由配置由属性确定。

除了描述的层, 我们还有几个重要的概念。首先是数据模型的分离。客户端数据模型主要用于REST API层。它将查询转换为域模型, 反之亦然, 从域模型转换为客户端数据模型, 但是查询模型也可以在查询处理器中使用。转换是使用AutoMapper完成的。

项目结构

我使用VS 2017 Professional创建项目。我通常在不同的文件夹上共享源代码和测试。感觉很舒适, 看起来不错, CI中的测试运行方便, 微软似乎建议这样做:

VS 2017 Professional中的文件夹结构。

项目简介:

项目 描述
花费 控制器项目, 域模型与API模型之间的映射, API配置
普通费用 在这一点上, 收集了异常类, 这些异常类通过过滤器以某种方式解释, 以将正确的HTTP代码返回给用户错误
费用模型 API模型专案
费用, 数据, 访问 接口和工作单位模式实施项目
费用数据模型 领域模型项目
费用查询 查询处理器和特定于查询的类的项目
Expenses.Security 当前用户的安全上下文的接口和实现的项目

项目之间的参考:

该图显示了项目之间的引用。

通过模板创建的费用:

从模板创建的费用清单。

通过模板在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, Role和UserRole类引用访问子系统。该系统不伪装成年度系统, 对该子系统的描述也不是本文的目的。因此, 将省略数据模型和实现的一些细节。访问组织系统可以用更完善的系统代替, 而无需更改业务逻辑。

接下来, 在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();
        }
}

同样在此阶段, 对于单元测试, 需要ISecurityContext接口, 该接口定义API的当前用户(项目为Expenses.Security):

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

接下来, 你需要定义查询处理器的接口和实现, 其中将包含用于处理成本的所有业务逻辑, 在我们的示例中为IExpensesQueryProcessor和ExpensesQueryProcessor:

public interface IExpensesQueryProcessor
{
        IQueryable<Expense> Get();
        Expense Get(int id);
        Task<Expense> Create(CreateExpenseModel model);
        Task<Expense> Update(int id, UpdateExpenseModel model);
        Task Delete(int id);
}

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

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

  • 起订量
  • 流利的断言

然后在Expenses.Queries.Tests程序集中, 定义单元测试的固定装置并描述我们的单元测试:

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

描述了单元测试之后, 描述了查询处理器的实现:

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

一旦业务逻辑准备就绪, 我便开始编写API集成测试以确定API合同。

第一步是准备项目Expenses.Api.IntegrationTests

  1. 安装nuget软件包:
    • 流利的断言
    • 起订量
    • Microsoft.AspNetCore.TestHost
  2. 建立项目结构
  3. 创建一个CollectionDefinition, 借助它我们可以确定将在每次测试运行开始时创建并在每次测试运行结束时被销毁的资源。
[CollectionDefinition("ApiCollection")]
    public class DbCollection : ICollectionFixture<ApiServer>
    {   }
 ~~~

And define our test server and the client to it with the already authenticated user by default:

公共类ApiServer:IDisposable {public const string Username =” admin”; public const string密码=” admin”;

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

为了方便在集成测试中使用HTTP请求, 我编写了一个帮助器:

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

在此阶段, 我需要为每个实体定义一个REST API合同, 我将为REST API支出编写合同:

网址 方法 体型 结果类型 描述
费用 得到 DataResult <ExpenseModel> 在查询参数”命令”中获取可能使用过滤器和排序器的所有费用
费用/ {id} 得到 费用模型 通过ID获取费用
花费 开机自检 CreateExpenseModel 费用模型 创建新的费用记录
费用/ {id} UpdateExpenseModel 费用模型 更新现有费用

当你请求成本列表时, 可以使用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; }
}

模型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<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键。

应用配置

你必须配置我们的应用程序中可用的其他服务:

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

此方法使用helper类查找模型与实体之间的所有映射, 反之亦然, 并获取IMapper接口以创建将在控制器中使用的IAutoMapper包装器。这个包装器没有什么特别的, 它只是为AutoMapper方法提供了一个方便的接口。

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

要配置AutoMapper, 请使用helper类, 该类的任务是搜索特定名称空间类的映射。所有映射都位于”费用/映射”文件夹中:

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

此外, 我们对默认的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);
        }
}

为了获得其他https://www.srcmini02.com/api的出色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文件夹中, 并有条件地分为两个文件夹:Controllers和RestApi。在文件夹中, 控制器是在旧的良好MVC中充当控制器的控制器, 即返回标记, 而在RestApi中则是REST控制器。

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

public class ExpensesController : Controller
{
}

接下来, 通过使用属性[Route(” api / [controller]”)]标记该类, 配置〜/ api / Expenses类型的路由。

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

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

在此阶段, 你可以开始实现方法。第一种方法是获取费用清单:

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

该方法的实现非常简单, 我们可以查询到数据库的查询, 该查询从ExpensesQueryProcessor映射到IQueryable <ExpenseModel>中, 该查询又返回结果。

此处的自定义属性是QueryableResult, 它使用AutoQueryable库在服务器端处理分页, 筛选和排序。该属性位于”费用/过滤器”文件夹中。结果, 此过滤器将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和复杂业务逻辑的系统。

如果你想构建一个使用你的API的Angular应用, 请查看srcminier Pablo Albella的同伴Angular 5和ASP.NET Core。

赞(0)
未经允许不得转载:srcmini » 使用ASP.NET Core构建ASP.NET Web API

评论 抢沙发

评论前必须登录!