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

Web API – 一种可靠的方法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (43投票s)

2016年1月5日

CPOL

9分钟阅读

viewsIcon

99410

downloadIcon

2774

在 .NET 无状态服务库之上轻松创建精简的 Web API

引言

之前的 CodeProject 文章中,我声称(作为对其中一篇文章评论的回答)该文章中描述的领域服务库构成了Web API的完美基础。在本文中,我将演示这一点。

对于 Web API 究竟是什么,可能存在多种理解。在我的理解中,它是一种基于 HTTP 的 Web 服务。这意味着一个真正拥抱 HTTP 的 Web 服务——HTTP 是互联网的主要应用协议,也是万维网的驱动力。一个 Web 服务不仅将 HTTP 用作传输协议,而且还积极使用 HTTP 的统一接口(HTTP 方法 GETPUTPOSTDELETE 等)。它是一个 Web 服务,没有任何基于 HTTP 的 SOAP 或 RPC 抽象层——只有纯粹的 HTTP。你可以将其视为一种“回归基础”的 Web 服务。

有些人也使用 REST API 这个术语。然而,REST 并不是 Web API 的同义词,而是一种架构风格,你可以在 Web API 中选择遵守或不遵守。实际上,要判断 Web API 是否符合 RESTful 规范可能相当困难。Martin Fowler 对 Richardson 成熟度模型有一个很好的描述,这有助于判断 Web API 是否真正符合 RESTful 规范。

背景

上一篇文章中,我介绍了以下三个程序集 (DLL)

Image of 3-layered structure

DomainServices 项目包含通用接口和抽象类形式的基本抽象——例如,通用 IRepository 接口和抽象 BaseService 类。DomainServices 项目也作为开源项目提供,并作为 NuGet 包发布。本文中的示例代码使用了此包。

MyServices 项目包含 DomainServices 抽象的一些具体扩展——例如,一个 ProductService 和一个 IProductRepository 接口。

MyServices.Data 项目包含在 MyServices 中定义的存储库接口的具体实现——例如,一个 ProductRepository,它是 IProductRepository 的实现,用于存储序列化为 JSON 文件的产品。

在本文的示例代码中,添加了一个名为 MyServices.Web 的额外程序集。这就是 Web API。它包含许多所谓的控制器——例如,一个 ProductController

4-layered architecture

在领域服务之上构建 Web API 如此容易的主要原因是这些服务是无状态的。它们不维护任何有关服务消费者(客户端)状态的信息。它们不跟踪从一次调用到另一次调用的任何信息。这与 Web API 完美契合,因为 HTTP 是一个无状态协议。通过这种方式,Web API 可以实现为一个非常精简的层——本质上是作为一种外观模式,通过 HTTP 公开纯粹的 .NET 服务。

总的来说,这种架构的特点如下:

  • 存储库模式在数据提供程序技术(数据库、ORM 系统、文件格式等)方面确保了最大的灵活性和可伸缩性。
  • 真正的业务功能被限定在 MyServices 中,在那里可以使用 伪造的存储库轻松进行单元测试
  • MyServices 中的业务功能可以作为库提供给任何类型的 .NET 项目——例如,一个 Windows 桌面应用程序。

由于 Web API 层非常精简,开发框架的选择并不关键,因为它可以相对容易地进行更换。

描绘为依赖关系图,它看起来像这样

Dependency Graph

在此架构中,Web API(和其他 UI)以及数据提供者仅仅是实现细节。它们是核心业务功能在 MyServices 程序集中的插件。这是一种非常健壮的架构,因为它允许推迟重大决策。也就是说,关于 UI、数据提供者和框架等方面的重大决策。一个好的架构允许你迟做决策。迟做决策总是更好的,因为那时你拥有更多的信息。

现在,让我们深入了解 MyServices.Web 示例代码的细节。代码是在 Visual Studio 2022 中使用 ASP.NET Core 6.0 和 C# 10 编写的。

控制器

