65.9K
CodeProject 正在变化。 阅读更多。
Home

加速 ASP.NET Core WEB API 应用程序 - 第三部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (19投票s)

2019 年 10 月 2 日

CPOL

40分钟阅读

viewsIcon

50817

深入重构和优化 ASP.NET Core WEB API 应用程序代码

引言

在第三部分中,我们将回顾以下内容

为什么我们需要重构和优化代码?

第一部分的目标是创建一个非常简单的基础应用程序,我们可以从中开始。主要重点是如何更容易地应用和检查不同的方法,修改代码并检查结果。

第二部分专注于生产力。实现了各种方法。与第一部分相比,代码变得更加复杂。

现在,在选择并实现方法之后,我们可以将我们的应用程序视为一个整体。代码需要深入的重构和优化,以满足良好编程风格的各种原则。

不要重复自己 (DRY) 原则

根据 DRY 原则,我们应该消除代码重复。因此,让我们检查 ProductsService 代码,看看它是否有任何重复。我们可以立即看到,在所有返回 ProductViewModelIEnumerable<productviewmodel> 类型值的返回 ProductViewModel 的方法中,以下代码片段重复了多次:

…
new ProductViewModel()
    {
        Id = p.ProductId,
        Sku = p.Sku,
        Name = p.Name
    }
…

我们一直是从 Product 类型对象创建 ProductViewModel 类型对象。将 ProductViewModels 对象的字段初始化移到其构造函数中是合乎逻辑的。让我们在 ProductViewModel 类中创建一个构造函数方法。在构造函数中,我们用 Product 参数的相应值填充对象的字段值。

public ProductViewModel(Product product)
{
    Id = product.ProductId;
    Sku = product.Sku;
    Name = product.Name;
}

现在我们可以重写 ProductsServiceFindProductsAsyncGetAllProductsAsync 方法中的重复代码。

return new OkObjectResult(products.Select(p => new ProductViewModel()
                    {
                        Id = p.ProductId,
                        Sku = p.Sku,
                        Name = p.Name
                    }));
                    
    return new OkObjectResult(products.Select(p => new ProductViewModel(p)));
…

并更改 ProductsService 类的 GetProductAsyncDeleteProductAsync 方法。

return new OkObjectResult(new ProductViewModel()
                    {
                        Id = product.ProductId,
                        Sku = product.Sku,
                        Name = product.Name
                    });
                    
    return new OkObjectResult(new ProductViewModel(product));
…

并对 PriceViewModel 类重复同样的操作。

…    
    	new PriceViewModel()
                    {
                        Price = p.Value,
                        Supplier = p.Supplier
                    }
…

虽然我们在 PricesService 中只使用了一次该代码片段,但最好在类内部的构造函数中封装 PriceViewModel 的字段初始化。

让我们创建一个 PriceViewModel 类构造函数。

…    
public PriceViewModel(Price price)
{
    Price = price.Value;
    Supplier = price.Supplier;
}
…

并更改代码片段。

return new OkObjectResult(pricess.Select(p => new PriceViewModel()
                    {
                        Price = p.Value,
                        Supplier = p.Supplier
                    })
                    .OrderBy(p => p.Price)
                    .ThenBy(p => p.Supplier));
                    
    return new OkObjectResult(pricess.Select(p => new PriceViewModel(p))
                    .OrderBy(p => p.Price)
                    .ThenBy(p => p.Supplier));
                    
… 

try/catch/finally 块中的异常处理

接下来需要解决的问题是异常处理。在整个应用程序中,所有可能引起异常的操作都已被调用到 try-catch 结构中。这种方法在调试过程中非常方便,因为它允许我们在异常发生的地方进行检查。但这种方法也有代码重复的缺点。在 ASP.NET Core 中更好的异常处理方法是在中间件或异常过滤器中全局处理它们。

我们将创建异常处理中间件,以集中处理异常,包括日志记录和生成用户友好的错误消息。

我们的异常处理中间件的要求

  • 将详细信息记录到日志文件
  • 在调试模式下提供详细错误消息,在生产环境中提供友好消息
  • 统一错误消息格式

在 .NET Core 中记录到文件

在 .NET Core 应用程序的 Main 方法开始时,我们创建并运行了 Web 服务器。

…    
BuildWebHost(args).Run();
… 

此时,会自动创建一个 ILoggerFactory 实例。现在可以通过依赖注入访问它,并在代码中的任何地方执行日志记录。但是,使用标准的 ILoggerFactory,我们无法记录到文件。为了克服这个限制,我们将使用 Serilog 库,它扩展了 ILoggerFactory 并允许记录到文件。

让我们首先安装 Serilog.Extensions.Logging.File NuGet 包。

我们应该在我们应用日志记录的模块中添加 using Microsoft.Extensions.Logging; 语句。

Serilog 库可以以不同的方式配置。在我们的简单示例中,要为 Serilog 设置日志记录规则,我们应该在 Startup 类的 Configure 方法中添加以下代码。

… 
        public void Configure(IApplicationBuilder app, IHostingEnvironment env,
                          ILoggerFactory loggerFactory)
        {
            loggerFactory.AddFile("Logs/log.txt");
… 

这意味着记录器将写入相对的 \Logs 目录,并且日志文件的名称格式将是:log-yyyymmdd.txt

统一异常消息格式

在工作过程中,我们的应用程序可以生成不同类型的异常消息。我们的目标是统一这些消息的格式,以便客户端应用程序的某个通用方法可以处理它们。

让所有消息都具有以下格式。

{
    "message": "Product not found"
}    

格式非常简单。对于我们这样的简单应用程序来说是可以接受的。但是,我们应该预见到扩展它的机会,并集中在一个地方这样做。为此,我们将创建一个 ExceptionMessage 类,它将封装消息格式化过程。并且我们将在需要生成异常消息的任何地方使用这个类。

让我们在项目中创建一个 Exceptions 文件夹,并在其中添加一个 ExceptionMessage 类。

using Newtonsoft.Json;

namespace SpeedUpCoreAPIExample.Exceptions
{
    public class ExceptionMessage
    {
        public string Message { get; set; }

        public ExceptionMessage() {}

        public ExceptionMessage(string message)
        {
            Message = message;
        }

        public override string ToString()
        {
            return JsonConvert.SerializeObject(new { message = new string(Message) });
        }
    }
}

现在我们可以创建我们的 ExceptionsHandlingMiddleware 了。

实现 .NET Core 异常处理中间件

Exceptions 文件夹中,创建一个 ExceptionsHandlingMiddleware 类。

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Net;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Exceptions
{
    public class ExceptionsHandlingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<ExceptionsHandlingMiddleware> _logger;

        public ExceptionsHandlingMiddleware
        (RequestDelegate next, ILogger<ExceptionsHandlingMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }

        public async Task InvokeAsync(HttpContext httpContext)
        {
            try
            {
                await _next(httpContext);
            }
            catch (Exception ex)
            {
                await HandleUnhandledExceptionAsync(httpContext, ex);
            }
        }

        private async Task HandleUnhandledExceptionAsync(HttpContext context,
                                Exception exception)
        {
            _logger.LogError(exception, exception.Message);

            if (!context.Response.HasStarted)
            {
                int statusCode = (int)HttpStatusCode.InternalServerError; // 500
                string message = string.Empty;
#if DEBUG
                message = exception.Message;
#else
                message = "An unhandled exception has occurred";
#endif
                context.Response.Clear();
                context.Response.ContentType = "application/json";
                context.Response.StatusCode = statusCode;

                var result = new ExceptionMessage(message).ToString();
                await context.Response.WriteAsync(result);
            }
        }
    }
}

此中间件会拦截未处理的异常,记录异常的详细信息,并在调试时发出详细消息 (#if DEBUG) 或在生产环境中发出用户友好的消息。

注意我们如何使用 ExceptionMessage 类来格式化结果。

现在,我们应该在 Startup.Configure 方法的应用程序 HTTP 请求管道中,在 app.UseMvc(); 语句之前添加此中间件。

    app.UseMiddleware<ExceptionsHandlingMiddleware>();;
    …
    app.UseMvc();    

让我们检查一下它的工作原理。为此,我们将更改 ProductsRepository.FindProductsAsync 方法中的存储过程名称为一个不存在的方法 GetProductsBySKUError

public async Task<IEnumerable<product>> FindProductsAsync(string sku)
{
    return await _context.Products.AsNoTracking().FromSql
    ("[dbo].GetProductsBySKUError @sku = {0}", sku).ToListAsync();
}

并从 ProductsService.FindProductsAsync 方法中删除 Try-Catch 块。

public async Task<IActionResult> FindProductsAsync(string sku)
{
    try
    {
        IEnumerabler<Product> products = 
                     await _productsRepository.FindProductsAsync(sku);}
    catch
    {
        return new ConflictResult();
    }
    …
}

让我们运行我们的应用程序并检查结果。

使用 Swagger 调用 https://:49858/api/products/find/aa

我们将获得 500 HTTP 响应代码和一个消息。

让我们检查日志文件。

现在我们有了 Logs 文件夹和一个文件。

在文件中,我们有详细的异常描述。

…
""[dbo].GetProductsBySKUError @sku = @p0" (627a98df)
System.Data.SqlClient.SqlException (0x80131904): 
       Could not find stored procedure 'dbo.GetProductsBySKUError'.
…

我们声称我们的异常处理中间件应该在调试模式下生成详细的错误消息,在生产环境中生成友好的消息。让我们检查一下。为此,我们将把工具栏中的活动解决方案配置更改为 Release。

或在配置管理器中。

然后再次调用不正确的 API。结果将正如我们所预期的。

因此,我们的异常处理程序按预期工作。

注意!如果我们没有删除 Try-Catch 块,我们永远不会让这个处理程序工作,因为未处理的异常将被 Catch 语句内的代码处理。

不要忘记将存储过程名称恢复为正确的 GetProductsBySKU

现在我们可以删除 ProductsServicePricesService 类中所有的 Try-Catch 块。

注意!为了简洁起见,我们省略了删除 Try-Catch 块的实现代码。

我们仍然需要 Try-Catch 块的唯一地方是 ProductsService.PreparePricesAsyncPricesService.PreparePricesAsync 方法。正如我们在**第二部分**中所讨论的,我们不希望在这些地方破坏应用程序的工作流程。

删除 Try-Catch 块后,代码变得更加简单明了。但大多数服务的返回方法中仍然存在一些重复:

    return new NotFoundResult();

让我们也改进这一点。

在所有查找值集合的方法中,如 ProductsService.GetAllProductsAsyncProductsService.FindProductsAsyncPricesService.GetPricesAsync,我们有两个问题。

第一个是检查从存储库接收到的集合是否不为空。为此,我们使用了语句

…
    if (products != null) 
…

但在我们的情况下,集合永远不会是 null (除非在存储库中发生了已处理的异常)。由于现在所有异常都在服务和存储库之外的专用中间件中处理,我们将始终收到一个值集合 (如果未找到任何内容,则为空)。因此,正确检查结果的方法将是

if (products.Any())

(products.Count() > 0)

并且对于 PricesService 类在 GetPricesAsync 方法中也是如此:更改

if (pricess != null)
    
    if (pricess.Any())

第二个问题是我们应该为空集合返回什么结果。到目前为止,我们返回了 NotFoundResult(),但这也不是完全正确的。例如,如果我们创建另一个应该返回由 Product 及其 Prices 组成的值的 API,则空的 Prices 集合将在 JSON 结构中表示为空数组,StatusCode 将是 200 - OK。因此,为了保持一致性,我们应该重写上述方法的代码,为不为空的集合删除 NotFoundResult

public async Task<IActionResult> FindProductsAsync(string sku)
{
    IEnumerable<Product> products = await _productsRepository.FindProductsAsync(sku);

    if (products.Count() == 1)
    {
        //only one record found - prepare prices beforehand
        ThreadPool.QueueUserWorkItem(delegate
        {
            PreparePricesAsync(products.FirstOrDefault().ProductId);
        });
    };

    return new OkObjectResult(products.Select(p => new ProductViewModel(p)));
}    

public async Task<IActionResult> GetAllProductsAsync()
{
    IEnumerable<Product> products = await _productsRepository.GetAllProductsAsync();

    return new OkObjectResult(products.Select(p => new ProductViewModel(p)));
}

并在 PricesService 中。

public async Task<IActionResult> GetPricesAsync(int productId)
{
    IEnumerable<Price> pricess = await _pricesRepository.GetPricesAsync(productId);

    return new OkObjectResult(pricess.Select(p => new PriceViewModel(p))
                            .OrderBy(p => p.Price)
                            .ThenBy(p => p.Supplier));
}

代码变得非常直接,但另一个问题仍然存在:从服务返回 IActionResult 是否是正确的解决方案。

业务逻辑层应向控制器返回哪种类型的结果?

传统上,业务层的 <> 方法向控制器返回 POCO (Plain old CLR object) 类型值,然后控制器使用适当的 StatusCode 形成正确的响应。例如,ProductsService.GetProductAsync 方法应该返回 ProductViewModel 对象或 null (如果找不到产品)。然后 Controller 应分别生成 OkObjectResult(ProductViewModel)NotFound() 响应。

但这种方法并非总是可能的。实际上,我们可能有各种原因从 Service 返回 null。例如,让我们想象一个用户可以访问某些内容的应用程序。此内容可以是公开的、私有的或预付费的。当用户请求某些内容时,ISomeContentService 可以返回 ISomeContent 或 null。null 存在一些可能的原因:

401 Unauthorized
402 Payment Required
403 Forbidden
404 Not Found
…

原因在 Service 内部变得清晰。如果方法只返回 null,Service 如何通知 Controller 原因?这不足以让 Controller 创建正确的响应。为了解决这个问题,我们使用了 IActionResult 类型作为 Service - 业务层的返回类型。这种方法非常灵活,因为使用 IActionResult 结果,我们可以将一切传递给 Controller。但是,业务层是否应该形成 API 响应,执行 Controller 的工作?这是否会破坏关注点分离设计原则?

一种摆脱业务层 IActionResult 的方法是使用自定义异常来控制应用程序的工作流程并生成适当的响应。为了实现这一点,我们将增强我们的异常处理中间件,使其能够处理自定义异常。

自定义异常处理中间件

让我们创建一个简单的 HttpException 类,继承自 Exception。并增强我们的异常处理中间件以处理 HttpException 类型的异常。

HttpException 文件夹中,添加 HttpException 类。

using System;
using System.Net;

namespace SpeedUpCoreAPIExample.Exceptions
{
    // Custom Http Exception
    public class HttpException : Exception
    {
        // Holds Http status code: 404 NotFound, 400 BadRequest, ...
        public int StatusCode { get; }
        public string MessageDetail { get; set; }

        public HttpException(HttpStatusCode statusCode, string message = null, 
                             string messageDetail = null) : base(message)
        {
            StatusCode = (int)statusCode;
            MessageDetail = messageDetail;
        }
    }
}

并更改 ExceptionsHandlingMiddleware 类代码。

…
    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (HttpException ex)
        {
            await HandleHttpExceptionAsync(httpContext, ex);
        }
        catch (Exception ex)
        {
            await HandleUnhandledExceptionAsync(httpContext, ex);
        }
    }
