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






4.98/5 (19投票s)
深入重构和优化 ASP.NET Core WEB API 应用程序代码
引言
在第三部分中,我们将回顾以下内容
- 为什么我们需要重构和优化代码?
- 不要重复自己 (DRY) 原则
- try/catch/finally 块中的异常处理
- 我们的异常处理中间件的要求
- 在 .NET Core 中记录到文件
- 统一异常消息格式
- 实现 .NET Core 异常处理中间件
- 业务逻辑层应向控制器返回哪种类型的结果?
- 自定义异常处理中间件
- 使用 HttpClientFactory 的类型化客户端
- 处理应用程序的设置
- 缓存关注点分离
- 通用异步分布式缓存存储库
- Entity Framework 中的内存和数据库内分页
- Controller vs ControllerBase
- 自定义 Id 参数验证过滤器和特性
- 分页参数自定义模型验证过滤器
- 跨域资源共享 (CORS)
- API 版本控制
- 本地解析 DNS 名称
- 记录 .NET Core API 应用程序
- 删除未使用的或重复的 NuGet 包
- Microsoft.AspNetCore.All 和 Microsoft.AspNetCore.App 元包
- 从 ASP.NET Core 2.2 迁移到 3.0
- 关注点
- 历史
为什么我们需要重构和优化代码?
第一部分的目标是创建一个非常简单的基础应用程序,我们可以从中开始。主要重点是如何更容易地应用和检查不同的方法,修改代码并检查结果。
第二部分专注于生产力。实现了各种方法。与第一部分相比,代码变得更加复杂。
现在,在选择并实现方法之后,我们可以将我们的应用程序视为一个整体。代码需要深入的重构和优化,以满足良好编程风格的各种原则。
不要重复自己 (DRY) 原则
根据 DRY 原则,我们应该消除代码重复。因此,让我们检查 ProductsService
代码,看看它是否有任何重复。我们可以立即看到,在所有返回 ProductViewModel
或 IEnumerable<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;
}
现在我们可以重写 ProductsService
的 FindProductsAsync
和 GetAllProductsAsync
方法中的重复代码。
…
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
类的 GetProductAsync
和 DeleteProductAsync
方法。
…
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
!
现在我们可以删除 ProductsService
和 PricesService
类中所有的 Try
-Catch
块。
Try
-Catch
块的实现代码。我们仍然需要 Try
-Catch
块的唯一地方是 ProductsService.PreparePricesAsync
和 PricesService.PreparePricesAsync
方法。正如我们在**第二部分**中所讨论的,我们不希望在这些地方破坏应用程序的工作流程。
删除 Try
-Catch
块后,代码变得更加简单明了。但大多数服务的返回方法中仍然存在一些重复:
return new NotFoundResult();
让我们也改进这一点。
在所有查找值集合的方法中,如 ProductsService.GetAllProductsAsync
、ProductsService.FindProductsAsync
和 PricesService.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.GetProductAsync
和 ProductsService.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);
…
}
我们还应该在 ProductsService
和 PricesService
类中重新声明相应的方法,将 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
方法中使用它。IHttpContextAccessor
和 GetFullyQualifiedApiUrl
方法都只用于 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
服务中,我们应该在类构造函数中注入它。我们可以删除 IHttpContextAccessor
和 IHttpClientFactory
,因为我们不再使用它们,并且可以删除 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。我们将为 Products
和 Prices
服务形成两个包含设置的节。
"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.GetPricesAsync
和 PricesRepository.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
,因为我们不打算直接创建它的实例。相反,它将是 PricesCacheRepository
和 ProductsCacheRepository
类的基类。注意,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)
};
}
}
}
GetDefaultOptions
在 ProductCacheRepository
和 PricesCacheRepository
类中的实现是相同的,似乎可以移到基类。但是在实际应用程序中,不同对象的缓存策略可能不同,如果我们把 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
{
}
}
}
}
在 GetPricesAsync
和 PreparePricesAsync
方法中,我们使用了 PricesCacheRepository
的 GetOrSetValueAsync
方法。如果所需值不在缓存中,则调用异步方法 GetPricesAsync
。
我们还创建了 IsPriceCachedAsync
和 RemovePriceAsync
方法,稍后将使用它们。不要忘记在 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 中的内存和数据库内分页
您可能已经注意到,ProductsController
的 GetAllProductsAsync
和 FindProductsAsync
方法以及 PricesController
的 GetPricesAsync
方法返回的产品和价格集合的大小没有任何限制。这意味着在具有巨大数据库的实际应用程序中,某些 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.GetAllProductsAsync
和 ProductsRepository.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;
}
}
}
以及继承自 PageViewModel
的 ProductsPageViewModel
和 PricesPageViewModel
类。
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.GetAllProductsAsync
和 ProductsService.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);
}
…
PageIndex
和 PageSize
参数传递给 PaginatedList
构造函数,则会使用默认值 - PageIndex = 1
和 PageSize
= 整个数据表大小。为了避免返回 Products
和 Prices
表的所有记录,我们将分别使用来自 ProductsSettings
和 PricesSettings
的默认值 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));
}
…
现在我们可以重写 ProductsController
和 PricesController
,使它们能够处理新的分页机制。
让我们更改 ProductsController.GetAllProductsAsync
和 ProductsController.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 的客户端,它仍然可以使用新版本,因为如果我们省略了 pageIndex
或 pageSize
参数或两者都省略,它们的值将为 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
参数的 GetProductAsync
和 DeleteProductAsync
操作。
…
[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 级别检查参数更好,但这将需要重复验证代码或创建其他公共辅助类及其验证方法。
由于分页参数来自外部,组织其检查的更好的地方是在将它们传递给分页过程之前的控制器中。
因此,我们必须创建另一个模型验证过滤器,该过滤器将验证 PageIndex
和 PageSize
参数。验证的想法略有不同 - 任何一个或两个参数都可以省略,可以等于零或大于零的整数。
在同一个 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.GetAllProductsAsync
、ProductsController.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.MVC
或 Microsoft.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
类中应用版本控制。在该类中,我们设置 HttpClient
的 BaseAddress
属性来调用 API。我们应该在构建基本地址时考虑版本控制。
为了避免硬编码我们要调用的 API 的版本,我们创建一个 API 版本控制设置类。在 appsettings.json 文件中,创建一个 API 部分:
…
,
"Api": {
"Version": "1.0"
}
…
然后在 Settings 文件夹中,创建一个 ApiSettings.cs 文件。
namespace SpeedUpCoreAPIExample.Settings
{
public class ApiSettings
{
public string Version { get; set; }
}
}
在 Startup
的 ConfigureServices
方法中声明该类。
…
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.AspNetCore
和 Swashbuckle.AspNetCore.Filters
版本 5.0.0-rc8 已可用,但我们使用了较低的版本。原因是版本 2.9.0 和 5.0.0-rc8 之间存在一些兼容性问题。因此,选择了经过验证的稳定 NuGet 包组合。希望在新的版本中,Swagger 开发人员将解决所有兼容性问题。让我们在应用程序中创建一个 Swagger 文件夹,然后在其中创建一个 SwaggerServiceExtensions
类。这个 static Swagger
扩展类将封装所有与服务设置相关的逻辑。我们将从 Startup
的 ConfigureServices
和 Configure
方法调用此类的函数,从而使 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/。
然后,我们实例化 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.com 或 http://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
类链接起来。
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
{
…
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.All
的 PackageReference
中删除 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>
…
再次重新加载项目。
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 日:初始版本