控制器类处理传入的 HTTP 请求。为 MyServices 中的每个服务创建一个控制器类是非常有意义的。这是完整的 ProductsController 类,具有完整的 CRUD 功能:

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly ProductService _productService;
 
    public ProductsController(IProductRepository productRepository)
    {
        _productService = new ProductService(productRepository);
    }
 
    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll() => Ok(_productService.GetAll());
 
    [HttpGet("{id}")]
    public ActionResult<Product> Get(Guid id) => Ok(_productService.Get(id));
 
    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    public ActionResult<Product> Add(ProductDto productDto)
    {
        var product = productDto.ToProduct();
        _productService.Add(product);
        return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
    }
 
    [HttpPut]
    public ActionResult<Product> Update(ProductDto productDto)
    {
        var product = productDto.ToProduct();
        _productService.Update(product);
        return Ok(_productService.Get(product.Id));
    }
 
    [HttpDelete("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    public IActionResult Delete(Guid id)
    {
        _productService.Remove(id);
        return NoContent();
    }
}

如上所述,最好是 Web 服务程序集除了少量精简的控制器类之外,不应包含太多其他内容。在我看来,它不会比上面提到的产品控制器更精简:一些非常短的公共方法(动作)本质上将工作重定向到底层服务。

如果控制器操作返回 IActionResultActionResult<T>,ASP.NET 框架会提供一些底层功能来组成 HTTP 响应消息。该框架带有一些内置的 IActionResult 实现——也称为便捷方法。例如,Ok 将创建一个带有 HTTP 状态码 200 (OK) 的响应,或者 CreatedAtAction 将创建一个带有状态码 201 (Created) 和一个指向创建资源的路由的 Location 头的响应。

使用构造函数注入——一种依赖注入(DI)模式——将 IProductRepository 实例注入到控制器中:

public class ProductsController : ControllerBase
{
    private readonly ProductService _productService;
 
    public ProductsController(IProductRepository productRepository)
    {
        _productService = new ProductService(productRepository);
    }

    ...
}

依赖注入在 Program.cs 文件中配置

builder.Services.AddScoped<IProductRepository>(_ => productRepository);

路由

当 Web API 收到 HTTP 请求时,它会尝试将请求路由到控制器中的某个动作。有两种方法可以将传入请求路由到正确的动作:基于约定的路由属性路由。此代码使用属性路由,这是 REST API 的首选选项。

这是通过使用路由模板之一完成的——例如,HttpGet。下面的例子定义了检索具有特定 ID 的产品的动作的路由

[HttpGet("api/products/{id}")]
public ActionResult<Product> Get(Guid id) => Ok(_productService.Get(id));

字符串 "api/product/{id}" 是路由的 URI 模板。在此 URI 模板中,"{id}" 是变量参数的占位符。动作特定的路由模板可以与控制器上的 Route 属性结合使用,该属性为控制器中所有动作定义一个公共路由前缀。

[Route("api/products")]
public class ProductsController : ControllerBase
{
    ...
}

模型绑定

将 HTTP 请求的变量参数映射到动作参数的过程称为模型绑定。由 stringintGuid 等基本 .NET 类型表示的简单参数可以直接通过 URI 传递,或者通过使用查询字符串参数传递。例如,在产品控制器的 Delete 动作中就是这种情况

[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult Delete(Guid id)
{
    _productService.Remove(id);
    return NoContent();
}

如果要传递更复杂的参数,例如在添加新产品时传递一个产品,则需要从 HTTP 请求的正文中读取它。框架会自动假定复杂类型是请求正文的一部分。例如,在产品控制器的 Add 动作中就是这样做的:

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
public ActionResult<Product> Add(ProductDto productDto)
{
    var product = productDto.ToProduct();
    _productService.Add(product);
    return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}

请注意,领域模型对象(Product 类)并未直接暴露在 API 边界,即,不假定 HTTP 请求中发送的输入数据可以直接反序列化为 Product 类。相反,引入了一个 ProductDto 数据结构。这是一种非常健壮的方法,因为在边界处,应用程序并非面向对象ProductDto 结构的作用是解释和验证接收到的数据,并可能将其转换为正确的 Product 对象。更多内容见下文。

模型验证

模型验证在模型绑定后自动进行,并在数据不符合业务规则时报告错误——例如,如果给出了负数的产品价格。

在 ASP.NET Core 中,您可以使用 DataAnnotations 属性(例如,RequiredKeyRange)为领域模型上的属性设置验证规则。但如上所述,与其在模型本身层面处理验证,不如在“解释层”——ProductDto 结构中处理,这是一种更健壮的方法。

public struct ProductDto
{
    public Guid? Id { get; set; }
 
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Required, Range(1, double.MaxValue)]
    public decimal Price { get; set; }

    public Product ToProduct()
    {
        return new Product(Id ?? Guid.NewGuid(), Name) { Price = Price };
    }
}

从这个数据结构可以看出,不期望添加新产品的 HTTP 请求提供产品 ID,因为 Id 属性没有用 Required 属性修饰。如果未提供,ID 将在调用 ToProduct 方法时自动生成。

异常处理

示例代码还演示了如何创建自定义异常处理中间件

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
 
    public ExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }
 
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await ex.ToHttpResponse(context);
        }
    }
}