…
…
private async Task HandleHttpExceptionAsync
               (HttpContext context, HttpException exception)
{
    _logger.LogError(exception, exception.MessageDetail);

    if (!context.Response.HasStarted)
    {
        int statusCode = exception.StatusCode;
        string message = exception.Message;

        context.Response.Clear();

        context.Response.ContentType = "application/json";
        context.Response.StatusCode = statusCode;

        var result = new ExceptionMessage(message).ToString();
        await context.Response.WriteAsync(result);
    }
}    

在中间件中,我们在通用 Exception 类型之前处理 HttpException 类型异常,调用 HandleHttpExceptionAsync 方法。并且我们记录详细的异常消息(如果提供)。

现在,我们可以重写 ProductsService.GetProductAsyncProductsService.DeleteProductAsync

…
public async Task<IActionResult> GetProductAsync(int productId)
{
    Product product = await _productsRepository.GetProductAsync(productId);

    if (product == null)
        throw new HttpException(HttpStatusCode.NotFound, 
        "Product not found",  $"Product Id: {productId}");

    ThreadPool.QueueUserWorkItem(delegate
    {
        PreparePricesAsync(productId);
    });

    return new OkObjectResult(new ProductViewModel(product));
}

public async Task<IActionResult> DeleteProductAsync(int productId)
{
    Product product = await _productsRepository.DeleteProductAsync(productId);

    if (product == null)
        throw new HttpException(HttpStatusCode.NotFound, 
        "Product not found",  $"Product Id: {productId}");

    return new OkObjectResult(new ProductViewModel(product));
}
…

在此版本中,而不是从服务返回 404 Not Found 及 IActionResult,我们抛出了自定义的 HttpException,并且异常处理中间件向用户返回了适当的响应。让我们通过调用具有明显不在 Products 表中的 productid 的 API 来检查其工作原理。

https://:49858/api/products/100

我们的通用异常处理中间件工作正常。

由于我们创建了一种替代方法来从业务层传递任何 StatusCode 和消息,因此我们可以轻松地将返回类型从 IActionResult 更改为适当的 POCO 类型。为此,我们必须重写以下接口。

public interface IProductsService
{
    Task<IActionResult> GetAllProductsAsync();
    Task<IActionResult> GetProductAsync(int productId);
    Task<IActionResult> FindProductsAsync(string sku);
    Task<IActionResult> DeleteProductAsync(int productId);
    
    Task<IEnumerable<ProductViewModel>> GetAllProductsAsync();
    Task<ProductViewModel> GetProductAsync(int productId);
    Task<IEnumerable<ProductViewModel>> FindProductsAsync(string sku);
    Task<ProductViewModel> DeleteProductAsync(int productId);
}    

并更改。

public interface IPricesService
{
    Task<IEnumerable<Price>> GetPricesAsync(int productId);
    
    Task<IEnumerable<PriceViewModel>> GetPricesAsync(int productId);    
…
}

我们还应该在 ProductsServicePricesService 类中重新声明相应的方法,将 IActionResult 类型更改为接口中的类型。同时也要更改它们的返回语句,删除 OkObjectResult 语句。例如,在 ProductsService.GetAllProductsAsync 方法中。

新版本将是。

public async Task<IEnumerable<ProductViewModel>> GetAllProductsAsync()
{
    IEnumerable<Product> products = await _productsRepository.GetAllProductsAsync();

    return products.Select(p => new ProductViewModel(p));
}

最后的任务是更改控制器操作,使其创建 OK 响应。它将始终是 200 OK,因为 NotFound 将由 ExceptionsHandlingMiddleware 返回。

例如,对于 ProductsService.GetAllProductsAsync,返回语句应从。

// GET /api/products
[HttpGet]
public async Task<IActionResult> GetAllProductsAsync()
{
    return await _productsService.GetAllProductsAsync();
}

to

// GET /api/products
[HttpGet]
public async Task<IActionResult> GetAllProductsAsync()
{
    return new OkObjectResult(await _productsService.GetAllProductsAsync());
} 

你在 ProductsController 的所有操作和 PricesService.GetPricesAsync 操作中执行此操作。

使用 HttpClientFactory 的类型化客户端

我们之前的 HttpClient 实现存在一些问题,我们可以改进。首先,我们必须注入 IHttpContextAccessor 以在 GetFullyQualifiedApiUrl 方法中使用它。IHttpContextAccessorGetFullyQualifiedApiUrl 方法都只用于 HttpClient,并且在 ProductsService 的其他地方从不使用。如果我们想在其他服务中应用相同的功能,我们将不得不编写几乎相同的代码。因此,最好创建一个单独的辅助类——包装 HttpClient,并将所有必要的 HttpClient 调用业务逻辑封装在该类中。

我们将使用另一种处理 HttpClientFactory 的方式 - 类型化客户端类。

Interfaces 文件夹中,创建一个 ISelfHttpClient 接口。

using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface ISelfHttpClient
    {
        Task PostIdAsync(string apiRoute, string id);
    }
}

我们只声明了一个方法,该方法调用任何控制器的操作,使用 HttpPost 方法和 Id 参数。

让我们创建一个 Helpers 文件夹,并在其中添加一个继承自 ISelfHttpClient 接口的新类 SelfHttpClient

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Helpers
{
    // HttpClient for application's own controllers access 
    public class SelfHttpClient : ISelfHttpClient
    {
        private readonly HttpClient _client;
        
        public SelfHttpClient(HttpClient httpClient, 
                              IHttpContextAccessor httpContextAccessor)
        {
            string baseAddress = string.Format("{0}://{1}/api/",
                                httpContextAccessor.HttpContext.Request.Scheme,
                                httpContextAccessor.HttpContext.Request.Host);

            _client = httpClient;
            _client.BaseAddress = new Uri(baseAddress);
        }

        // Call any controller's action with HttpPost method and Id parameter.
        // apiRoute - Relative API route.
        // id - The parameter.
        public async Task PostIdAsync(string apiRoute, string id)
        {
            try
            {
                var result = await _client.PostAsync
                (string.Format("{0}/{1}", apiRoute, Id), null).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                //ignore errors
            }
        }
    }
}

在此类中,我们在类构造函数中获取要调用的 API 的 baseAddress。在 PostIdAsync 方法中,我们通过其相对的 apiRoute 路由调用 API,并以 HttpPost 方法传递 Id 作为响应参数。*请注意,我们发送 null 而不是创建一个空的 HttpContent*。

我们应该在 Startup.ConfigureServices 方法中声明此类。

services.AddHttpClient();
        services.AddHttpClient<ISelfHttpClient, SelfHttpClient>();

现在我们可以在应用程序中的任何地方使用它。在 ProductsService 服务中,我们应该在类构造函数中注入它。我们可以删除 IHttpContextAccessorIHttpClientFactory,因为我们不再使用它们,并且可以删除 GetFullyQualifiedApiUrl 方法。

ProductsService 构造函数的新版本将是。

public class ProductsService : IProductsService
{
    private readonly IProductsRepository _productsRepository;
    private readonly ISelfHttpClient _selfHttpClient;

    public ProductsService(IProductsRepository productsRepository, 
                           ISelfHttpClient selfHttpClient)
    {
        _productsRepository = productsRepository;
        _selfHttpClient = selfHttpClient;
    }
}    

让我们更改 PreparePricesAsync 方法。首先,我们将其重命名为 CallPreparePricesApiAsync,因为这个名称更具信息性,并且该方法。

private async void CallPreparePricesApiAsync(string productId)
{
    await _selfHttpClient.PostIdAsync("prices/prepare", productId);
}

不要忘记在 ProductsService 中调用此方法的所有地方都将 PreparePricesAsync 更改为 CallPreparePricesApiAsync。同时也要考虑到,在 CallPreparePricesApiAsync 中,我们使用 string productId 参数的类型。

您可以看到我们将 API URL 的尾部作为 PostIdAsync 参数传递。新的 SelfHttpClient 确实是可重用的。例如,如果我们有一个 API /products/prepare,我们可以这样调用 API。

private async void CallPrepareProductAPIAsync(string productId)
{
    await _selfHttpClient.PostIdAsync("products/prepare", productId);
}

处理应用程序的设置

在前面的部分中,我们通过注入 IConfiguration 来访问应用程序的设置。然后,在类构造函数中,我们创建了一个 Settings 类,其中解析了相应的设置变量并应用了默认值。这种方法对于调试来说很好,但在调试之后,使用简单的 POCO 类来访问应用程序的设置似乎更可取。因此,让我们稍微更改一下我们的 appsettings.json。我们将为 ProductsPrices 服务形成两个包含设置的节。

  "Caching": {
    "PricesExpirationPeriod": 15
  }

  "Products": {
    "CachingExpirationPeriod": 15,
    "DefaultPageSize": 20
  },
  "Prices": {
    "CachingExpirationPeriod": 15,
    "DefaultPageSize": 20
  },