如果抛出异常,将调用 ToHttpResponse 扩展方法。在此方法中,选定的异常被映射到特定的 HTTP 状态码。KeyNotFoundExceptionArgumentOutOfRangeException 被映射到 HTTP 状态码 404 (未找到)ArgumentException 被映射到状态码 400 (错误请求)。所有其他异常都将被映射到状态码 500 (内部服务器错误)

public static class ExtensionMethods
{
    public static Task ToHttpResponse(this Exception ex, HttpContext context)
    {
        HttpStatusCode code;
        if (ex is KeyNotFoundException || ex is ArgumentOutOfRangeException)
        {
            code = HttpStatusCode.NotFound;
        }
        else if (ex is ArgumentException)
        {
            code = HttpStatusCode.BadRequest;
        }
        else if (ex is NotImplementedException || ex is NotSupportedException)
        {
            code = HttpStatusCode.NotImplemented;
        }
        else
        {
            code = HttpStatusCode.InternalServerError;
        }
 
        var result = JsonSerializer.Serialize(new { error = ex.ToString() });
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)code;
        return context.Response.WriteAsync(result);
    }
 
    public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<ExceptionHandlingMiddleware>();
    }
}

现在,将自定义中间件添加到 Program.cs 中定义的请求管道将强制执行此策略,并确保在抛出异常时返回正确的 HTTP 状态码

app.UseExceptionHandling();

调试与托管

为了演示目的,在 Program.cs 文件中配置了一个内存中的产品存储库,并预置了几个产品:

var products = new List<Product> { 
    new Product(Guid.NewGuid(), "Coke") { Price = 1.35M },
    new Product(Guid.NewGuid(), "Fanta") { Price = 1.85M }
};

var productRepository = new InMemoryProductRepository(products);
builder.Services.AddScoped<IProductRepository>(_ => productRepository);

要在 Visual Studio 中调试示例代码,请将 MyServices.Web 设置为启动项目,然后按 F5。这将在 Swagger UI 下启动。您现在可以浏览 API 端点。

例如,要添加新产品,请展开 POST api/products 端点,按下“试用”按钮,修改请求正文,然后按下“执行”按钮。

Web API 可以像任何其他 ASP.NET Web 应用程序一样部署——例如,它可以在 Windows 上使用 IIS 托管,或者可以发布到 Azure

摘要

通过将 Web API 创建为无状态 .NET 服务集合的外观,这些服务拥有并公开核心业务功能,您可以创建一个非常精简的 Web API。将 Web API 视为底层 .NET 服务的插件,您将建立一个非常健壮的架构,允许推迟重大决策——例如,Web API 编程框架的选择不再那么重要,因为它相对容易被其他技术取代。

该解决方案也非常适合 DI,因为服务可以通过构造函数注入的方式注入到控制器类中。

尽管所描述的架构非常坚固,是生产代码的良好基础,但为了清晰起见,本文和示例代码中省略了许多生产代码的考虑事项。这些考虑事项包括但不限于:安全性、版本控制、缓存、日志记录、内容协商等。然而,有大量的宝贵资源可以为这些主题提供帮助。

示例代码中使用的 DomainServices 框架作为开源项目提供,并作为 NuGet 包发布。

历史

  • 2016年1月5日
    • 初始版本
  • 2016年1月7日
    • 添加了依赖关系图
    • 修改了文本
    • 修改了代码片段语法
  • 2021年11月18日
    • 示例代码已更新为使用 ASP.NET Core 6.0 和 C# 10
    • 现在使用 ASP.NET Core 内置依赖注入代替 Unity
    • 引入 Swagger UI
    • 文章文本相应修改
© . All rights reserved.