…  
注意!我们将在本文后面使用 DefaultPageSize 值。

让我们创建设置 POCO 类。创建一个 Settings 文件夹,其中包含以下文件。

namespace SpeedUpCoreAPIExample.Settings
{
    public class ProductsSettings
    {
        public int CachingExpirationPeriod { get; set; }
        public int DefaultPageSize { get; set; }
    }
}

namespace SpeedUpCoreAPIExample.Settings
{
    public class PricesSettings
    {
        public int CachingExpirationPeriod { get; set; }
        public int DefaultPageSize { get; set; }
    }
}

虽然类仍然相似,但在实际应用程序中,不同服务的设置可能会有很大差异。因此,我们将使用这两个类,以便以后不将它们分开。

现在,我们使用这些类所需要的一切就是在 Startup.ConfigureServices 中声明它们。

…      
    //Settings
    services.Configure<ProductsSettings>(Configuration.GetSection("Products"));
    services.Configure<PricesSettings>(Configuration.GetSection("Prices"));

    //Repositories
…

之后,我们可以在应用程序的任何地方注入设置类,如下一节所示。

缓存关注点分离

PricesRepository 中,我们使用 IDistributedCache 实现了缓存。基于存储库的缓存的理念是完全隐藏数据存储源的细节,使其不为业务层所知。在这种情况下,Service 不知道数据是否经过了缓存阶段。这种解决方案真的好吗?

存储库负责处理 DbContext,即从数据库获取数据或将数据保存到数据库。但缓存肯定超出了这个关注点。此外,在更复杂的系统中,在从数据库获取原始数据后,在将数据交付给用户之前,可能需要修改数据。并且缓存最终状态的数据是合理的。据此,在业务逻辑层 - 服务中应用缓存更好。

注意!在 PricesRepository.GetPricesAsyncPricesRepository.PreparePricesAsync 方法中,缓存的代码几乎相同。逻辑上,我们应该将此代码移到一个单独的类中以避免重复。

通用异步分布式缓存存储库

其思想是创建一个将 IDistributedCache 业务逻辑封装起来的存储库。该存储库将是通用的,并且能够缓存任何类型的对象。这是它的接口。

using Microsoft.Extensions.Caching.Distributed;
using System;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface IDistributedCacheRepository<T>
    {
        Task<T> GetOrSetValueAsync(string key, Func<Task<T>> valueDelegate, 
                                   DistributedCacheEntryOptions options);
        Task<bool> IsValueCachedAsync(string key);
        Task<T> GetValueAsync(string key);
        Task SetValueAsync(string key, T value, DistributedCacheEntryOptions options);
        Task RemoveValueAsync(string key);
    }
}

这里唯一有趣的地方是异步委托作为 GetOrSetValueAsync 方法的第二个参数。这将在实现部分讨论。在 Repositories 文件夹中,创建一个新的 DistributedCacheRepository 类。

using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;
using SpeedUpCoreAPIExample.Interfaces;
using System;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Repositories
{
    public abstract class DistributedCacheRepository<T> : 
           IDistributedCacheRepository<T> where T : class
    {
        private readonly IDistributedCache _distributedCache;
        private readonly string _keyPrefix;

        protected DistributedCacheRepository
                  (IDistributedCache distributedCache, string keyPrefix)
        {
            _distributedCache = distributedCache;
            _keyPrefix = keyPrefix;
        }

        public virtual async Task<T> GetOrSetValueAsync
        (string key, Func<Task<T>> valueDelegate, DistributedCacheEntryOptions options)
        {
            var value = await GetValueAsync(key);
            if (value == null)
            {
                value = await valueDelegate();
                if (value != null)
                    await SetValueAsync(key, value, options ?? GetDefaultOptions());
            }

            return null;
        }

        public async Task<bool> IsValueCachedAsync(string key)
        {
            var value = await _distributedCache.GetStringAsync(_keyPrefix + key);

            return value != null;
        }

        public async Task<T> GetValueAsync(string key)
        {
            var value = await _distributedCache.GetStringAsync(_keyPrefix + key);
            
            return value != null ? JsonConvert.DeserializeObject<T>(value) : null;
        }

        public async Task SetValueAsync(string key, T value, 
                     DistributedCacheEntryOptions options)
        {
            await _distributedCache.SetStringAsync
            (_keyPrefix + key, JsonConvert.SerializeObject(value), 
                               options ?? GetDefaultOptions());
        }

        public async Task RemoveValueAsync(string key)
        {
            await _distributedCache.RemoveAsync(_keyPrefix + key);
        }
        
        protected abstract DistributedCacheEntryOptions GetDefaultOptions();        
    }
}

该类是 abstract,因为我们不打算直接创建它的实例。相反,它将是 PricesCacheRepositoryProductsCacheRepository 类的基类。注意,GetOrSetValueAsync 具有虚拟修饰符 - 我们将在派生类中重写此方法。GetDefaultOptions 方法也是如此,在这种情况下,它被声明为 abstract,因此它将在派生类中实现。当它在父 DistributedCacheRepository 类中调用时,将调用派生类中的继承方法。

GetOrSetValueAsync 方法的第二个参数被声明为异步委托:Func<Task<T>> valueDelegate。在 GetOrSetValueAsync 方法中,我们首先尝试从 Cache 获取值。如果尚未缓存,则通过调用 valueDelegate 函数获取它,然后缓存该值。

让我们创建 DistributedCacheRepository 的特定类型派生类。

using Microsoft.Extensions.Caching.Distributed;
using SpeedUpCoreAPIExample.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface IPricesCacheRepository
    {
        Task<IEnumerable<Price>> GetOrSetValueAsync(string key, 
        Func<Task<IEnumerable<Price>>> valueDelegate, 
                  DistributedCacheEntryOptions options = null);
        Task<bool> IsValueCachedAsync(string key);
        Task RemoveValueAsync(string key);
    }
}
using Microsoft.Extensions.Caching.Distributed;
using SpeedUpCoreAPIExample.Models;
using System;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface IProductCacheRepository
    {
        Task<Product> GetOrSetValueAsync(string key, 
        Func<Task<Product>> valueDelegate, DistributedCacheEntryOptions options = null);
        Task<bool> IsValueCachedAsync(string key);
        Task RemoveValueAsync(string key);
        Task SetValueAsync(string key, Product value, 
             DistributedCacheEntryOptions options = null);
    }
}

然后我们将在 Repositories 文件夹中创建两个类。

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.Models;
using SpeedUpCoreAPIExample.Settings;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Repositories
{
    public class PricesCacheRepository : 
    DistributedCacheRepository<IEnumerable<Price>>, IPricesCacheRepository
    {
        private const string KeyPrefix = "Prices: ";
        private readonly PricesSettings _settings;

        public PricesCacheRepository
        (IDistributedCache distributedCache, IOptions<PricesSettings> settings)
                      : base(distributedCache, KeyPrefix)
        {
            _settings = settings.Value;
        }

        public override async Task<IEnumerable<Price>> 
        GetOrSetValueAsync(string key, Func<Task<IEnumerable<Price>>> valueDelegate, 
        DistributedCacheEntryOptions options = null)
        {
            return base.GetOrSetValueAsync(key, valueDelegate, options);
        }
        
        protected override DistributedCacheEntryOptions GetDefaultOptions()
        {
            //use default caching options for the class 
            //if they are not defined in options parameter
            return new DistributedCacheEntryOptions()
                          {
                              AbsoluteExpirationRelativeToNow = 
                              TimeSpan.FromMinutes(_settings.CachingExpirationPeriod)
                          };
        }
    }
}

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.Models;
using SpeedUpCoreAPIExample.Settings;
using System;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Repositories
{
    public class ProductCacheRepository : DistributedCacheRepository<Product>, 
                                          IProductCacheRepository
    {
        private const string KeyPrefix = "Product: ";
        private readonly ProductsSettings _settings;

        public ProductCacheRepository(IDistributedCache distributedCache, 
        IOptions<ProductsSettings> settings) : base(distributedCache, KeyPrefix)
        {
            _settings = settings.Value;
        }

        public override async Task<Product> 
        GetOrSetValueAsync(string key, Func<Task<Product>> valueDelegate, 
        DistributedCacheEntryOptions options = null)
        {
            return await base.GetOrSetValueAsync(key, valueDelegate, options);
        }
        
        protected override DistributedCacheEntryOptions GetDefaultOptions()
        {
            //use default caching options for the class 
            //if they are not defined in options parameter
            return new DistributedCacheEntryOptions()
                          {
                              AbsoluteExpirationRelativeToNow = 
                              TimeSpan.FromMinutes(_settings.CachingExpirationPeriod)
                          };
        }        
    }
}
注意!GetDefaultOptionsProductCacheRepositoryPricesCacheRepository 类中的实现是相同的,似乎可以移到基类。但是在实际应用程序中,不同对象的缓存策略可能不同,如果我们把 GetDefaultOptions 的一些通用实现移到基类,当派生类的缓存逻辑发生变化时,我们将不得不更改基类。这将违反“开放-关闭”设计原则。这就是为什么我们在派生类中实现了 GetDefaultOptions 方法。

Startup 类中声明存储库。

…    
    services.AddScoped<IPricesCacheRepository, PricesCacheRepository>();
    services.AddScoped<IProductCacheRepository, ProductCacheRepository>();
…  

现在,我们可以从 PricesRepository 中删除缓存,并使其尽可能简单。

using Microsoft.EntityFrameworkCore;
using SpeedUpCoreAPIExample.Contexts;
using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Repositories
{
    public class PricesRepository : IPricesRepository
    {
        private readonly DefaultContext _context;

        public PricesRepository(DefaultContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Price>> GetPricesAsync(int productId)
        {
            return await _context.Prices.AsNoTracking().FromSql
                         ("[dbo].GetPricesByProductId 
            @productId = {0}", productId).ToListAsync();
        }
    }
}

我们也可以重写 PricesService 类。我们注入了 IPricesCacheRepository 而不是 IDistributedCache

using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.Models;
using SpeedUpCoreAPIExample.ViewModels;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Services
{
    public class PricesService : IPricesService
    {
        private readonly IPricesRepository _pricesRepository;
        private readonly IPricesCacheRepository _pricesCacheRepository;

        public PricesService(IPricesRepository pricesRepository, 
                             IPricesCacheRepository pricesCacheRepository)
        {
            _pricesRepository = pricesRepository;
            _pricesCacheRepository = pricesCacheRepository;
        }

        public async Task<IEnumerable<PriceViewModel>> GetPricesAsync(int productId)
        {
            IEnumerable<Price> pricess = 
            await _pricesCacheRepository.GetOrSetValueAsync(productId.ToString(), 
            async () =>
                await _pricesRepository.GetPricesAsync(productId));
                
            return pricess.Select(p => new PriceViewModel(p))
                                .OrderBy(p => p.Price)
                                .ThenBy(p => p.Supplier);
        }

        public async Task<bool> IsPriceCachedAsync(int productId)
        {
            return await _pricesCacheRepository.IsValueCachedAsync(productId.ToString());
        }

        public async Task RemovePriceAsync(int productId)
        {
            await _pricesCacheRepository.RemoveValueAsync(productId.ToString());
        }

        public async Task PreparePricesAsync(int productId)
        {
            try
            {
                await _pricesCacheRepository.GetOrSetValueAsync(productId.ToString(), 
                async () => await _pricesRepository.GetPricesAsync(productId));
            }
            catch
            {
            }
        }
    }
}

GetPricesAsyncPreparePricesAsync 方法中,我们使用了 PricesCacheRepositoryGetOrSetValueAsync 方法。如果所需值不在缓存中,则调用异步方法 GetPricesAsync

我们还创建了 IsPriceCachedAsyncRemovePriceAsync 方法,稍后将使用它们。不要忘记在 IPricesService 接口中声明它们。

using SpeedUpCoreAPIExample.ViewModels;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface IPricesService
    {
        Task<IEnumerable<PriceViewModel>> GetPricesAsync(int productId);
        Task<bool> IsPriceCachedAsync(int productId);
        Task RemovePriceAsync(int productId);
        Task PreparePricesAsync(int productId);
    }
}

让我们检查一下新的缓存方法是如何工作的。为此,请在 GetPricesAsync 方法内设置一个断点。

并使用 Swagger Inspector Extension 调用 https://:49858/api/prices/1 API 两次。

第一次调用时,调试器会停在断点处。这意味着 GetOrSetValueAsync 方法在缓存中找不到结果,并且必须调用作为委托传递给 GetOrSetValueAsync_pricesRepository.GetPricesAsync(productId) 方法。但在第二次调用时,应用程序流程不会停在断点处,因为它从缓存中获取了值。

现在我们可以在 ProductService 中使用我们的通用缓存机制。

namespace SpeedUpCoreAPIExample.Services
{
    public class ProductsService : IProductsService
    {
        private readonly IProductsRepository _productsRepository;
        private readonly ISelfHttpClient _selfHttpClient;
        private readonly IPricesCacheRepository _pricesCacheRepository;
        private readonly IProductCacheRepository _productCacheRepository;
        private readonly ProductsSettings _settings;

        public ProductsService(IProductsRepository productsRepository, 
        IPricesCacheRepository pricesCacheRepository,
            IProductCacheRepository productCacheRepository, 
            IOptions<ProductsSettings> settings, ISelfHttpClient selfHttpClient)
        {
            _productsRepository = productsRepository;
            _selfHttpClient = selfHttpClient;
            _pricesCacheRepository = pricesCacheRepository;
            _productCacheRepository = productCacheRepository;
            _settings = settings.Value;
        }
        
        public async Task<ProductsPageViewModel> FindProductsAsync(string sku)
        {
            IEnumerable<product> products = 
                        await _productsRepository.FindProductsAsync(sku);

            if (products.Count() == 1)
            {
                //only one record found
                Product product = products.FirstOrDefault();
                string productId = product.ProductId.ToString();

                //cache a product if not in cache yet
                if (!await _productCacheRepository.IsValueCachedAsync(productId))
                {
                    await _productCacheRepository.SetValueAsync(productId, product);
                }

                //prepare prices
                if (!await _pricesCacheRepository.IsValueCachedAsync(productId))
                {
                    //prepare prices beforehand
                    ThreadPool.QueueUserWorkItem(delegate
                    {
                        CallPreparePricesApiAsync(productId);
                    });
                }
            };

            return new OkObjectResult(products.Select(p => new ProductViewModel(p)));
        }
        
        …
        public async Task<ProductViewModel> GetProductAsync(int productId)
        {
            Product product = 
            await _productCacheRepository.GetOrSetValueAsync(productId.ToString(), 
            async () => await _productsRepository.GetProductAsync(productId));

            if (product == null)
            {
                throw new HttpException(HttpStatusCode.NotFound, 
                "Product not found",  $"Product Id: {productId}");
            }

            //prepare prices
            if (!await _pricesCacheRepository.IsValueCachedAsync(productId.ToString()))
            {
                //prepare prices beforehand
                ThreadPool.QueueUserWorkItem(delegate
                {
                    CallPreparePricesApiAsync(productId.ToString());
                });
            }

            return new ProductViewModel(product);
        }

        …
        public async Task<ProductViewModel> DeleteProductAsync(int productId)
        {
            Product product = await _productsRepository.DeleteProductAsync(productId);

            if (product == null)
            {
                throw new HttpException(HttpStatusCode.NotFound, 
                "Product not found",  $"Product Id: {productId}");
            }
                
            //remove product and its prices from cache
            await _productCacheRepository.RemoveValueAsync(productId.ToString());
            await _pricesCacheRepository.RemoveValueAsync(productId.ToString());

            return new OkObjectResult(new ProductViewModel(product));
        }        
        …

Entity Framework 中的内存和数据库内分页

您可能已经注意到,ProductsControllerGetAllProductsAsyncFindProductsAsync 方法以及 PricesControllerGetPricesAsync 方法返回的产品和价格集合的大小没有任何限制。这意味着在具有巨大数据库的实际应用程序中,某些 API 的响应可能会返回大量数据,以至于客户端应用程序无法在合理的时间内处理或甚至接收这些数据。为避免此问题,一种好的做法是建立 API 结果的分页。

有两种组织分页的方法:内存内和数据库内。例如,当我们收到某个产品的价格时,我们会将结果缓存到 Redis 缓存中。因此,我们已经有了完整的价格集,并且可以进行内存内分页,这是更快的方法。

另一方面,在 GetAllProductsAsync 方法中使用内存内分页不是一个好主意,因为要在内存中进行分页,我们应该将整个 Products 集合从数据库读入内存。这是一个非常慢的操作,会消耗大量资源。因此,在这种情况下,最好在数据库中过滤必要的数据集,以根据页面大小和索引进行。

为了分页,我们将创建一个通用的 PaginatedList 类,该类能够处理任何数据类型的集合,并支持内存内和数据库内分页方法。

让我们在 Helpers 文件夹中创建一个继承自 List <T> 的通用 PaginatedList <T>

using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Helpers
{
    public class PaginatedList<T> : List<T>
    {
        public int PageIndex { get; private set; }
        public int PageSize { get; private set; }
        public int TotalCount { get; private set; }
        public int TotalPages { get; private set; }

        public PaginatedList(IEnumerable<T> source, int pageSize, int pageIndex = 1)
        {
            TotalCount = source.Count();

            PageIndex = pageIndex;
            PageSize = pageSize == 0 ? TotalCount : pageSize;
            TotalPages = (int)Math.Ceiling(TotalCount / (double)PageSize);

            this.AddRange(source.Skip((PageIndex - 1) * PageSize).Take(PageSize));
        }

        private PaginatedList(IEnumerable<T> source, 
        int pageSize, int pageIndex, int totalCount) : base(source)
        {
            PageIndex = pageIndex;
            PageSize = pageSize;
            TotalCount = totalCount;
            TotalPages = (int)Math.Ceiling(TotalCount / (double)PageSize);
        }

        public static async Task<PaginatedList<T>> 
        FromIQueryable(IQueryable<T> source, int pageSize, int pageIndex = 1)
        {
            int totalCount = await source.CountAsync();
            pageSize = pageSize == 0 ? totalCount : pageSize;

            int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

            if (pageIndex > totalPages)
            {
                //return empty list
                return new PaginatedList<T>(new List<T>(), 
                       pageSize, pageIndex, totalCount);
            }

            if (pageIndex == 1 && pageSize == totalCount)
            {
                //no paging needed
            }
            else
            {
                source = source.Skip((pageIndex - 1) * pageSize).Take(pageSize);
            };

            List<T> sourceList = await source.ToListAsync();
            return new PaginatedList<T>(sourceList, pageSize, pageIndex, totalCount);
        }
    }
}

我们需要第一个构造函数来处理任何类型的内存数据集合。第二个构造函数也用于内存集合,但当页面大小和页面数已知时。我们将其标记为 private,因为它仅在 PaginatedList 类本身在 FromIQueryable 方法中使用。

FromIQueryable 用于建立数据库内分页。源参数具有 IQueryable 类型。使用 IQueryable,我们直到执行实际的数据库请求(如 source.CountAsync()source.ToListAsync())之前,都不会处理物理数据。因此,我们可以格式化适当的分页查询,并在一次请求中只接收一小部分过滤后的数据。

让我们也调整 ProductsRepository.GetAllProductsAsyncProductsRepository.FindProductsAsync 方法,使它们能够处理数据库内分页。现在它们应该返回 IQueryable,而不是像以前那样返回 IEnumerable

namespace SpeedUpCoreAPIExample.Interfaces
{
    public interface IProductsRepository
    {
…  
        Task<IEnumerable<Product>> GetAllProductsAsync();
        Task<IEnumerable<Product>> FindProductsAsync(string sku);

        IQueryable<Product> GetAllProductsAsync();
        IQueryable<Product> FindProductsAsync(string sku);
 …  
   }
}

ProductsRepository 类中正确的方法代码。

…      
        public async Task<IEnumerable<Product>> GetAllProductsAsync()
        {
            return await _context.Products.AsNoTracking().ToListAsync();
        }

        public IQueryable<Product> GetAllProductsAsync()
        {
            return  _context.Products.AsNoTracking();
        }

        public async Task<IEnumerable<Product>> FindProductsAsync(string sku)
        {
            return await _context.Products.AsNoTracking().FromSql
            ("[dbo].GetProductsBySKU @sku = {0}", sku).ToListAsync();
        }

        public IQueryable<Product> FindProductsAsync(string sku)
        {
            return _context.Products.AsNoTracking().FromSql
                   ("[dbo].GetProductsBySKU @sku = {0}", sku);
        }
…  

让我们定义我们将分页结果返回给用户的类。在 ViewModels 文件夹中,创建一个 PageViewModel - 一个基类。

namespace SpeedUpCoreAPIExample.ViewModels
{
    public class PageViewModel
    {
        public int PageIndex { get; private set; }
        public int PageSize { get; private set; }
        public int TotalPages { get; private set; }
        public int TotalCount { get; private set; }

        public bool HasPreviousPage => PageIndex > 1;
        public bool HasNextPage => PageIndex < TotalPages;

        public PageViewModel
        (int pageIndex, int pageSize, int totalPages, int totalCount)
        {
            PageIndex = pageIndex;
            PageSize = pageSize;
            TotalPages = totalPages;
            TotalCount = totalCount;
        }
    }
}

以及继承自 PageViewModelProductsPageViewModelPricesPageViewModel 类。

using SpeedUpCoreAPIExample.Helpers;
using SpeedUpCoreAPIExample.Models;
using System.Collections.Generic;
using System.Linq;

namespace SpeedUpCoreAPIExample.ViewModels
{
    public class ProductsPageViewModel : PageViewModel
    {
        public IList<ProductViewModel> Items;

        public ProductsPageViewModel(PaginatedList<Product> paginatedList) :
                base(paginatedList.PageIndex, paginatedList.PageSize, 
                     paginatedList.TotalPages, paginatedList.TotalCount)
        {
            this.Items = paginatedList.Select(p => new ProductViewModel(p)).ToList();
        }
    }
}
using SpeedUpCoreAPIExample.Helpers;
using SpeedUpCoreAPIExample.Models;
using System.Collections.Generic;
using System.Linq;

namespace SpeedUpCoreAPIExample.ViewModels
{
    public class PricesPageViewModel : PageViewModel
    {
        public IList<PriceViewModel> Items;

        public PricesPageViewModel(PaginatedList<Price> paginatedList) :
                base(paginatedList.PageIndex, paginatedList.PageSize, 
                paginatedList.TotalPages, paginatedList.TotalCount)
        {
            this.Items = paginatedList.Select(p => new PriceViewModel(p))
                .OrderBy(p => p.Price)
                .ThenBy(p => p.Supplier)
                .ToList();
        }
    }
}

PricesPageViewModel 中,我们对 PriceViewModel 的分页列表应用了额外的排序。

现在我们应该更改 ProductsService.GetAllProductsAsyncProductsService.FindProductsAsync,使它们返回 ProductsPageViewMode

public interface IProductsService
…
    Task<IEnumerable<ProductViewModel>> GetAllProductsAsync();
    Task<IEnumerable<ProductViewModel>> FindProductsAsync(string sku);

    Task<ProductsPageViewModel> GetAllProductsAsync(int pageIndex, int pageSize);
    Task<ProductsPageViewModel> FindProductsAsync
         (string sku, int pageIndex, int pageSize);
    public class ProductsService : IProductsService
    {
        private readonly IProductsRepository _productsRepository;
        private readonly ISelfHttpClient _selfHttpClient;
        private readonly IPricesCacheRepository _pricesCacheRepository;
        private readonly IProductCacheRepository _productCacheRepository;
        private readonly ProductsSettings _settings;

        public ProductsService
        (IProductsRepository productsRepository, 
         IPricesCacheRepository pricesCacheRepository,
            IProductCacheRepository productCacheRepository, 
            IOptions<ProductsSettings> settings, ISelfHttpClient selfHttpClient)
        {
            _productsRepository = productsRepository;
            _selfHttpClient = selfHttpClient;
            _pricesCacheRepository = pricesCacheRepository;
            _productCacheRepository = productCacheRepository;
            _settings = settings.Value;
        }

        public async Task<ProductsPageViewModel> 
        FindProductsAsync(string sku, int pageIndex, int pageSize)
        {
            pageSize = pageSize == 0 ? _settings.DefaultPageSize : pageSize;
            PaginatedList<Product> products = await PaginatedList<Product>
                                            .FromIQueryable
            (_productsRepository.FindProductsAsync(sku), pageIndex, pageSize);

            if (products.Count() == 1)
            {
                //only one record found
                Product product = products.FirstOrDefault();
                string productId = product.ProductId.ToString();

                //cache a product if not in cache yet
                if (!await _productCacheRepository.IsValueCachedAsync(productId))
                {
                    await _productCacheRepository.SetValueAsync(productId, product);
                }

                //prepare prices
                if (!await _pricesCacheRepository.IsValueCachedAsync(productId))
                {
                    //prepare prices beforehand
                    ThreadPool.QueueUserWorkItem(delegate
                    {
                        CallPreparePricesApiAsync(productId);
                    });
                }
            };

            return new ProductsPageViewModel(products);
        }

        public async Task<ProductsPageViewModel> 
        GetAllProductsAsync(int pageIndex, int pageSize)
        {
            pageSize = pageSize == 0 ? _settings.DefaultPageSize : pageSize;
            PaginatedList<Product> products = await PaginatedList<Product>
                                            .FromIQueryable
                                            (_productsRepository.GetAllProductsAsync(), 
                                             pageIndex, pageSize);

            return new ProductsPageViewModel(products);
        }
…
注意,如果没有将有效的 PageIndexPageSize 参数传递给 PaginatedList 构造函数,则会使用默认值 - PageIndex = 1PageSize = 整个数据表大小。为了避免返回 ProductsPrices 表的所有记录,我们将分别使用来自 ProductsSettingsPricesSettings 的默认值 DefaultPageSize

并更改 PricesServicePricesAsync 以返回 PricesPageViewModel

public interface IPricesService
…
    Task<IEnumerable<PriceViewModel> GetPricesAsync(int productId);

    Task<PricesPageViewModel> GetPricesAsync
        (int productId, int pageIndex, int pageSize);
…    
    public class PricesService : IPricesService
    {
        private readonly IPricesRepository _pricesRepository;
        private readonly IPricesCacheRepository _pricesCacheRepository;
        private readonly PricesSettings _settings;

        public PricesService(IPricesRepository pricesRepository, 
        IPricesCacheRepository pricesCacheRepository, IOptions<PricesSettings> settings)
        {
            _pricesRepository = pricesRepository;
            _pricesCacheRepository = pricesCacheRepository;
            _settings = settings.Value;
        }

        public async Task<PricesPageViewModel> 
       GetPricesAsync(int productId, int pageIndex, int pageSize)
        {
            IEnumerable<Price> prices = 
            await _pricesCacheRepository.GetOrSetValueAsync
            (productId.ToString(), async () =>
                    await _pricesRepository.GetPricesAsync(productId));            

            pageSize = pageSize == 0 ? _settings.DefaultPageSize : pageSize;
            return new PricesPageViewModel(new PaginatedList<Price>
                                          (prices, pageIndex, pageSize));
        }
…   

现在我们可以重写 ProductsControllerPricesController,使它们能够处理新的分页机制。

让我们更改 ProductsController.GetAllProductsAsyncProductsController.FindProductsAsync 方法。新版本将是。

[HttpGet]
public async Task<IActionResult> GetAllProductsAsync(int pageIndex, int pageSize)
{
    ProductsPageViewModel productsPageViewModel = 
    await _productsService.GetAllProductsAsync(pageIndex, pageSize);

    return new OkObjectResult(productsPageViewModel);
}

[HttpGet("find/{sku}")]
public async Task<IActionResult> FindProductsAsync
                (string sku, int pageIndex, int pageSize)
{
    ProductsPageViewModel productsPageViewModel = 
    await _productsService.FindProductsAsync(sku, pageIndex, pageSize);

    return new OkObjectResult(productsPageViewModel);
}

以及 PricesController.GetPricesAsync 方法。

[HttpGet("{Id:int}")]
public async Task<IActionResult> GetPricesAsync(int id, int pageIndex, int pageSize)
{
    PricesPageViewModel pricesPageViewModel = 
    await _pricesService.GetPricesAsync(id, pageIndex, pageSize);

    return new OkObjectResult(pricesPageViewModel);
}

如果我们有一个使用旧版本 API 的客户端,它仍然可以使用新版本,因为如果我们省略了 pageIndexpageSize 参数或两者都省略,它们的值将为 0,而我们的分页机制可以正确处理 pageIndex=0 和/或 pageSize=0 的情况。

既然我们已经达到了代码重构中的控制器,就留在这里,把所有最初的混乱都整理一下。

Controller vs ControllerBase

您可能已经注意到,在我们的解决方案中,ProductsController 继承自 Controller 类,而 PricesController 继承自 ControllerBase 类。两个控制器都工作正常,那么我们应该使用哪个版本?Controller 类支持 Views,因此应该用于创建使用 Views 的网站。对于 WEB API 服务,ControllerBase 更受欢迎,因为它更轻量级,因为它不具备 WEB API 中不需要的功能。

因此,我们将让我们的两个控制器都继承自 ControllerBase,并使用 [ApiController] 特性,该特性启用了自动模型验证、路由属性和其他等有用功能。

因此,将 ProductsController 的声明更改为。

…
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
…

让我们看看模型验证如何与 ApiController 特性一起工作。为此,我们将调用一些带有无效参数的 API。例如,下面的操作期望一个整数 Id,但我们发送了一个 string

https://:49858/api/products/aa

结果将是。

状态:400 错误请求

{
    "id": [
        "The value 'aa' is not valid."
    ]
}

如果我们故意声明了参数类型 [HttpGet("{Id:int}")],情况会更糟。

https://:49858/api/prices/aa

状态:404 未找到,没有任何关于 Id 参数类型不正确的消息。

因此,首先,我们将从 PricesController.GetPricesAsync 方法的 HttpGet 属性中删除 Id 类型声明。

[HttpGet("{Id:int}")]
[HttpGet("{id}")]

这将为我们提供一个标准的 400 错误请求和类型不匹配的消息。

直接关系到应用程序生产力的另一个问题是消除无意义的工作。例如,https://:49858/api/prices/-1 API 显然会返回 404 未找到,因为我们的数据库永远不会有负的 Id 值。

我们在应用程序中多次使用正整数 Id 参数。因此,想法是创建一个 Id 验证过滤器,并在每次有 Id 参数时使用它。

自定义 Id 参数验证过滤器和特性

在您的解决方案中,创建一个 Filters 文件夹,并在其中创建一个新的 ValidateIdAsyncActionFilter 类。

using Microsoft.AspNetCore.Mvc.Filters;
using SpeedUpCoreAPIExample.Exceptions;
using System.Linq;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Filters
{
    // Validating Id request parameter ActionFilter. 
    // Id is required and must be a positive integer
    public class ValidateIdAsyncActionFilter : IAsyncActionFilter
    {
        public async Task OnActionExecutionAsync
        (ActionExecutingContext context, ActionExecutionDelegate next)
        {
            ValidateParameter(context, "id");

            await next();
        }
        
        private void ValidateParameter(ActionExecutingContext context, string paramName)
        {
            string message = $"'{paramName.ToLower()}' must be a positive integer.";

            var param = context.ActionArguments.SingleOrDefault(p => p.Key == paramName);
            if (param.Value == null)
            {
                throw new HttpException(System.Net.HttpStatusCode.BadRequest, 
                message, $"'{paramName.ToLower()}' is empty.");
            }

            var id = param.Value as int?;
            if (!id.HasValue || id < 1)
            {
                throw new HttpException(System.Net.HttpStatusCode.BadRequest, message,
                                        param.Value != null ? 
                                        $"{paramName}: {param.Value}" : null);
            }
    }
}   

在过滤器中,我们检查请求是否只有一个 Id 参数。如果 Id 参数丢失或不是正整数值,过滤器将生成 BadRequest HttpException。抛出 HttpException 会将我们的 ExceptionsHandlingMiddleware 引入处理过程,并带来所有好处,如日志记录、统一的消息格式等。

为了能够在我们的控制器中的任何地方应用此过滤器,我们将创建同一个 Filters 文件夹中的 ValidateIdAttribute

using Microsoft.AspNetCore.Mvc;

namespace SpeedUpCoreAPIExample.Filters
{
    public class ValidateIdAttribute : ServiceFilterAttribute
    {
        public ValidateIdAttribute() : base(typeof(ValidateIdAsyncActionFilter))
        {
        }
    }
}    

ProductsController 中,添加引用过滤器类命名空间。

…
using SpeedUpCoreAPIExample.Filters;
…

并将 [ValidateId] 特性添加到所有需要 Id 参数的 GetProductAsyncDeleteProductAsync 操作。

…
    [HttpGet("{id}")]
    [ValidateId]
    public async Task<IActionResult> GetProductAsync(int id)
    {
…
    [HttpDelete("{id}")]
    [ValidateId]
    public async Task<IActionResult> DeleteProductAsync(int id)
    {
…

我们可以将 ValidateId 特性应用于整个 PricesController 控制器,因为它的所有操作都需要 Id 参数。此外,我们需要纠正 PricesController 类命名空间中的不准确之处 - 它显然应该是 namespace SpeedUpCoreAPIExample.Controllers,而不是 namespace SpeedUpCoreAPIExample.Contexts

using Microsoft.AspNetCore.Mvc;
using SpeedUpCoreAPIExample.Filters;
using SpeedUpCoreAPIExample.Interfaces;
using SpeedUpCoreAPIExample.ViewModels;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Contexts
namespace SpeedUpCoreAPIExample.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    
    public class PricesController : ControllerBase
    {    
…

最后一步是在 Startup.cs 中声明过滤器。

using SpeedUpCoreAPIExample.Filters;
…
public void ConfigureServices(IServiceCollection services)
…
services.AddSingleton<ValidateIdAsyncActionFilter>();
…

让我们检查一下新过滤器是如何工作的。为此,我们将再次调用不正确的 API https://:49858/api/prices/-1。结果将正如我们所期望的。

状态:400 错误请求

{
    "message": "'Id' must be a positive integer."
}
注意!我们使用了 ExceptionMessage 类,现在消息通常满足我们的格式约定,但并非总是如此!如果我们再次尝试 https://:49858/api/prices/aa,我们仍然会收到标准的 400 错误请求消息。这是因为 [ApiController] 特性。当应用它时,框架会自动注册一个 ModelStateInvalidFilter,它将在我们的 ValidateIdAsyncActionFilter 过滤器之前运行,并生成自己的格式的消息。

我们可以在 Startup 类的 ConfigureServices 方法中抑制此行为。

…    
services.AddMvc();
services.AddApiVersioning();
…
services.Configure<ApiBehaviorOptions>(options =>
{
    options.SuppressModelStateInvalidFilter = true;
});

之后,只有我们的过滤器在工作,并且我们能够控制模型验证消息的格式。但是,现在我们有义务为所有控制器操作参数组织显式验证。

分页参数自定义模型验证过滤器

我们在简单的应用程序中使用了三次分页。让我们检查一下不正确的参数会发生什么。为此,我们将调用 https://:49858/api/products?pageindex=-1

结果将是。

状态:500 内部服务器错误

{
    "message": "The offset specified in a OFFSET clause may not be negative."
}

这条消息确实令人困惑,因为不是服务器错误,而是纯粹的 BadRequest。而且如果你不知道它是关于分页的,文本本身就很神秘。

我们更希望得到一个响应。

状态:400 错误请求

{
    "message": "'pageindex' must be 0 or a positive integer."
}

另一个问题是检查参数的位置。注意,如果省略了任何一个或两个参数,我们的分页机制都能正常工作 - 它会使用默认值。我们只需要控制负参数。在 PaginatedList 级别抛出 HttpException 不是一个好主意,因为代码应该是可重用的,而不必更改它,而且下次 PaginatedList 不一定用于 ASP.NET 应用程序。在 Services 级别检查参数更好,但这将需要重复验证代码或创建其他公共辅助类及其验证方法。

由于分页参数来自外部,组织其检查的更好的地方是在将它们传递给分页过程之前的控制器中。

因此,我们必须创建另一个模型验证过滤器,该过滤器将验证 PageIndexPageSize 参数。验证的想法略有不同 - 任何一个或两个参数都可以省略,可以等于零或大于零的整数。

在同一个 Filters 文件夹中,创建一个新的 ValidatePagingAsyncActionFilter 类。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Newtonsoft.Json.Linq;
using System.Linq;
using System.Threading.Tasks;

namespace SpeedUpCoreAPIExample.Filters
{
    // Validating PageIndex and PageSize request parameters ActionFilter. 
    // If exist, must be 0 or a positive integer
    public class ValidatePagingAsyncActionFilter : IAsyncActionFilter
    {
        public async Task OnActionExecutionAsync
        (ActionExecutingContext context, ActionExecutionDelegate next)
        {
            ValidateParameter(context, "pageIndex");
            ValidateParameter(context, "pageSize");

            await next();
        }

        private void ValidateParameter
                (ActionExecutingContext context, string paramName)
        {
            var param = context.ActionArguments.SingleOrDefault
                        (p => p.Key == paramName);
            if (param.Value != null)
            {
                var id = param.Value as int?;
                if (!id.HasValue || id < 0)
                {
                    string message = $"'{paramName.ToLower()}' 
                                     must be 0 or a positive integer.";
                    throw new HttpException(System.Net.HttpStatusCode.BadRequest, 
                                            message,
                                            param.Value != null ? 
                                            $"{paramName}: {param.Value}" : null);
                }
            }
        }
    }
}

然后创建 ValidatePagingAttribute 类。

using Microsoft.AspNetCore.Mvc;

namespace SpeedUpCoreAPIExample.Filters
{
    public class ValidatePagingAttribute : ServiceFilterAttribute
    {
        public ValidatePagingAttribute() : 
               base(typeof(ValidatePagingAsyncActionFilter))
        {
        }
    }
}

然后,在 Startup.cs 中声明该过滤器。

…
public void ConfigureServices(IServiceCollection services)
…
services.AddSingleton<ValidatePagingAsyncActionFilter>();
…

最后,将 [ValidatePaging] 特性添加到 ProductsController.GetAllProductsAsyncProductsController.FindProductsAsync 方法。

…
    [HttpGet]
    [ValidatePaging]
    public async Task<IActionResult> GetAllProductsAsync(int pageIndex, int pageSize)
    {
…    
    [HttpGet("find/{sku}")]
    [ValidatePaging]
    public async Task<IActionResult> FindProductsAsync
                 (string sku, int pageIndex, int pageSize)
    {
…    

以及 PricesController.GetPricesAsync 方法。

…
    [HttpGet("{id}")]
    [ValidatePaging]
    public async Task<IActionResult> GetPricesAsync(int id, int pageIndex, int pageSize)
    {
…   

现在我们拥有了一个对所有敏感参数的自动验证机制,并且我们的应用程序工作正常 (至少在本地)。

跨域资源共享 (CORS)

在实际应用程序中,我们将把某个域名绑定到我们的 Web 服务,它的 URL 将是 http://mydomainname.com/api/。

同时,消费我们服务 API 的客户端应用程序可以托管在不同的域上。如果客户端 (例如网站) 使用 AJAX 进行 API 请求,并且响应不包含 Access-Control-Allow-Origin 标头,其值为 = * (允许所有域) 或与 origin (客户端主机) 相同,支持 CORS 的浏览器将出于安全原因阻止该响应。

让我们确保。将我们的应用程序构建并发布到 IIS,将其与测试 URL (例如 http://mydomainname.com) 绑定,并使用 https://resttesttest.com/ - 在线 API 测试工具调用任何 API。

启用 ASP.NET Core CORS

为了强制我们的应用程序发送正确的标头,我们应该启用 CORS。为此,请安装 Microsoft.AspNetCore.Cors NuGet 包 (如果您尚未通过其他包安装它,如 Microsoft.AspNetCore.MVCMicrosoft.AspNetCore.All)。

启用 CORS 最简单的方法是将以下代码添加到 Startup.cs

…    
public void Configure(
…
app.UseCors(builder => builder
    .AllowAnyOrigin()
    .AllowAnyMethod()
    .AllowAnyHeader());
…
app.UseMvc();
…

这样,我们允许从任何主机访问我们的 API。我们也可以添加 .AllowCredentials() 选项,但与 AllowAnyOrigin 一起使用它不安全。

之后,重新构建,将应用程序重新发布到 IIS,并使用 resttesttest.com 或其他工具进行测试。乍一看,一切正常 - CORS 错误消息消失了。但这只对我们的 ExceptionsHandlingMiddleware 有效。

HTTP 错误时未发送 CORS 标头

这种情况的发生是因为实际上,当发生 HttpException 或任何其他 Exception 时,响应标头集合是空的,并且中间件会处理它。这意味着没有 Access-Control-Allow-Origin 标头会传递给客户端应用程序,从而出现 CORS 问题。

如何在 ASP.NET Core Web 应用中发送 HTTP 4xx-5xx 响应及 CORS 标头

为了克服这个问题,我们应该以略有不同的方式启用 CORS。在 Startup.ConfigureServices 中,输入以下代码。

…    
public void ConfigureServices(IServiceCollection services)
{
    services.AddCors(options =>
    {
        options.AddPolicy("Default", builder =>
        {
            builder.AllowAnyOrigin();
            builder.AllowAnyMethod();
            builder.AllowAnyHeader();
        });
    });
…

并在 Startup.Configure 中。

…    
public void Configure(
…
    app.UseCors("Default");
…
    app.UseMvc();
…

启用 CORS 的这种方式使我们能够通过依赖注入从应用程序的任何地方访问 CorsOptions。其思想是在 ExceptionsHandlingMiddleware 中使用从 CorsOptions 获取的 CORS 策略重新填充响应标头。

ExceptionsHandlingMiddleware 类的正确代码。

using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using System;
using System.Net;
using System.Threading.Tasks;

namespace SCARWebService.Exceptions
{
    public class ExceptionsHandlingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<ExceptionsHandlingMiddleware> _logger;
        private readonly ICorsService _corsService;
        private readonly CorsOptions _corsOptions;

        public ExceptionsHandlingMiddleware(RequestDelegate next, 
               ILogger<ExceptionsHandlingMiddleware> logger,
               ICorsService corsService, IOptions<CorsOptions> corsOptions)
        {
            _next = next;
            _logger = logger;
            _corsService = corsService;
            _corsOptions = corsOptions.Value;
        }

…
        private async Task HandleHttpExceptionAsync
                (HttpContext context, HttpException exception)
        {
            _logger.LogError(exception, exception.MessageDetail);

            if (!context.Response.HasStarted)
            {
                int statusCode = exception.StatusCode;
                string message = exception.Message;

                context.Response.Clear();

                //repopulate Response header with CORS policy
                _corsService.ApplyResult(_corsService.EvaluatePolicy
                (context, _corsOptions.GetPolicy("Default")), context.Response);
    
                context.Response.ContentType = "application/json";
                context.Response.StatusCode = statusCode;

                var result = new ExceptionMessage(message).ToString();
                await context.Response.WriteAsync(result);
            }
        }

        private async Task HandleUnhandledExceptionAsync
                (HttpContext context, Exception exception)
        {

            _logger.LogError(exception, exception.Message);

            if (!context.Response.HasStarted)
            {
                int statusCode = (int)HttpStatusCode.InternalServerError; // 500
                string message = string.Empty;
#if DEBUG
                message = exception.Message;
#else
                message = "An unhandled exception has occurred”;
#endif
                context.Response.Clear();
                
                //repopulate Response header with CORS policy
                _corsService.ApplyResult(_corsService.EvaluatePolicy
                (context, _corsOptions.GetPolicy("Default")), context.Response);
                    
                context.Response.ContentType = "application/json";
                context.Response.StatusCode = statusCode;

                var result = new ExceptionMessage(message).ToString();
                await context.Response.WriteAsync(result);
            }
        }
…

如果我们重建并重新发布我们的应用程序,当它的 API 从任何主机被调用时,它将没有任何 CORS 问题地正常工作。

API 版本控制

在公开我们的应用程序之前,我们必须考虑如何使用它的 API。一段时间后,需求可能会发生变化,我们将不得不重写应用程序,使其 API 返回不同的数据集。如果我们发布具有新更改的 Web 服务,但不更新使用 API 的客户端应用程序,我们将面临客户端-服务器兼容性方面的重大问题。

为了避免这些问题,我们应该建立 API 版本控制。例如,旧版本的 Products API 将具有路由。

http://mydomainname.com/api/v1.0/products/

而新版本将具有路由。

http://mydomainname.com/api/v2.0/products/

在这种情况下,即使旧的客户端应用程序也能正常工作,直到它们更新到可以与 v2.0 版本正确配合的版本。

在我们的应用程序中,我们将实现基于 URL 路径的版本控制,其中版本号是 API URL 的一部分,如上例所示。

在 .NET Core 中,Microsoft.AspNetCore.Mvc.Versioning 包负责版本控制。因此,我们应该首先安装该包。

然后,将 services.AddApiVersioning() 添加到 Startup 类的 ConfigureServices 方法中。

…
    services.AddMvc();
    services.AddApiVersioning();

最后,将 ApiVersion 和正确的 Route 特性添加到两个控制器。

[ApiVersion("1.0")]
    [Route("/api/v{version:apiVersion}/[controller]/")]

现在我们有了版本控制。完成此操作后,如果我们想增强应用程序以支持 2.0 版本,例如,我们可以将 [ApiVersion("2.0")] 特性添加到控制器。

[ApiVersion("1.0")]
    [ApiVersion("2.0")]

然后创建我们只想与 v2.0 一起工作的操作,并将 [MapToApiVersion("2.0")] 特性添加到该操作。

版本控制机制几乎不需要任何编码即可开箱即用,但一如既往地有一点美中不足:如果我们不小心在 API URL 中使用了错误的版本 (https://:49858/api/v10.0/prices/1),我们将收到以下格式的错误消息。

状态:400 错误请求

{
    "error": {
        "code": "UnsupportedApiVersion",
        "message": "The HTTP resource that matches the request URI 
        'https://:49858/api/v10.0/prices/1' 
         does not support the API version '10.0'.",
        "innerError": null
    }
}

这是 标准的错误响应格式。它比以前信息量大得多,但与我们期望的格式完全不同。因此,如果我们想使用统一的格式处理所有类型的消息,我们必须在详细的标准错误响应格式和我们设计的简单格式之间做出选择。

为了应用标准错误响应格式,我们可以扩展我们的 ExceptionMessage 类。幸运的是,我们预见了这种可能性,而且不会很困难。但在此格式中,消息比我们想要提供给用户的更详细。这种详细程度对于简单应用程序来说可能不太相关。因此,既然我们不想让事情变得复杂,我们将使用我们简单的格式。

控制 API 版本控制的错误消息格式

让我们在 Exceptions 文件夹中创建一个 VersioningErrorResponseProvider 类。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Versioning;

namespace SpeedUpCoreAPIExample.Exceptions
{
    public class VersioningErrorResponseProvider : DefaultErrorResponseProvider
    {
        public override IActionResult CreateResponse(ErrorResponseContext context)
        {
            string message = string.Empty;
            switch (context.ErrorCode)
            {
                case "ApiVersionUnspecified":
                    message = "An API version is required, but was not specified.";

                    break;

                case "UnsupportedApiVersion":
                    message = "The specified API version is not supported.";

                    break;

                case "InvalidApiVersion":
                    message = "An API version was specified, but it is invalid.";

                    break;

                case "AmbiguousApiVersion":
                    message = "An API version was specified multiple times 
                               with different values.";

                    break;

                default:
                    message = context.ErrorCode;

                    break;
            }

            throw new HttpException(System.Net.HttpStatusCode.BadRequest, 
                                    message, context.MessageDetail);
        }
    }
}

该类继承自 DefaultErrorResponseProvider。它只是根据 ErrorCode (列出了 代码) 格式化一个友好的消息,并抛出 HttpException BadRequest 异常。然后,我们的 ExceptionHandlerMiddleware 会处理该异常,进行日志记录、统一错误消息格式化等。

最后一步是将 VersioningErrorResponseProvider 类注册为版本控制 HTTP 错误响应生成器。在 Startup 类中,在 ConfigureServices 方法中,在 API 版本控制服务注册中添加选项。

…    
    services.AddMvc();
    services.AddApiVersioning(options =>
    {
        options.ErrorResponses = new VersioningErrorResponseProvider();
    });
…    

因此,我们已将标准错误响应行为更改为我们期望的行为。

内部 HTTP 调用中的版本控制

我们还必须在 SelfHttpClient 类中应用版本控制。在该类中,我们设置 HttpClientBaseAddress 属性来调用 API。我们应该在构建基本地址时考虑版本控制。

为了避免硬编码我们要调用的 API 的版本,我们创建一个 API 版本控制设置类。在 appsettings.json 文件中,创建一个 API 部分:

…
 ,
 "Api": {
    "Version": "1.0"
  }
…

然后在 Settings 文件夹中,创建一个 ApiSettings.cs 文件。

namespace SpeedUpCoreAPIExample.Settings
{
    public class ApiSettings
    {
        public string Version { get; set; }
    }
}

StartupConfigureServices 方法中声明该类。

…  
public void ConfigureServices(IServiceCollection services)
…  
    //Settings
    services.Configure<ApiSettings>(Configuration.GetSection("Api"));
…

最后,更改 SelfHttpClient 的构造函数。

        public SelfHttpClient(HttpClient httpClient, 
        IHttpContextAccessor httpContextAccessor, IOptions<ApiSettings> settings)
        {
            string baseAddress = string.Format("{0}://{1}/api/v{2}/",
                                    httpContextAccessor.HttpContext.Request.Scheme,
                                    httpContextAccessor.HttpContext.Request.Host,
                                    settings.Value.Version);

            _client = httpClient;
            _client.BaseAddress = new Uri(baseAddress);
        }

本地 DNS 名称解析

让我们来完成 SelfHttpClient 类。我们使用它来提前调用我们自己的 API 进行数据准备。在类构造函数中,我们使用 HttpContextAccessor 来构建我们 API 的基本地址。由于我们已经开始在互联网上发布我们的应用程序,基本地址将是 http://mydomainname.com/api/v1.0/。当我们调用 API 时,HttpClient 在后台会调用 DNS 服务器来将 mydomainname.com 这个主机名解析为应用程序运行的 Web 服务器的 IP,然后连接到该 IP。但我们知道 IP - 它是我们自己服务器的 IP。因此,为了避免这种无意义的 DNS 服务器往返,我们应该在服务器上的 hosts 文件中添加它来本地解析主机名。

hosts 文件的路径是 C:\Windows\System32\drivers\etc\

您应该添加以下条目。

	192.168.1.1 mydomainname.com
	192.168.1.1 www.mydomainname.com

其中 192.168.1.1 - 是我们本地网络中 Web 服务器的 IP。

在此改进之后,HTTP 响应甚至不会离开我们服务器的边界,因此执行速度会快得多。

记录 .NET Core API 应用程序

我们可以从两个方面考虑应用程序的文档记录。

  • 代码的 XML 文档 - 实际上,代码本身应该是有文档的。但是,有时我们仍然需要对某些方法及其参数的详细信息进行额外的解释。我们将使用 XML 注释来记录我们的代码;
  • OpenAPI 文档 - 文档 API,以便客户端应用程序的开发人员能够应用此文档,并以 OpenAPI 规范格式接收反映 API 所有细节的全面信息。

XML 注释

要启用 XML 注释,请打开项目属性并选择 **Build** 选项卡。

在这里,我们应该勾选 XML 文档文件复选框,并保留默认值。我们还应该在 Suppress warnings 文本框中添加 1591 警告编号,以防止在省略某些 public 类、属性、方法等的 XML 注释时出现编译器警告。

现在我们可以像这样注释我们的代码。

… 
/// <summary>
/// Call any controller's action with HttpPost method and Id parameter.
/// </summary>
/// <param name="apiRoute">Relative API route.</param>
/// <param name="id">The parameter.</param>
public async Task PostIdAsync(string apiRoute, string id)
…  

在这里,您可以找到关于使用 XML 注释记录代码的详细信息。

将创建一个名为 XML 文档文件 textbox 中指定的 XML 文件。我们稍后将需要此文件。

RESTful API 的 OpenAPI 文档 (使用 Swagger)

API 文档机制的要求

  • 文档应自动生成。
  • 应支持 API 版本控制并自动发现。
  • 还应使用 XML 注释文件中的文档。
  • 该机制应提供具有文档的用户界面,用户可以在其中测试 API,而无需编写真实的客户端应用程序。
  • 文档应包括使用示例。

我们将使用 Swagger 来满足所有这些要求。让我们安装必要的 NuGet 包。在 NuGet 包管理器中,安装。

Swashbuckle.AspNetCore (4.0.1),
Swashbuckle.AspNetCore.Examples (2.9.0),
Swashbuckle.AspNetCore.Filters (4.5.5),
Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer (3.2.1)
注意!我们需要 ApiExplorer 包来自动发现所有 API 版本并为每个已发现的版本生成描述和端点。

安装后,我们的 Dependencies - NuGet 列表还将包括。

注意!尽管在撰写本文时,Swashbuckle.AspNetCoreSwashbuckle.AspNetCore.Filters 版本 5.0.0-rc8 已可用,但我们使用了较低的版本。原因是版本 2.9.0 和 5.0.0-rc8 之间存在一些兼容性问题。因此,选择了经过验证的稳定 NuGet 包组合。希望在新的版本中,Swagger 开发人员将解决所有兼容性问题。

让我们在应用程序中创建一个 Swagger 文件夹,然后在其中创建一个 SwaggerServiceExtensions 类。这个 static Swagger 扩展类将封装所有与服务设置相关的逻辑。我们将从 StartupConfigureServicesConfigure 方法调用此类的函数,从而使 Startup 类更短、更易读。

这是整个类及其解释。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.Examples;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerUI;
using System;
using System.IO;
using System.Reflection;

namespace SpeedUpCoreAPIExample.Swagger
{
    public static class SwaggerServiceExtensions
    {
        public static IServiceCollection AddSwaggerDocumentation
                      (this IServiceCollection services)
        {
            services.AddVersionedApiExplorer(options =>
            {
                //The format of the version added to the route URL 
                //(VV = <major>.<minor>) 
                options.GroupNameFormat = "'v'VV";

                //Order API explorer to change /api/v{version}/ to /api/v1/  
                options.SubstituteApiVersionInUrl = true;
            });

            // Get IApiVersionDescriptionProvider service
            IApiVersionDescriptionProvider provider = 
            services.BuildServiceProvider().GetRequiredService
                     <IApiVersionDescriptionProvider>();

            services.AddSwaggerGen(options =>
            {
                //Create description for each discovered API version
                foreach (ApiVersionDescription description in 
                         provider.ApiVersionDescriptions)
                {
                    options.SwaggerDoc(description.GroupName, 
                        new  Info()
                        {
                            Title = $"Speed Up ASP.NET Core WEB API Application 
                                    {description.ApiVersion}",
                            Version = description.ApiVersion.ToString(),
                            Description = "Using various approaches to increase 
                                               .NET Core RESTful WEB API productivity.",
                            TermsOfService = "None",
                            Contact = new Contact
                            {
                                Name = "Silantiev Eduard",
                                Email = "",
                                Url = "https://codeproject.org.cn/Members/EduardSilantiev"
                            },
                            License = new License
                            {
                                Name = "The Code Project Open License (CPOL)",
                                Url = "https://codeproject.org.cn/info/cpol10.aspx"
                            }
                        });
                }

                //Extend Swagger for using examples
                options.OperationFilter<ExamplesOperationFilter>();

                //Get XML comments file path and include it to Swagger 
                //for the JSON documentation and UI.
                string xmlCommentsPath = 
                       Assembly.GetExecutingAssembly().Location.Replace("dll", "xml");
                options.IncludeXmlComments(xmlCommentsPath);
             });

            return services;
        }

        public static IApplicationBuilder UseSwaggerDocumentation
                                        (this IApplicationBuilder app,
                                        IApiVersionDescriptionProvider provider)
        {
            app.UseSwagger();
            app.UseSwaggerUI(options =>
            {
                //Build a swagger endpoint for each discovered API version  
                foreach (ApiVersionDescription description in 
                         provider.ApiVersionDescriptions)
                {
                    options.SwaggerEndpoint($"/swagger/
                    {description.GroupName}/swagger.json", 
                    description.GroupName.ToUpperInvariant());
                    options.RoutePrefix = string.Empty;
                    options.DocumentTitle = "SCAR store API documentation";
                    options.DocExpansion(DocExpansion.None);
                }
            });

            return app;
        }
    }
}

AddSwaggerDocumentation 方法中,我们添加了 VersionedApiExplorer 及选项,允许 ApiExplorer 理解 API 路由中版本控制的格式,并自动将 /v{version:apiVersion}/ 更改为 OpenApi 文档中的 /v1.1/。

注意!"'v'VV" 模式符合我们的版本控制考虑:<major>.<minor>,即 v1.0。但是 Swagger 会将 v1.0 转换为 v1,而 v1.1 保持不变。尽管如此,API 无论使用 v1.0 还是 v1 表示法都能正常工作。您可以在这里找到关于自定义 API 版本格式字符串的详细信息。

然后,我们实例化 ApiVersionDescriptionProvider。我们需要此服务来获取版本列表并为每个已发现的版本生成描述。在 services.AddSwaggerGen 命令中,我们生成这些描述。

您可以在这里找到关于OpenAPI 规范的详细信息。

在下一行,我们扩展了 Swagger Generator,使其能够向 OpenApi 文档添加响应示例 (以及请求示例,尽管在本例中不适用)。

…      
    options.OperationFilter<ExamplesOperationFilter>();
…

AddSwaggerDocumentation 方法的最后阶段是让 Swagger 知道 XML 注释文件的路径。因此,Swagger 将把 XML 注释包含在其 json OpenApi 文件和 UI 中。

UseSwaggerDocumentation 方法中,我们启用了 Swagger 并为所有 API 版本构建 Swagger UA 端点。我们再次使用 IApiVersionDescriptionProvider 来发现所有 API,但这次我们将提供者作为方法的参数传递,因为我们从 Startup.Configure 方法调用 UseSwaggerDocumentation 方法,在那里我们已经可以通过依赖注入获取提供者引用。

RoutePrefix = string.Empty 选项意味着 Swagger UI 将在我们的应用程序的根 URL 上可用,即 http://mydomainname.comhttp://mydomainname.com/index.html

DocExpansion(DocExpansion.None) 意味着在打开 Swagger UI 时,请求体将全部折叠。

Swagger 响应示例

我们已经在 AddSwaggerDocumentation 方法中扩展了 Swagger 以使用示例。让我们创建示例数据类。在 Swagger 文件夹中,创建一个 SwaggerExamples.cs 文件,其中包含所有示例类。

using SpeedUpCoreAPIExample.Exceptions;
using SpeedUpCoreAPIExample.ViewModels;
using Swashbuckle.AspNetCore.Examples;
using System.Collections.Generic;

namespace SpeedUpCoreAPIExample.Swagger
{
    public class ProductExample : IExamplesProvider
    {
        public object GetExamples()
        {
            return new ProductViewModel(1, "aaa", "Product1");
        }
    }

    public class ProductsExample : IExamplesProvider
    {
        public object GetExamples()
        {
            return new ProductsPageViewModel()
            {
                PageIndex = 1,
                PageSize = 20,
                TotalPages = 1,
                TotalCount = 3,
                Items = new List<ProductViewModel>()
                {
                    new ProductViewModel(1, "aaa", "Product1"),
                    new ProductViewModel(2, "aab", "Product2"),
                    new ProductViewModel(3, "abc", "Product3")
                }
            };
        }
    }

    public class PricesExamples : IExamplesProvider
    {
        public object GetExamples()
        {
            return new PricesPageViewModel()
            {
                PageIndex = 1,
                PageSize = 20,
                TotalPages = 1,
                TotalCount = 3,
                Items = new List<PriceViewModel>()
                { 
                    new PriceViewModel(100, "Bosch"),
                    new PriceViewModel(125, "LG"),
                    new PriceViewModel(130, "Garmin")
                }
            };
        }
    }

    public class ProductNotFoundExample : IExamplesProvider
    {
        public object GetExamples()
        {
            return new ExceptionMessage("Product not found");
        }
    }

    public class InternalServerErrorExample : IExamplesProvider
    {
        public object GetExamples()
        {
            return new ExceptionMessage("An unhandled exception has occurred");
        }
    }
}

这些类非常简单,它们只是返回 ViewModels 和示例数据,或者以我们统一的消息格式返回错误消息示例。然后,我们将 API 的响应代码与适当的示例链接起来。

现在我们在 Startup.ConfigureServices 方法中添加 Swagger 服务。

…      
    public void ConfigureServices(IServiceCollection services)
…          
        services.AddSwaggerDocumentation();
…

并在 Startup.Configure 方法中添加 Swagger 中间件。

…      
    public void Configure(IApplicationBuilder app, IHostingEnvironment env,
           ILoggerFactory loggerFactory, IApiVersionDescriptionProvider provider)
…          
    app.UseSwaggerDocumentation(provider);

    app.UseCors("Default");
    app.UseMvc();
…
注意!我们通过依赖注入获取 IApiVersionDescriptionProvider 并将其作为参数传递给 UseSwaggerDocumentation

用于生成 OpenAPI 文档的标签和特性

Swagger 理解大多数 XML 注释标签,并且有各种自己的特性。我们只选择了其中的一小部分,但这足以生成简短清晰的文档。

我们应该在控制器操作声明中应用这些标签和特性。以下是 ProductsController 的一些示例及解释。

…      
    /// <summary>
    /// Gets all Products with pagination.
    /// </summary>
    /// <remarks>GET /api/v1/products/?pageIndex=1&pageSize=20</remarks>
    /// <param name="pageIndex">Index of page to display 
    (if not set, defauld value = 1 - first page is used).</param>
    /// <param name="pageSize">Size of page (if not set, defauld value is used).</param>
    /// <returns>List of product swith pagination state</returns>
    /// <response code="200">Products found and returned successfully.</response>
    [ProducesResponseType(typeof(ProductsPageViewModel), StatusCodes.Status200OK)]
    [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProductsExample))]
    [HttpGet]
    [ValidatePaging]
    public async Task<IActionResult> GetAllProductsAsync(int pageIndex, int pageSize)
…
…      
    /// <summary>
    /// Gets a Product by Id.
    /// </summary>
    /// <remarks>GET /api/v1/products/1</remarks>
    /// <param name="id">Product's Id.</param>
    /// <returns>A Product information</returns>
    /// <response code="200">Product found and returned successfully.</response>
    /// <response code="404">Product was not found.</response>
    [ProducesResponseType(typeof(ProductViewModel), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)]
    [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProductExample))]
    [SwaggerResponseExample
     (StatusCodes.Status404NotFound, typeof(ProductNotFoundExample))]
    [HttpGet("{id}")]
    [ValidateId]
    public async Task<IActionResult> GetProductAsync(int id)
…

这些标签显然是不言自明的。让我们回顾一下特性。

[ProducesResponseType(typeof(ProductViewModel), StatusCodes.Status200OK)]

我们在此指出,如果操作成功,返回值类型将是 ProductViewModel:响应代码 = 200 OK)。

[SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProductExample))]

在这里,我们将 StatusCodes.Status200OK 和我们创建并填充了演示数据的 ProductExample 类链接起来。

注意!Swagger 已自动识别 id 参数为必需参数,来自 [HttpGet("{id}")] 特性。

我们 API 的响应代码列表并不完整。异常处理中间件也可以为任何 API 返回 Status500InternalServerError (内部服务器错误)。与其为每个操作添加响应代码=500 的描述,不如一次性为整个控制器声明。

…
    [ApiVersion("1.0")]
    [Route("/api/v{version:apiVersion}/[controller]/")]
    [ApiController]
    [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
    [SwaggerResponseExample(StatusCodes.Status500InternalServerError, 
     typeof(InternalServerErrorExample))]
    public class ProductsController : ControllerBase
    {
…
注意!我们不希望向客户端应用程序开发人员公开我们内部 API api/v1/prices/prepare。这就是为什么我们用 IgnoreApi = true 特性标记该操作。
[ApiExplorerSettings(IgnoreApi = true)]
    [HttpPost("prepare/{id}")]
    public async Task<IActionResult> PreparePricesAsync(int id)
    {
…

如果我们启动应用程序并访问其根 URL,我们将找到根据提供的选项、XML 注释和特性形成的 Swagger UI。

在右上角,我们可以看到“Select a spec”会话,这是一个版本选择器。如果我们向某个控制器添加 [ApiVersion("2.0")] 特性,则会自动发现 2.0 版本,并出现在此下拉列表中。

Swagger UI 非常简单。我们可以展开/折叠每个 API 并查看其描述、参数、示例等。如果我们要测试 API,应该点击“TryItOut”按钮。

然后,在适当的参数输入框中输入您想要检查的值,然后单击 **Examine**。

在这种情况下,结果将如预期。

对于客户端应用程序的开发人员,可以下载 OpenApi json 文件。

它可用于自动生成客户端应用程序代码 (例如使用 NSwagStudio),或导入到某些测试框架 (如 Postman) 中,以建立 API 的自动测试。

删除未使用的或重复的 NuGet 包

代码重构和优化似乎是一个无止境的过程。因此,我们必须在这里停止。但是,您可以使用 ReSharper 等有用工具继续获取有关如何改进代码质量的新想法。

由于代码在本文的范围内将不再更改,因此我们可以检查一下我们目前拥有的 NuGet 包。现在可以明显看出,我们存在一些包的重复和版本混乱。

目前,我们的依赖结构如下。

实际上,Microsoft.AspNetCore.All 包包含了这四个选定的包,因此我们可以轻松地将它们从应用程序中删除。

但是,在删除这些包时,我们应该考虑版本兼容性。例如,Microsoft.AspNetCore.All (2.0.5) 包包含 Microsoft.AspNetCore.Mvc (2.0.2)。这意味着我们在控制器中使用的 ApiController 特性将存在问题,而该特性自 MVC 版本 2.1 起可用。

因此,在删除额外的包后,我们还应该将 Microsoft.AspNetCore.All 升级到最新稳定版本。首先,我们应该在开发机上安装新的 SDK 版本 (如果还没有)。由于我们已经安装了 2.2 版本,我们将仅将应用程序的目标框架更改为 .NET Core 2.2。为此,右键单击项目,转到 **Properties** 菜单,并将 Target framework 更改为 2.2。

然后升级 Microsoft.AspNetCore.All 包。在 NuGet 包管理器中,从已安装的包中选择 Microsoft.AspNetCore.All 并安装新版本。

如果我们尝试用新依赖项重建解决方案,它将成功构建,但带有以下警告。

warning NETSDK1071: A PackageReference to 'Microsoft.AspNetCore.All' 
specified a Version of `2.2.6`. Specifying the version of this package 
is not recommended. For more information, see https://aka.ms/sdkimplicitrefs  

简单来说,我们应该在 CSPROJ 文件中删除 Microsoft.AspNetCore.All 的显式版本规范。为此,右键单击项目并选择 **Upload Project** 菜单。卸载完成后,再次右键单击项目并选择。

只需从 Microsoft.AspNetCore.AllPackageReference 中删除 Version="2.2.6"。结果应该是。

<Project Sdk="Microsoft.NET.Sdk.Web">
…
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.All" />
    <PackageReference Include="Serilog.Extensions.Logging.File" Version="1.1.0" />
  </ItemGroup>
…

再次重新加载项目。

请注意,在删除显式版本规范后,您可以在 NuGet 和 SDK 部分都看到 Microsoft.AspNetCore.All (并且仍然带有其版本)。

但是,如果我们再次重建解决方案,它将成功构建而没有任何警告。我们可以启动应用程序并使用 Swagger 或任何其他工具测试 API。它工作正常。

Microsoft.AspNetCore.All 和 Microsoft.AspNetCore.App 元包

即使在我们这样的小型简单应用程序中,我们也遇到了 NuGet 和版本混乱的开端。我们通过使用 Microsoft.AspNetCore.All 轻松解决了这些问题。

使用元包的另一个好处是我们应用程序的大小。它会变小,因为元包遵循共享框架概念。使用共享框架,构成元包的所有 DLL 文件都安装在共享文件夹中,也可以被其他应用程序使用。在我们的应用程序中,我们只有指向此文件夹中 DLL 的链接。当我们构建应用程序时,所有这些 DLL 都不会被复制到应用程序的文件夹中。这意味着,为了正常工作,目标机器上必须安装 .NET Core 2.0 (或更高版本) 运行时。

当我们容器化我们的应用程序时,共享框架概念的好处更大。元包将是 ASP.NET Core Runtime Docker 镜像的一部分。应用程序映像将只包含不属于元包的包,因此应用程序映像会更小,可以更快地部署。

最后一个要揭示的奇迹是隐式版本控制。由于我们在 CSPROJ 文件中删除了确切的元包版本,我们的应用程序将与目标机器上安装的任何版本的 .NET Core 运行时一起工作,前提是该运行时具有等于或高于我们引用的元包的版本。这使得在另一个环境中部署我们的应用程序以及更新 .NET Core 运行时而无需重新构建应用程序变得更加容易。

请注意,隐式版本控制仅在我们的项目使用 <Project Sdk="Microsoft.NET.Sdk.Web"> 时才有效。

从 ASP.NET Core 2.2 迁移到 3.0

本文的代码是用 ASP.NET Core 2.2 编写的。在准备文章时,新版本 3.0 已发布。如果您想使用 ASP.NET Core 3.0 检查代码,请考虑从 ASP.NET Core 2.2 迁移到 3.0

关注点

即使经过如此显著的改进,我们的应用程序仍然没有准备好投入生产。它缺少 HTTPS 支持、自动测试、保护连接字符串等。这些将可能是未来文章的重点。

历史

  • 2019 年 10 月 2 日:初始版本
© . All rights reserved.