# 「翻译」手摸手教你如何实现干净的、易维护的 RESTful APIs
原文链接 An awesome guide on how to build RESTful APIs with ASP.NET Core
约定: 文中的 API Endpoint 中的Endpoint翻译为端点, 其意思为WEB API接口的资源终点位置
# 概述
RESTful
不是一个新的术语。它指的是web服务从客户端应用程序接收和发送数据的架构风格。这些应用程序的目标是集中不同客户端应用程序将使用的数据。
选择一个恰当的工具来编写 RESTful
服务是非常重要的, 因为我们需要考虑到可伸缩性、可维护性、文档是否健全以及其他相关方面。ASP.NET Core 为我们提供了一个功能强大、易于使用的 API 来实现这一个目标。
在本篇文章中, 我会使用 ASP.NET Core 框架, 来向你展示一个“几乎”真实的场景编写结构化良好的RESTful API。我将会详细介绍开发过程的常见模式和策略。
我还将向你展示一些常见的框架和库的使用方式, 如 Entity Framework Core 和 AutoMapper , 来交付必要的功能。
# 先决条件
我假定你已经有面向对象编程概念的知识。
虽然我将会复习很多关于 C# programming language 的细节, 我建议你对这一门语言有基础的理解。
同时, 我也假定你知道什么是 REST、 HTTP 协议 是如何工作的、什么是 API endpoints (路径又称"终点") 以及 JSON 是什么。这里有一个关于这方面的 入门教程 。最后一个要求是了解关系型数据库是如何工作。
跟我一起编写代码, 你必须先安装 .NET Core 2.2, 以及 Postman , Postman 工具的作用是测试 API。我建议你使用代码编辑器(如 Visual Studio Code )来开发API。选择你最喜欢的代码编辑器。如果你选择 VS code , 我建议你安装 C# extension , 它会让你拥有更好的代码高亮效果。
你可以在本文最后找到一个本篇教程的配套代码的 GitHub 仓库链接, 以检查最终结果。
# The Scope
让我们为一个超市写一个虚拟的 web API. 假设我们要实现以下功能:
- 创建一个 RESTful 服务, 它允许客户端应用程序管理超市的产品分类, 它需要暴露一个路径(API)来增删改查产品分类, 例如奶制品和化妆品, 并同时管理这些产品的类别。
- 对于分类, 我们需要存储它们的名称。对于产品, 我们需要存储它们的名称、计量单位(比如, KG为产品的重量)、包装数量(比如, 10 如果产品是一包饼干)以及它们各自的类别。
为了简化这个例子, 我不会处理库存产品,产品运输,安全和任何其他功能。给定的需求已经足以向你展示 ASP.NET Core 是如何工作的。
在开发这个服务之前, 我们基本上需要两个 API endpoints: 一个管理分类, 另一个管理产品。在JSON通信方面,我们可以将响应看作如下:
API endpoint: /api/categories
JSON 响应(GET请求):
{
[
{ "id": 1, "name": "Fruits and Vegetables" },
{ "id": 2, "name": "Breads" },
… // Other categories
]
}
API endpoint: /api/products
JSON 响应(GET请求):
{
[
{
"id": 1,
"name": "Sugar",
"quantityInPackage": 1,
"unitOfMeasurement": "KG"
"category": {
"id": 3,
"name": "Sugar"
}
},
… // Other products
]
}
让我们开始编写这个应用吧.
# 步骤1 - 创建 API
首先, 我们必须为这个web服务创建一个目录结构, 然后我们需要使用 .NET CLI tools 脚手架构建一个 web API。打开终端或者命令行(取决于你使用的操作系统)依次输入下列的命令:
mkdir src/Supermarket.API
cd src/Supermarket.API
dotnet new webapi
前两个命令只是为了API创建一个新的目录, 并将当前位置更改为新创建的目录。最后一个命令根据 Web API 模板创建一个项目, 这就是我们将要开发的应用程序。你可以通过这个链接阅读更多的关于这些命令和其他的可以创建的项目模板
新建的目录现在将会有如下的结构:
# 结构概述
一个 ASP.NET Core 应用程序由一组在 Startup
类中配置的中间件(附加到应用程序管道的小应用程序片段, 用于处理请求和响应)组成。如果你以前已经使用过 Express.js 这样的框架, 那么这个概念对你来说并不陌生。
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseMvc();
}
}
当应用程序开始运行, Program
类中的 Main
方法首先被调用。它使用启动配置创建一个默认的 web 主机,通过一个特定的端口提供HTTP服务 (默认情况下,HTTP使用5000端口, 5001用于HTTPS)。
namespace Supermarket.API
{
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
}
看一看在 Controllers
目录中的 ValuesController
类, 它公开当 API 通过路由 /api/values
接收请求时将调用的方法。
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
// GET api/values
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
return new string[] { "value1", "value2" };
}
// GET api/values/5
[HttpGet("{id}")]
public ActionResult<string> Get(int id)
{
return "value";
}
// POST api/values
[HttpPost]
public void Post([FromBody] string value)
{
}
// PUT api/values/5
[HttpPut("{id}")]
public void Put(int id, [FromBody] string value)
{
}
// DELETE api/values/5
[HttpDelete("{id}")]
public void Delete(int id)
{
}
}
如果你不理解上面代码的某个部分, 别担心。我将在开发必要的API endpoints时详细介绍每个点。现在,简单地删除这个类,因为我们不打算使用它。
# 步骤2 - 创建领域模型
我将应用一些设计概念,使得应用程序能保持简单且易于维护。
编写自己能够理解和维护的代码并不困难,但是你必须记住,你会作为团队的一份子去工作。如果你不注意如何编写代码,结果将会很可怕,它会让你和你的团队非常头痛。这件事听起来很极端,对吧? 但是相信我,这是事实。
wtf — code quality measurement by smitty42 is licensed under CC-BY-ND 2.0
让我们开始编写领域层, 该层会有我们的模型类, 这些类用来表示我们的产品和分类, 以及仓储层和服务层的接口类型。稍后我将会解释这两个概念。
在 Supermarket.API
这个目录中, 创建一个名为 Domaind
的文件夹。同时在这个新的领域文件夹中, 新建另一个名为 Models
的文件夹。我们需要添加到这个文件夹的第一个模型是 Category
。起初, 它只是一个简单的老式 CLR 对象类(POCO)。这意味着该类将只有描述其基本信息的属性。
using System.Collections.Generic;
namespace Supermarket.API.Domain.Models
{
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public IList<Product> Products { get; set; } = new List<Product>();
}
}
这个类有一个 Id
属性去区分列别, 和一个 Name
属性, 以及一个 Products
属性。最后一个将由 Entity Framework Core 使用, 它是 ASP.NET Core 中最常见的数据持久化 ORM 工具, 用来映射产品和类别之间的关系。从面向对象编程的角度来看, 它是很有意义的, 因为一个类别会有许多个相关产品。
我们还需要在相同的目录下创建一个产品模型类, 添加一个新的 Product
类。
namespace Supermarket.API.Domain.Models
{
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public short QuantityInPackage { get; set; }
public EUnitOfMeasurement UnitOfMeasurement { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}
}
产品同样拥有 Id 和 Name 两个属性。QuantityInPackage
属性表示一包产品中含有多少个单元(记得 Scope 中的饼干示例), 以及一个 UnitOfMeasurement
属性。这个属性用一个枚举类型来表示可能的度量单位。最后两个属性, CategoryId
和 Category
将会被 ORM 用来映射产品和类别之间的关系。它表示一个产品只有一个类别。
让我们一起定义最后一个领域模型中的部分, EUnitOfMeasurement
枚举。
按照惯例,enum不需要在名称前面以“E”开头,但是在一些库和框架中,你会发现这个前缀是区分enum与接口和类的一种方式。
using System.ComponentModel;
namespace Supermarket.API.Domain.Models
{
public enum EUnitOfMeasurement : byte
{
[Description("UN")]
Unity = 1,
[Description("MG")]
Milligram = 2,
[Description("G")]
Gram = 3,
[Description("KG")]
Kilogram = 4,
[Description("L")]
Liter = 5
}
}
这段代码非常直截了当。在这里,我们只定义了少量的度量单位的可能性,不过,在一个真正的超市系统中,你可能有许多其他的度量单位,并且可能有一个单独的模型。
请注意 Description
attribute 被应用在每个可能的枚举上。attribute是在c#语言的类、接口、属性和其他组件上定义元数据的一种方式。在本案例中, 我们会使用它来简化产品 API endpoint 的响应, 不过你现在不需要关心它。我们稍后再回来。
我们的基本模型已经可供使用。现在我们可用开始编写 API endpoint 来管理所有的类别。
# 步骤3 - 类别 API
在 Controllers 目录中, 添加一个名为 CategoriesController
的类。
通常情况下, 该目录下的所有以*“Controller”*结尾的类将会成为我们应用程序中的控制器。这意味着它们将会处理请求和响应。你必须从 Controller
这个类继承, 它在 Microsoft.AspNetCore.Mvc
命名空间中定义。
名称空间由一组相关的类、接口、枚举和结构体组成。你可以将其看作类似于 Javascript 语言的modules或Java的packages。
这个新的控制器应该通过 /api/categories
这个路由来响应。我们通过在类的名称上添加 Route
attribute 来实现这个功能, 根据惯例, 指定一个占位符, 指示路由应该使用没有控制器后缀的类名。
using Microsoft.AspNetCore.Mvc;
namespace Supermarket.API.Controllers
{
[Route("/api/[controller]")]
public class CategoriesController : Controller
{
}
}
让我们开始处理 GET 请求。首先, 当有人通过 GET 谓词从 /api/categories
请求数据时, API 需要返回所有的类别。我们可以为这个目的创建一个类别服务。
从概念上讲,服务基本上是定义处理某些业务逻辑方法的类或接口。在许多不同的编程语言中,创建用于处理业务逻辑的服务是一种常见的方式,例如身份验证和授权、支付、复杂的数据流、缓存以及需要在其他服务或模型之间进行某些交互的任务。
使用服务,我们可以将请求和响应处理与完成任务所需的实际逻辑隔离开来。
我们即将创建的服务会定义一个单一的职责或方法: 一个列表方法。我们希望这个方法返回数据库中的所有类别。
为简单起见,在本例中我们不处理数据分页或过滤。我将在以后撰写一篇文章,介绍如何轻松地处理这些特性。
在 C# 中定义一个预期的行为((在其他面向对象的语言中,例如Java)), 我们使用 interface 来定义。接口告诉我们要什么, 但是不实现其真正的逻辑。真正的实现逻辑在实现这个接口的类中。如果你不清楚这个概念,不要担心。你很快就会明白的。
在 Domain
目录中, 创建一个名为 Services
的新文件夹。在其中添加一个名为 ICategoryService
的接口。按照惯例,所有接口都应该以 C# 中的大写字母 “I” 开头。接口代码定义如下:
using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;
namespace Supermarket.API.Domain.Services
{
public interface ICategoryService
{
Task<IEnumerable<Category>> ListAsync();
}
}
实现 ListAsync
方法必须异步返回类别的枚举。
Task
类, 封装返回值, 表示异步。我们需要以异步方式思考,因为我们必须等待数据库完成一些操作来返回数据,而这个过程可能需要一段时间。还请注意“async”后缀。这是一个表明我们的方法应该异步执行的约定。
我们有很多约定, 对吗? 我个人非常喜欢这种方式, 因为它保证应用程序的可读性, 即使你是一个使用.NET技术的公司的新人。
“ - Ok, we defined this interface, but it does nothing. How can it be useful?”
如果你刚从一个如 Javascript 或者其它非强类型语言转过来, 这些概念可能会比较陌生。
接口允许我们从实际实现中抽象出所需的行为。使用一种已知的机制, 例如依赖注入, 我们可以实现这些接口并将它们与其他组件隔离。
通常情况下, 当你使用依赖注入时, 你需要通过接口定义一下行为。然后创建一个类去实现这个接口。最后, 将接口中的引用绑定到创建的类。
提示
这听起来很让人困惑。我们不能简单地创建一个类来为我们做这些事情吗?
让我们继续实现我们的 API ,然后你会理解为什么我使用这种方式。
按照以下的方式修改 CategoriesController
的代码:
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Services;
namespace Supermarket.API.Controllers
{
[Route("/api/[controller]")]
public class CategoriesController : Controller
{
private readonly ICategoryService _categoryService;
public CategoriesController(ICategoryService categoryService)
{
_categoryService = categoryService;
}
[HttpGet]
public async Task<IEnumerable<Category>> GetAllAsync()
{
var categories = await _categoryService.ListAsync();
return categories;
}
}
}
我已经为我们的控制器定义了一个构造方法(在创建类的新实例构造函数会被调用), 它接受一个 ICategoryService
实例。这意味着这个实例可以是任何实现这个接口服务的类。我将这个实例存储在一个私有的只读字段 _categoryService
中。我们将使用此字段访问类别服务实现的方法。
顺便说一下,下划线前缀是表示字段的另一种常见约定。特别提一下, 这个约定并不是 .NET 官方文档 推荐的, 但这是一种非常常见的做法,它可以使用“This”关键字来区分类字段和局部变量。我个人认为它更易于阅读,很多框架和库都使用这种约定。
结构体函数的下方, 我定义了一个处理请求 /api/categories
的方法。HttpGet
属性表示通知 ASP.NET Core 管道使用 GET 请求处理它(这个属性可以省略,但是为了便于阅读,最好编写它)。
该方法使用 category 服务实例列出所有类别,然后将类别返回给客户端。框架管道处理数据, 并将其序列化成 JSON 对象。IEnumerable<Category>
类型告诉框架,我们想要返回一个类别的枚举,而 Task
类型之前的 async
关键字告诉管道,这个方法应该异步执行。最后,当我们定义一个异步方法时,我们必须使用 await
关键字来处理需要一些时间的任务。
我们定义好了 API 的初始结构。现在,有必要真正实现 categories 服务。
# 步骤4 - 类别服务实现
在 API 的根目录中( Supermarket.API
目录 ), 创建一个名为 Services
的新文件夹。我们将把所有的服务实现放入其中。在新创建的文件夹中, 创建一个 CategoryService
类, 按照下面的代码进行修改。
using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Services;
namespace Supermarket.API.Services
{
public class CategoryService : ICategoryService
{
public async Task<IEnumerable<Category>> ListAsync()
{
}
}
}
它只是接口实现的基本代码,但我们仍然不处理任何逻辑。让我们考虑一下列表方法应该如何工作。
我们需要去访问数据库并返回所有的类别, 然后我需要将这些数据返回给客户端。
服务类不是应该处理数据访问的类。这是一种称为仓储模式的模式,用于管理数据库中的数据。
当我们使用仓储模式, 我们需要定义一个 仓储类 , 它基本上封装了处理数据访问的所有逻辑。这些仓储库公开方法来列出、创建、编辑和删除给定模型的对象,与操作集合的方式相同。在内部,这些方法与数据库通信以执行CRUD操作,它将数据库访问与应用程序的其他部分隔离开来。
我们的服务需要与类别仓储进行通信, 以获得对象的集合。
通常情况下, 一个服务可以调用一个或者多个仓储, 以及其它的服务去执行操作。
创建一个处理数据访问逻辑的定义可能看起来有些多余,但是你会在一段时间内看到,将这个逻辑与服务类隔离确实很有好处。
让我们创建一个仓储库,它负责持久化类别的一种方式来仲裁数据库通信。
# 步骤5 - 类别仓储与持久层
在 Domain
目录中, 新建一个 Repositories
新文件夹。然后, 新建一个名为 ICategoryRespository
的接口类。以如下的方式定义一个接口。
using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;
namespace Supermarket.API.Domain.Repositories
{
public interface ICategoryRepository
{
Task<IEnumerable<Category>> ListAsync();
}
}
初始代码与服务接口的代码基本相同。
当有了这个接口, 我们回到服务类中并开始实现集合方法, 使用 ICategoryRepository
的实例去返回数据。
using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Repositories;
using Supermarket.API.Domain.Services;
namespace Supermarket.API.Services
{
public class CategoryService : ICategoryService
{
private readonly ICategoryRepository _categoryRepository;
public CategoryService(ICategoryRepository categoryRepository)
{
this._categoryRepository = categoryRepository;
}
public async Task<IEnumerable<Category>> ListAsync()
{
return await _categoryRepository.ListAsync();
}
}
}
现在, 我们必须类别仓储中的真正逻辑。在这之前, 我们必须先考虑我们如何去访问数据库。
是的, 我们仍然还没有数据库!
我们将使用 Entity Framework Core (为了简单, 我将之称为 EF Core
) 作为我们的数据库 ORM。这个框架附带了ASP.NET Core 作为其默认的ORM,并且 API 支持友好的,允许我们将应用程序的类映射到数据库表。
EF Core 允许我们先设计应用程序, 然后根据我们在代码中定义的内容生成一个数据库。这种技术称为 code first
。我们将使用 code first 方法来生成数据库(在本例中,实际上我将使用内存数据库,但是你可以轻松地将其更改为 SQL 服务器或 MySQL 服务器实例)。
在 API 根目录中, 创建一个名为 Persistence
的文件夹。这个文件夹将包含所有我们访问数据库的内容, 比如仓储层的实现。
在这个新目录中, 创建一个新文件夹 Contexts
, 然后添加一个 AppDbContext
类。这个类必须继承 DbContext
, 它是 EF Core 用来映射你的模型到数据库表的类。修改代码如下:
using Microsoft.EntityFrameworkCore;
namespace Supermarket.API.Domain.Persistence.Contexts
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
}
}
我们添加到这个类的构造函数负责通过依赖项注入将数据库配置传递给基类。过一会儿你就会明白这是怎么回事。
现在, 我们需要创建两个 DbSet
属性, 这些属性是将模型映射到数据库表的集合(惟一对象的集合)。
同时, 我们必须将模型的属性映射到相应的表列, 指定哪些属性是主键, 哪些是外健, 列表类型等等。我们可以使用 Fluent API 的特性来指定数据库映射,从而重写 OnModelCreating
方法。更改 AppDbContext
类如下:
using Microsoft.EntityFrameworkCore;
using Supermarket.API.Domain.Models;
namespace Supermarket.API.Persistence.Contexts
{
public class AppDbContext : DbContext
{
public DbSet<Category> Categories { get; set; }
public DbSet<Product> Products { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Category>().ToTable("Categories");
builder.Entity<Category>().HasKey(p => p.Id);
builder.Entity<Category>().Property(p => p.Id).IsRequired().ValueGeneratedOnAdd();
builder.Entity<Category>().Property(p => p.Name).IsRequired().HasMaxLength(30);
builder.Entity<Category>().HasMany(p => p.Products).WithOne(p => p.Category).HasForeignKey(p => p.CategoryId);
builder.Entity<Category>().HasData
(
new Category { Id = 100, Name = "Fruits and Vegetables" }, // Id set manually due to in-memory provider
new Category { Id = 101, Name = "Dairy" }
);
builder.Entity<Product>().ToTable("Products");
builder.Entity<Product>().HasKey(p => p.Id);
builder.Entity<Product>().Property(p => p.Id).IsRequired().ValueGeneratedOnAdd();
builder.Entity<Product>().Property(p => p.Name).IsRequired().HasMaxLength(50);
builder.Entity<Product>().Property(p => p.QuantityInPackage).IsRequired();
builder.Entity<Product>().Property(p => p.UnitOfMeasurement).IsRequired();
}
}
}
我们指定了模型应该映射到哪些表。同时, 我们使用了 HasKey
设置了主键, 使用 Property
方法设置表列, 和一些约束如: IsRequired
, HasMaxLength
和 ValueGeneratedOnAdd
, 所有都是用 “流畅的方式” lambda 表达式 (链式方式)。
看下面这段代码:
builder.Entity<Category>()
.HasMany(p => p.Products)
.WithOne(p => p.Category)
.HasForeignKey(p => p.CategoryId);
这里我们指定了表之间的关系。我们说一个类别具有多个产品, 我们设置了映射这个关系的属性( Products
来自 Category
类, 而 Category
来自 Product
类。同时, 我们还设置了一个外健( CategoryId
)。
如果你想学习如何使用 EF Core
来配置一对一或多对多之间的关系, 可以看一下这个教程, 以及如何将其作为一个整体使用。
这里还有一种生成数据的配置方式, 通过 HasData
来:
builder.Entity<Category>().HasData
(
new Category { Id = 100, Name = "Fruits and Vegetables" },
new Category { Id = 101, Name = "Dairy" }
);
在这里, 我们简单的添加了两个示例类别。这是在我们完成 API 接口后用来测试接口所必需的条件。
注意
我们在这里手动设置了 Id 属性,因为内存数据库需要它才能工作。我将标识符设置为大数字,以避免自动生成的标识符和种子数据之间的冲突。
这种限制在真正的关系数据库中并不存在,因此,如果你使用 SQL Server 之类的数据库,就不必指定这些标识符。如果你想了解这种行为,可以自行查看这个 Github issue。
在实现了数据库上下文类之后,我们可以实现类别仓储层。在 Persistence
目录中新建一个名为 Repositories
的新文件夹, 然后新建一个 BaseRepository
类:
using Supermarket.API.Persistence.Contexts;
namespace Supermarket.API.Persistence.Repositories
{
public abstract class BaseRepository
{
protected readonly AppDbContext _context;
public BaseRepository(AppDbContext context)
{
_context = context;
}
}
}
这个类仅是一个抽象类, 供所有的仓储层类去继承。抽象类是无法实例化的类。你必须实例化一个类来使用它。
BaseRepository
通过依赖项注入接收 AppDbContext
的实例,并公开一个名为 _context
的受保护属性(该属性只能由子类访问),该属性允许访问处理数据库操作所需的所有方法。
在同一个文件夹中添加一个名为 CategoryRepository
新类。现在我们来真正实现仓储层的逻辑:
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Repositories;
using Supermarket.API.Persistence.Contexts;
namespace Supermarket.API.Persistence.Repositories
{
public class CategoryRepository : BaseRepository, ICategoryRepository
{
public CategoryRepository(AppDbContext context) : base(context)
{
}
public async Task<IEnumerable<Category>> ListAsync()
{
return await _context.Categories.ToListAsync();
}
}
}
这个仓储层类继承了基类 BaseRepository
并实现了 ICategoryRepository
接口。
你可能注意到列表方法的实现是如此简单。我们使用 Categories
数据库去访问类别表并且调用 ToListAsync
扩展方法, 这个方法负责将查询结果转换成类别集合。
EF Core 将我们的方法调用转换成 SQL 查询语句, 以最优的方法。只有在调用将数据转换为集合的方法或使用方法获取特定数据时,查询才会执行。
我们现在有一个简洁的类别控制器实现, 以及服务和仓储层。
我们已经将关注点分离,创建了只做它们应该做的事情的类。
测试应用程序之前的最后一步是使用 ASP.NET Core 依赖注入机制将我们的接口绑定到相应的类。
# 步骤6 - 配置依赖注入
现在是你最终理解这个概念如何工作的时候了。
在应用程序的根目录中, 打开 Startup
类。这个类负责在应用程序启动时配置所有类型的配置。
框架管道在运行时调用 ConfigureServices
和 Configure
方法, 以配置应用程序应该如何工作以及它必须使用哪些组件。
让我们看一看 ConfigureServices
方法。这里我们只有一行代码来配置应用程序去使用 MVC 管道, 这基本上意味着应用程序将使用控制器类来处理请求和响应(这里有更多底层处理的事情,但这不是你现在需要知道的)。
我们可以使用 ConfigureServices
方法, 访问 services
参数并配置我们的依赖注入绑定。清除类代码,删除所有注释,修改代码如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Supermarket.API.Domain.Repositories;
using Supermarket.API.Domain.Services;
using Supermarket.API.Persistence.Contexts;
using Supermarket.API.Persistence.Repositories;
using Supermarket.API.Services;
namespace Supermarket.API
{
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
services.AddDbContext<AppDbContext>(options => {
options.UseInMemoryDatabase("supermarket-api-in-memory");
});
services.AddScoped<ICategoryRepository, CategoryRepository>();
services.AddScoped<ICategoryService, CategoryService>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseMvc();
}
}
}
让我们观察这一段代码:
services.AddDbContext<AppDbContext>(options => {
options.UseInMemoryDatabase("supermarket-api-in-memory");
});
这里我们配置来一个数据库上下文。我们告诉 ASP.NET Core 将我们的 AppDbContext
与我们的内存中的数据库实现一起使用, 该实现由作为参数传递给我们的方法的字符串标识。通常, 在编写集成测试时使用内存中的数据库, 但是为了简单起见, 我在这里使用它。通过这种方式, 我们不需要连接到真实的数据库来测试应用程序。
这些行在内部配置我们的数据库上下文,以便使用确定作用域的生存周期进行依赖项注入。
作用域的生存周期告诉 ASP.NET Core 管道, 每当它需要解析一个类, 这个类接收 AppDbContext
的实例作为构造函数参数时, 它应该使用类的相同实例。如果内存中没有实例, 则管道将创建一个新实例, 并在给定请求期间在需要它的所有类中重用它。通过这种方式, 你不必在每次使用这类的时候都实例化这个类。
这里有其他的生命周期, 你可以在官方文档中查阅。
依赖注入技术带给了我们很多好处,比如:
- 代码重用性;
- 更好的生产力,因为当我们不得不改变实现的时候,我们不需要麻烦的去改变你使用那个特性的一百多个地方;
- 你可以轻易测试你的应用程序, 因为我们可以使用 mock (类的假实现)来隔离需要测试的内容,在 mock 中,我们必须将接口作为构造函数参数传递;
- 当类需要通过构造函数接收更多依赖项时,你不必手动更改创建实例的所有位置(这太棒了!)
当配置好数据库上下文时, 我们还将服务和存储库绑定到相应的类。
services.AddScoped<ICategoryRepository, CategoryRepository>();
services.AddScoped<ICategoryService, CategoryService>();
这里我们使用作用域生命周期, 因为这些类内部必须使用数据库的上下文。在这种情况下, 指定相同的范围是有意义的。
现在我们配置了依赖绑定, 我们还需要在 Program
类中做一点小的改动, 以便我们的数据库能正确的初始化。这一步仅仅在使用内存数据库时需要(查看这个 Github issue 能知道原因)。
我们有必要更改 Main
方法,以确保在应用程序启动时“创建”数据库,因为我们使用的是内存提供程序。没有这个更改,我们想要创建的类别就不会创建。
实现了所有的基本特性之后,就可以测试我们的 API 端点了。
# 步骤7 - 测试分类的 API
打开 API 根目录中的终端或命令提示符,输入以下命令:
dotnet run
这个命令
上面的命令启动应用程序。控制台将显示一个类似这样的输出:
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
Entity Framework Core 2.2.0-rtm-35687 initialized ‘AppDbContext’ using provider ‘Microsoft.EntityFrameworkCore.InMemory’ with options: StoreName=supermarket-api-in-memory
info: Microsoft.EntityFrameworkCore.Update[30100]
Saved 2 entities to in-memory store.
info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0]
User profile is available. Using ‘C:\Users\evgomes\AppData\Local\ASP.NET\DataProtection-Keys’ as key repository and Windows DPAPI to encrypt keys at rest.
Hosting environment: Development
Content root path: C:\Users\evgomes\Desktop\Tutorials\src\Supermarket.API
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
你可以看到 EF Core 被调用去初始化数据库。最后一行展示了应用程序运行在哪个端口上。
打开浏览器并导航到 http://localhost:5000/api/categories (或导航到控制台输出中显示的URL)。如果你看到由于HTTPS而导致的安全错误,只需为应用程序添加一个异常。
浏览器将显示以下JSON数据作为输出:
[
{
"id": 100,
"name": "Fruits and Vegetables",
"products": []
},
{
"id": 101,
"name": "Dairy",
"products": []
}
]
这里我们看到的是在配置数据库上下文时添加到数据库中的数据。这个输出确认了我们的代码可以正常工作。
你用几行代码就创建了一个GET API,并且由于API的体系结构,你有一个非常容易更改的代码结构。
现在,向你展示当你必须根据业务需求进行调整时,更改这段代码是多么容易。
# 步骤8 - 创建一个分类资源
如果你还记得 API endpoint 的规范,你应该注意到了我们的实际 JSON 响应有一个额外属性:一个产品数组。让我们来看一下期望响应的例子:
{
[
{ "id": 1, "name": "Fruits and Vegetables" },
{ "id": 2, "name": "Breads" },
… // Other categories
]
}
因为我们的类别模型有一个 Products 属性,products 数组出现在我们当前的 JSON 响应中,EF Core 需要这个属性来纠正给定类别的产品映射。
我们不想这个属性在我们的响应中,但是我们不能修改我们的模型类去删除这个属性。这样会导致 EF Core 在我们试图管理类别数据时抛出异常,这也会破坏我们的领域模型设计,因为没有产品的产品类别是没有意义的。
要返回只包含超市类别的标识符和名称的 JSON 数据,我们必须创建一个 resource 类。
resource类 是一个只包含客户机应用程序和API端点之间交换的基本信息的类,通常以JSON数据的形式表示一些特定信息。
来自 API 端点的所有响应都必须返回一个资源。
响应直接返回实体模型是一个糟糕的实践, 因为它可能包含客户端应用程序不需要或者它没有权限获取的信息(例如, 一个用户模型的可能返回用户密码信息, 这是一个严重的安全问题)。
我们需要一个资源去表示仅仅只有我们的分类, 而没有产品。
现在你知道我们要的资源是什么了, 让我们实现它。首先, 在命令行按 Ctrl + C 停止正在运行的应用程序。在应用的根目录文件夹, 创建一个名为 Resources
的文件夹。然后, 创建一个 CategoryResource
类。
namespace Supermarket.API.Resources
{
public class CategoryResource
{
public int Id { get; set; }
public string Name { get; set; }
}
}
我们必须将类别服务提供的类别模型集合映射到类别资源集合中。
我们将会一个叫做 AutoMapper 的库, 它能处理两个对象之间的映射。AutoMapper 是 .NET 世界中非常流行的库,许多商业和开源项目中都在使用它。
将下列的行输入到命令行中, 将 AutoMapper 添加到我们的应用中。
dotnet add package AutoMapper
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
在使用 AutoMapper 之前, 我们必须先做下面的两件事:
- 以依赖注入的方式将其注册;
- 创建一个类, 告诉 AutoMapper 如何去处理类之间的映射关系。
首先, 打开 Startup
类。在 ConfigureServices
方法, 在最后一个行, 添加下面的代码:
services.AddAutoMapper();
这一行处理 AutoMapper 的所有必要配置,比如以依赖注入注册它,以及在启动期间扫描应用程序以配置映射配置文件。
现在, 在根文件夹, 创建一个新的文件夹 Mapping
, 然后创建一个类 ModelToResourceProfile
。修改代码如下:
using AutoMapper;
using Supermarket.API.Domain.Models;
using Supermarket.API.Resources;
namespace Supermarket.API.Mapping
{
public class ModelToResourceProfile : Profile
{
public ModelToResourceProfile()
{
CreateMap<Category, CategoryResource>();
}
}
}
这个类继承自 Profile
, 是AutoMapper用来检查映射如何工作的类类型。在构造函数中, 我们创建一个 Category
模型类和 CategoryResource
类之间的映射。由于类的属性具有相同的名称和类型,我们不需要为它们进行任何特殊的配置。
最后一步是将 categories 控制器更改为使用 AutoMapper 来处理对象映射。
using System.Collections.Generic;
using System.Threading.Tasks;
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Services;
using Supermarket.API.Resources;
namespace Supermarket.API.Controllers
{
[Route("/api/[controller]")]
public class CategoriesController : Controller
{
private readonly ICategoryService _categoryService;
private readonly IMapper _mapper;
public CategoriesController(ICategoryService categoryService, IMapper mapper)
{
_categoryService = categoryService;
_mapper = mapper;
}
[HttpGet]
public async Task<IEnumerable<CategoryResource>> GetAllAsync()
{
var categories = await _categoryService.ListAsync();
var resources = _mapper.Map<IEnumerable<Category>, IEnumerable<CategoryResource>>(categories);
return resources;
}
}
}
我修改了构造函数去接收实现了 IMapper
接口的对象。你可以使用接口中的方法去调用 Automapper 映射方法。
我还更改了 GetAllAsync
方法,将类别枚举映射为使用map方法的资源枚举。此方法接收要映射的类或集合的实例,并通过泛型类型定义将其定义为必须映射的类或集合的类型。
注意,我们可以很容易地更改实现,而不必修改服务类或存储库,只需向构造函数注入一个新的依赖项(IMapper
)即可。
依赖项注入使应用程序易于维护和更改,因为不必破坏所有代码实现来添加或删除特性。
你可能意识到不仅控制器类,而且所有接收依赖的类(包括依赖本身)都被自动解析为根据绑定配置接收正确的类。
依赖注入很神奇,不是吗?
现在,再次使用 dotnet run 命令启动API,并转到http://localhost:5000/api/categories查看新的JSON响应。
我们已经有了 GET 端点。现在,让我们创建一个新的端点来发布(创建)类别。
# 步骤9 - 创建新的分类
当处理资源的创建, 我们必须要考虑很多事情, 比如:
- 数据有效性和完整性;
- 创建资源的权限;
- 错误处理;
- 日志。
我不会在本篇教程中告诉你授权和验证的详细情况, 但是你可以阅读我的 JSON web令牌身份验证教程时,你可以看到如何轻松实现这些功能。
还有一个非常流行的框架叫做 ASP.NET Identity,它提供有关安全性和用户注册的内置解决方案,可以在应用程序中使用它。它提供与EF Core一起工作的方式,比如一个你可以使用的内建的 IdentityDbContext
。你可以在这里了解更多。
让我们编写一个 HTTP POST 端点,它将涵盖其他场景(日志记录除外,可以根据不同的范围和工具进行更改)。
在创建这个新端点之前, 我们需要一个新的资源。这个资源将会映射客户端应用发送到这个端点(在本例中, 是类别名称)的数据到我们英语中的某个类。
因为我们正在创建一个新类别,所以还没有ID,这意味着我们需要一个资源来表示只包含其名称的类别。
在 Resources
文件夹, 添加一个 SaveCategoryResource
新类:
using System.ComponentModel.DataAnnotations;
namespace Supermarket.API.Resources
{
public class SaveCategoryResource
{
[Required]
[MaxLength(30)]
public string Name { get; set; }
}
}
注意 Name
属性上的 Required
和MaxLength
这个两个attributes(特性)。这些特性叫做数据注释。ASP.NET Core 管道中使用这些元数据去验证请求与响应的数据。正如名称所暗示的,类别名称是必需的,并且最大长度为30个字符。
现在,让我们定义新API端点的具体化。将以下代码添加到 categories 控制器:
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] SaveCategoryResource resource)
{
}
我们告诉框架这是一个使用 HttpPost
特性表示的 HTTP POST 端点。
注意这个方法的相应类型, Task<IActionResult>
。控制器类中出现的方法称为 actions,它们具有此签名,因为我们可以在应用程序执行操作后返回多个可能的结果。
在这种情况下, 如果类别的名称是无效的,如果出现问题,我们必须返回一个400的代码(坏的请求)响应,通常包含一个错误消息,客户机应用程序可以使用它来对待这个问题,或者我们可以在一起正常的情况下返回一个200响应(成功)的数据。
作为响应, 我们可以使用很多操作响应类型, 但通常情况下, 我们可以使用这个接口和 ASP.NET Core 将为此使用一个默认类。
FromBody
特性告诉 ASP.NET Core 将请求体数据解析到我们的新资源类。这意味着,当包含类别名称的 JSON 被发送到我们的应用程序时,框架将自动将其解析为我们的新类。
现在, 让我们实现我们的路由逻辑。我们必须按照一点的步骤才能成功创建一个新的类别:
- 首先, 我们必须验证传入的请求。如果请求无效,则必须返回包含错误消息的错误请求响应;
- 然后, 如果请求有效, 我们必须使用 AutoMapper 映射我们的新资源到我们的类别模型类中;
- 我们现在需要调用我们的服务, 告诉它保存我们的新类别。如果保存逻辑执行没有问题, 它应该返回一个包含我们新类别数据的响应。如果没有,它应该给我们一个进程失败的指示,以及一个潜在的错误消息;
- 最后, 如果发生错误, 我们返回一个错误的请求。如果没有, 我们将新的类别模型映射到一个类别资源,并向客户端返回一个包含新类别数据的成功响应。
它看起来很复杂,但是使用我们为 API 构造的服务体系结构来实现这个逻辑确实很容易。
让我们从验证传入的请求开始。
# 步骤10 - 使用模型状态验证请求主体
ASP.NET Core 的控制器有一个叫做 ModelState
的属性。此属性在请求执行期间填充,然后执行我们的操作。它是 ModelStateDictionary
的一个实例, 包含诸如请求是否有效和潜在验证错误等信息的类。
修改端点的代码如下:
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] SaveCategoryResource resource)
{
if (!ModelState.IsValid)
return BadRequest(ModelState.GetErrorMessages());
}
上面代码检查模型状态(在本例中,是在请求体中发送的数据)是否无效,并检查我们的数据注释。如果不是,则API返回一个坏定请求(400状态码)和注释元数据提供的默认错误消息。
ModelState.GetErrorMessages()
方法还未实现。它是一个 扩展方法 (扩展现有类或接口的功能的方法), 我将实现它来将验证错误转换为简单的字符串以返回给客户端。
在我们的 API 的根目录中添加一个新的文件夹 Extensions
,然后添加一个新的类 ModelStateExtensions
。
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Supermarket.API.Extensions
{
public static class ModelStateExtensions
{
public static List<string> GetErrorMessages(this ModelStateDictionary dictionary)
{
return dictionary.SelectMany(m => m.Value.Errors)
.Select(m => m.ErrorMessage)
.ToList();
}
}
}
所有的扩展方法必须是静态的, 以及声明它们的类。这意味着它们不处理特定的实例数据,并且它们只在应用程序启动时加载一次。
参数声明前面的 this
关键字是告诉c#编译器把它当作一个扩展方法。目的是为了能让我们像调用这个类的普通方法一样调用它,因为我们在需要使用扩展的地方包含了相应的 using
指令。
这个扩展使用了LINQ查询,这是 .NET 的一个非常有用的特性,允许我们使用链式表达式来查询和转换数据。这里的表达式将验证错误方法转换为包含错误消息的字符串列表。
在执行下一步之前, 导入名称空间 Supermarket.API.Extensions
到categories控制器中。
using Supermarket.API.Extensions;
让我们通过将新资源映射到类别模型类来继续实现端点逻辑。
# 步骤11 - 映射新的资源
我们已经定义了一个映射配置文件来将模型转换为资源。现在我们需要一个新的配置文件来做相反的事情。
在 Mapping
中创建一个新的类 ResourceToModelProfile
:
using AutoMapper;
using Supermarket.API.Domain.Models;
using Supermarket.API.Resources;
namespace Supermarket.API.Mapping
{
public class ResourceToModelProfile : Profile
{
public ResourceToModelProfile()
{
CreateMap<SaveCategoryResource, Category>();
}
}
}
这里没有什么新的改动。多亏了依赖注入的,AutoMapper将在应用程序启动时自动注册这个配置文件,我们不需要更改任何其他地方来使用它。
现在我们可以把我们的新资源映射到相应的模型类:
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] SaveCategoryResource resource)
{
if (!ModelState.IsValid)
return BadRequest(ModelState.GetErrorMessages());
var category = _mapper.Map<SaveCategoryResource, Category>(resource);
}
# 步骤12 - 使用请求-响应模式来处理保存逻辑
现在我们必须实现最有趣的逻辑:保存一个新类别。我们希望我们的服务能做到这一点。
保存逻辑可能会因为连接到数据库时出现的问题而失败,或者可能因为任何内部业务规则都会使我们的数据无效。
如果出现错误,我们不能简单地抛出一个错误,因为它可能会停止API,而客户端应用程序不知道如何处理这个问题。此外,我们可能会有一些日志机制来记录错误。
保存方法的约定,它意味着方法和响应类型的签名,需要指出我们是否正确地执行了流程。如果过程顺利,我们将收到类别数据。如果没有,我们至少必须接收一条错误消息,说明进程失败的原因。
我们可以使用请求-响应模式来实现这个功能。此企业级设计模式将我们的请求和响应参数封装到类中,作为一种封装信息的方式,我们的服务将使用这些信息来处理某些任务,并将信息返回给使用服务的类。
这种模式给我们带来了一些好处,比如:
- 如果我们需要更改服务来接收更多参数,我们不必破坏它的签名;
- 我们可以为我们的请求或响应定义一个标准契约;
- 我们可以在不停止应用程序进程的情况下处理业务逻辑和潜在的故障,并且我们不需要使用大量的try-catch块。
让我们为处理数据更改的服务方法创建一个标准的响应类型。对于这种类型的每个请求,我们都想知道该请求执行时是否没有问题。如果失败,我们希望向客户端返回一条错误消息。
在 Domain
文件夹中的 Services
, 创建一个 Communication
的新文件夹。新建一个 BaseResponse
的新类。
namespace Supermarket.API.Domain.Services.Communication
{
public abstract class BaseResponse
{
public bool Success { get; protected set; }
public string Message { get; protected set; }
public BaseResponse(bool success, string message)
{
Success = success;
Message = message;
}
}
}
这是一个抽象类, 我们的响应类型会继承它。
该抽象定义了一个 Success
属性(它将告诉请求是否成功完成)和一个 Message
属性(如果某些操作失败,它将带有错误消息)。
注意,这些属性是必需的,只有继承的类才能设置这些数据,因为子类必须通过构造函数传递这些信息。
TIP
为所有的响应定义一个基类并不是一个好的设计, 因为基类会耦合代码并阻止你轻易地修改它。最好使用组合而不是继承。
对于这个 API 的范围来说,使用基类并不是什么问题,因为我们的服务不会增长太多。如果你意识到服务或应用程序将频繁增长和更改,请避免使用基类。
现在, 在同一个目录, 添加一个 SaveCategoryResponse
新类:
using Supermarket.API.Domain.Models;
namespace Supermarket.API.Domain.Services.Communication
{
public class SaveCategoryResponse : BaseResponse
{
public Category Category { get; private set; }
private SaveCategoryResponse(bool success, string message, Category category) : base(success, message)
{
Category = category;
}
/// <summary>
/// Creates a success response.
/// </summary>
/// <param name="category">Saved category.</param>
/// <returns>Response.</returns>
public SaveCategoryResponse(Category category) : this(true, string.Empty, category)
{ }
/// <summary>
/// Creates am error response.
/// </summary>
/// <param name="message">Error message.</param>
/// <returns>Response.</returns>
public SaveCategoryResponse(string message) : this(false, message, null)
{ }
}
}
响应类型同样设置为 Category
属性, 如果请求成功完成,该属性将包含我们的 Category 数据。
注意,我为这个类定义了三个不同的构造函数:
私有的,它将把成功和消息参数传递给基类,并设置
Category
属性;仅将类别属性作为参数的构造函数。它将创建一个成功的响应,调用私有构造函数来设置相应的属性;
第三个构造函数只指定消息。用于创建失败响应。
因为 C# 支持重载构造函数, 我们简化了响应的创建,不必定义不同的方法来处理它,只是使用了不同的构造函数。
现在,我们可以更改服务接口来添加新的存储方法。
将 ICategoryService
修改成如下:
using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;
using Supermarket.API.Domain.Services.Communication;
namespace Supermarket.API.Domain.Services
{
public interface ICategoryService
{
Task<IEnumerable<Category>> ListAsync();
Task<SaveCategoryResponse> SaveAsync(Category category);
}
}
我们将简单地将一个类别传递给这个方法,它将处理保存模型数据所需的所有逻辑,编排存储库和其他必要的服务。
注意,这里我没有创建特定的请求类,因为我们不需要任何其他参数来执行此任务。在计算机编程中有一个概念叫做 KISS —— 简单,愚蠢。基本上,它说你应该让你的应用程序尽可能的简单。
在设计应用程序时请记住这一点: 只应用解决问题所需的内容。不要过度设计你的应用程序。
现在我们可以完成我们的端点逻辑:
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] SaveCategoryResource resource)
{
if (!ModelState.IsValid)
return BadRequest(ModelState.GetErrorMessages());
var category = _mapper.Map<SaveCategoryResource, Category>(resource);
var result = await _categoryService.SaveAsync(category);
if (!result.Success)
return BadRequest(result.Message);
var categoryResource = _mapper.Map<Category, CategoryResource>(result.Category);
return Ok(categoryResource);
}
在验证了请求的数据映射到我们的模型资源后, 我们将其传递给我们的服务来保存数据。
如果发生错误, API 返回一个错误响应。如果没有, API 映射一个新的类别(包含了新的 Id
) 到我们之前创建的CategoryResource
并将其发送给客户端。
现在, 让我们实现服务的真正逻辑。
# 步骤13 - 数据库逻辑和工作模式单元
因为我们要将数据持久化到数据库中,所以我们需要在存储库中添加一个新方法。
添加一个新的AddAsync
方法到ICategoryRepository
接口:
public interface ICategoryRepository
{
Task<IEnumerable<Category>> ListAsync();
Task AddAsync(Category category);
}
现在, 让我们在实际的repository类中实现这个方法:
public class CategoryRepository : BaseRepository, ICategoryRepository
{
public CategoryRepository(AppDbContext context) : base(context)
{ }
public async Task<IEnumerable<Category>> ListAsync()
{
return await _context.Categories.ToListAsync();
}
public async Task AddAsync(Category category)
{
await _context.Categories.AddAsync(category);
}
}
这里我们只是添加了一个新的类别到我们的集合。
当我们将一个类添加到DBSet<>
时,EF Core开始跟踪发生在我们的模型上的所有变化,并在当前状态下使用这些数据来生成查询将插入、更新或删除模型的。
当前的实现只是简单地将模型添加到我们的集合中,但是我们的数据仍然不会被保存。
有一个名为SaveChanges
的方法出现在上下文类中,我们必须调用它才能真正执行到数据库中的查询语句。我没有在这里调用它,因为存储库不应该持久化数据,它只是对象在内存中的集合。
即使是在经验丰富的 .NET 开发人员之间,这个主题也很有争议,但是让我来解释一下为什么不应该在存储库类中调用SaveChanges
。
我们可以把存储库想象成 .NET 框架中的任何其他集合。当我们在 .NET (以及任一其它的编程语言, 如 Javascript和Java)中处理一个集合, 你通常可以:
- 添加一个新元素(就像往集合、数组、字典中添加元素);
- 查找和筛选元素;
- 从集合中移除一个元素;
- 替换一个元素, 或者更新它。
想象一下现实世界中的集合。想象一下,你正在写一张购物清单,要在超市买东西(多么巧啊,不是吗?)
在集合中, 你添加了所有需要购买的水果。你可以把水果添加到这个列表中,如果你不买的话就把一个水果移除,或者你可以替换一个水果的名字。但是你不能将水果保存到列表中。用平实的英语说这样的事没有意义。
TIP
在用面向对象的编程语言设计类和接口时,尝试使用自然语言来检查你所做的是否正确。
例如,说一个人实现了一个person接口是有意义的,但是一个人实现一个account是没有意义的。
如果你想“保存”水果列表(在这种情况下,是购买所有的水果),你需要付费,超市会处理库存数据,以检查他们是否需要从供应商那里购买更多的水果。
在编程时可以应用相同的逻辑。存储库不应该保存、更新或删除数据。相反,它们应该将其委托给另一个类来处理这个逻辑。
直接将数据保存到存储库时还存在另一个问题: 不能使用事务。
假设我们的应用程序有一个日志记录机制,该机制存储一些用户名,并在每次对API数据进行更改时执行操作。
现在,假设由于某种原因,你调用了一个更新用户名的服务(这不是常见的场景,但是让我们考虑一下)。
你同意要更改虚构用户表中的用户名,首先必须更新所有日志,以正确地告知谁执行了该操作,对吗?
现在,假设我们为不同存储库中的用户和日志实现了更新方法,它们都调用了SaveChanges
。如果其中一个方法在更新过程中失败了,会发生什么情况?你将以数据不一致而告终。
我们应该只在所有更改完成后才将更改保存到数据库中。为了实现这个功能, 我们必须使用 事物, 这基本上是大多数数据库实现的功能,只有在复杂的操作完成后才保存数据。
“好吧,如果我们不能在这里保存东西,我们应该在哪里保存呢?”
处理此问题的常见模式是 工作单元模式。此模式由一个类组成,该类将我们的AppDbContext
实例作为依赖项接收,并公开启动、完成或中止事务的方法。
我们将使用一个工作单元的简单实现来处理这里的问题。
在Domain
层的Repositories
文件夹中添加一个名为IUnitOfWork
的新接口:
using System.Threading.Tasks;
namespace Supermarket.API.Domain.Repositories
{
public interface IUnitOfWork
{
Task CompleteAsync();
}
}
如你所见,它只公开一个异步完成数据管理操作的方法。
现在让我们添加真正的实现。
在Persistence
层的RepositoriesRepositories
文件夹中添加一个名为UnitOfWork
的新类:
using System.Threading.Tasks;
using Supermarket.API.Domain.Repositories;
using Supermarket.API.Persistence.Contexts;
namespace Supermarket.API.Persistence.Repositories
{
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public async Task CompleteAsync()
{
await _context.SaveChangesAsync();
}
}
}
这是一个简单、干净的实现,它只会在你使用存储库完成修改之后将所有更改保存到数据库中。
如果你研究工作单元模式的实现,你将发现更复杂的实现回滚操作的实现。
由于EF Core已经在幕后实现了存储库模式和工作单元,所以我们不需要关心回滚方法。
”——什么?那么,为什么我们必须创建所有这些接口和类吗?”
将持久性逻辑与业务规则分离在代码可重用性和维护方面提供了许多优势。如果我们直接使用EF Core,我们最终会得到更复杂的类,这些类不太容易修改。
假设将来你决定将 ORM 框架更改为不同的框架,例如Dapper,或者由于性能原因必须实现普通SQL查询。如果将查询逻辑与服务耦合,则很难更改逻辑,因为在很多类中都必须这样做。
使用repository(仓储)模式,你可以简单地实现一个新的repository类,并使用依赖项注入对其进行绑定。
所以,基本上,如果你在你的服务中直接使用EF Core,你必须改变一些东西,你会得到:
如上文所述,EF Core在背后实现了工作单元和仓储模式。我们可以将**DbSet<>**属性视为存储库。另外,SaveChanges仅在所有数据库操作成功的情况下保存数据。
现在你已经知道了什么是工作单元,以及为什么要将它与存储库一起使用,接下来让我们实现真正的服务逻辑。
public class CategoryService : ICategoryService
{
private readonly ICategoryRepository _categoryRepository;
private readonly IUnitOfWork _unitOfWork;
public CategoryService(ICategoryRepository categoryRepository, IUnitOfWork unitOfWork)
{
_categoryRepository = categoryRepository;
_unitOfWork = unitOfWork;
}
public async Task<IEnumerable<Category>> ListAsync()
{
return await _categoryRepository.ListAsync();
}
public async Task<SaveCategoryResponse> SaveAsync(Category category)
{
try
{
await _categoryRepository.AddAsync(category);
await _unitOfWork.CompleteAsync();
return new SaveCategoryResponse(category);
}
catch (Exception ex)
{
// Do some logging stuff
return new SaveCategoryResponse($"An error occurred when saving the category: {ex.Message}");
}
}
}
由于我们的解耦架构,我们可以简单地传递一个UnitOfWork实例作为这个类的依赖项。
我们的业务逻辑非常简单。
首先,我们尝试将新类别添加到数据库中,然后API尝试保存它,将所有内容包装在try-catch块中。
如果发生故障,API将调用某个虚构的日志服务并返回一个表示故障的响应。
如果流程结束时没有问题,应用程序将返回一个成功响应,发送类别数据。简单,是吧?
TIP
在实际应用程序中,不应该将所有内容都封装在一个通用try-catch块中,而是应该分别处理所有可能的错误。
简单地添加try-catch块无法覆盖大多数可能的失败场景。确保正确的实现错误处理。
测试我们的API之前的最后一步是将工作单元接口绑定到它各自的类。
将这行代码添加到Startup
类的ConfigureServices
方法中:
services.AddScoped<IUnitOfWork, UnitOfWork>();
现在, 让我们测试它!
# 步骤14 - 使用Postman测试我们的POST端点
再次使用dotnet run
启动我们的应用程序。
我们不能使用浏览器来测试POST端点。让我们使用Postman来测试我们的端点。它是一个非常好的测试RESTful APIs的工具。
打开Postman并关闭介绍消息。你会看到这样的屏幕:
将默认选中的GET
更改为POST
的选择框。
在Enter request URL
字段中键入API地址。
我们必须提供发送给API的请求体数据。单击Body
菜单项,然后将其下面显示的选项更改为raw
。
Postman将在右边显示一个Text
选项。将其更改为JSON (application/ JSON)
,并粘贴以下JSON数据:
{
"name": ""
}
如你所见, 我们将向新端点发送一个空名称字符串。
单击Send
按钮。你会收到这样的输出:
你还记得我们为端点创建的验证逻辑吗?这个输出就是它工作的证明!
还要注意右侧显示的400状态码。BadRequest结果会自动将此状态代码添加到响应中。
现在让我们将JSON数据更改为一个有效的数据,以查看新的响应:
API正确地创建了我们的新资源。
到目前为止,我们的API可以列出和创建类别。你学了很多关于C#语言的东西,ASP.NET核心框架以及构造API的常用设计方法。
让我们继续创建用于更新类别的端点的categories API。
从现在开始,既然我已经向你们解释了大部分概念,我将加快解释的速度,把注意力放在新的主题上,以免浪费你们的时间。我们开始吧!
# 步骤15 - 更新类别
为了更新类别, 我们需要一个HTTP PUT端点。
我们必须实现的逻辑与POST非常相似:
- 首先, 我们必须使用
ModelState
来验证传入的请求数据; - 如果请求是有效的,API应该使用AutoMapper将传入的资源映射到模型类;
- 然后,我们需要调用我们的服务,告诉它更新类别,提供相应的类别
Id
和更新后的数据; - 如果数据库中没有具有给定
Id
的类别,则返回一个错误请求。我们可以使用NotFound
结果,但这对于这个范围来说并不重要,因为我们向客户应用程序提供了一条错误消息; - 如果保存逻辑正确执行,服务必须返回一个包含更新的类别数据的响应。如果没有,它应该给我们一个过程失败的指示,并告诉我们原因;
- 最后,如果出现错误,API将返回一个错误请求。如果没有,它将更新后的类别模型映射到类别资源,并向客户应用程序返回一个成功响应。
让我们将新的PutAsync
方法添加到控制器类中:
[HttpPut("{id}")]
public async Task<IActionResult> PutAsync(int id, [FromBody] SaveCategoryResource resource)
{
if (!ModelState.IsValid)
return BadRequest(ModelState.GetErrorMessages());
var category = _mapper.Map<SaveCategoryResource, Category>(resource);
var result = await _categoryService.UpdateAsync(id, category);
if (!result.Success)
return BadRequest(result.Message);
var categoryResource = _mapper.Map<Category, CategoryResource>(result.Category);
return Ok(categoryResource);
}
如果你将它与POST逻辑进行比较,你会注意到这里只有一个区别:HttPut
属性指定了给定路由应该接收的参数。
我们将调用此端点,并指定类别Id
作为最后一个URL片段,如/api/categories/1
。ASP.NET核心管道将这个片段解析为同名的参数。
现在我们必须在ICategoryService
接口中定义UpdateAsync
方法签名:
public interface ICategoryService
{
Task<IEnumerable<Category>> ListAsync();
Task<SaveCategoryResponse> SaveAsync(Category category);
Task<SaveCategoryResponse> UpdateAsync(int id, Category category);
}
现在让我们转到真正的逻辑。
# 步骤16 - 更新逻辑
要更新类别,首先需要从数据库中返回当前数据(如果存在的话)。我们还需要将它更新到我们的DBSet<>
。
让我们向ICategoryService
接口添加两个新方法:
public interface ICategoryRepository
{
Task<IEnumerable<Category>> ListAsync();
Task AddAsync(Category category);
Task<Category> FindByIdAsync(int id);
void Update(Category category);
}
我们已经定义了FindByIdAsync
方法,它将异步地从数据库返回一个类别,并定义了Update
方法。注意,Update
方法不是异步的,因为EF Core API不需要异步方法来更新模型。
现在让我们将真正的逻辑实现到CategoryRepository
类中:
public async Task<Category> FindByIdAsync(int id)
{
return await _context.Categories.FindAsync(id);
}
public void Update(Category category)
{
_context.Categories.Update(category);
}
最后我们可以进行编码服务逻辑:
public async Task<SaveCategoryResponse> UpdateAsync(int id, Category category)
{
var existingCategory = await _categoryRepository.FindByIdAsync(id);
if (existingCategory == null)
return new SaveCategoryResponse("Category not found.");
existingCategory.Name = category.Name;
try
{
_categoryRepository.Update(existingCategory);
await _unitOfWork.CompleteAsync();
return new SaveCategoryResponse(existingCategory);
}
catch (Exception ex)
{
// Do some logging stuff
return new SaveCategoryResponse($"An error occurred when updating the category: {ex.Message}");
}
}
API尝试从数据库获取类别。如果结果为 null
,则返回一个响应,说明类别不存在。如果类别存在,我们需要设置它的新名称。
然后,API尝试保存更改,就像创建新类别时一样。如果流程完成,服务将返回一个成功响应。如果不是,则执行日志逻辑,端点接收包含错误消息的响应。
现在我们来测试一下。首先,让我们添加一个新的类别来拥有一个可用的有效Id
。我们可以使用我们在数据库中添加的类别的标识符,但是我想这样做是为了向你展示我们的API将更新正确的资源。
再次运行应用程序,使用Postman向数据库POST一个新类别:
有了有效的Id
之后,将POST
选项改为PUT
到选择框中,并在URL的末尾添加Id值。将name
属性更改为另一个名称,并发送请求检查结果:
你可以发送一个GET请求到API端点,以确保你正确地编辑了类别名:
对于类别,我们必须实现的最后一个操作是排除类别。让我们创建一个HTTP Delete端点。
# 步骤17 - 删除类别
删除类别的逻辑非常容易实现,因为我们需要的大多数方法都是以前构建的。
以下是我们路由工作的必要步骤:
API需要调用我们的服务,告诉它删除我们的类别,并提供相应的
Id
;如果数据库中没有具有给定ID的类别,则服务应返回一条指示它的消息;
如果执行删除逻辑没有问题,则服务应该返回一个包含已删除类别数据的响应。如果没有,它应该给我们一个进程失败的指示,以及一个潜在的错误消息;
最后,如果出现错误,API将返回一个错误请求。如果没有,API将更新后的类别映射到资源,并向客户端返回一个成功响应。
让我们开始添加新的端点逻辑:
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var result = await _categoryService.DeleteAsync(id);
if (!result.Success)
return BadRequest(result.Message);
var categoryResource = _mapper.Map<Category, CategoryResource>(result.Category);
return Ok(categoryResource);
}
HttpDelete
属性还定义了一个id
模板。
在将DeleteAsync
签名添加到ICategoryService
接口之前,我们需要进行一个小的重构。
新的服务方法必须返回包含类别数据的响应,这与我们对PostAsync
和UpdateAsync
方法所做的相同。我们可以为此重用SaveCategoryResponse
,但是在本例中我们不保存数据。
为了避免创建具有相同形状的新类来交付这个需求,我们可以简单地将SaveCategoryResponse
重命名为CategoryResponse
。
如果你使用的是Visual Studio Code,你可以打开SaveCategoryResponse
类,将鼠标光标放在类名的上方,并使用选项Change All Occurrences
来重命名类:
一定要重新命名文件名。
让我们将DeleteAsync
方法签名添加到ICategoryService
接口:
public interface ICategoryService
{
Task<IEnumerable<Category>> ListAsync();
Task<CategoryResponse> SaveAsync(Category category);
Task<CategoryResponse> UpdateAsync(int id, Category category);
Task<CategoryResponse> DeleteAsync(int id);
}
在实现删除逻辑之前,我们需要在存储库中添加一个新方法。
将Remove
方法签名添加到ICategoryRepository
接口:
void Remove(Category category);
现在在repository类中添加真正的实现:
public void Remove(Category category)
{
_context.Categories.Remove(category);
}
EF Core需要将模型的实例传递给Remove
方法来正确地理解我们要删除的模型,而不是简单地传递一个Id
。
最后,我们来实现CategoryService
类的逻辑:
public async Task<CategoryResponse> DeleteAsync(int id)
{
var existingCategory = await _categoryRepository.FindByIdAsync(id);
if (existingCategory == null)
return new CategoryResponse("Category not found.");
try
{
_categoryRepository.Remove(existingCategory);
await _unitOfWork.CompleteAsync();
return new CategoryResponse(existingCategory);
}
catch (Exception ex)
{
// Do some logging stuff
return new CategoryResponse($"An error occurred when deleting the category: {ex.Message}");
}
}
这没什么新鲜的。该服务尝试通过ID查找类别,然后调用存储库来删除类别。最后,工作单元完成对数据库执行实际操作的事务。
”-嘿,那每个类别的产品呢?难道你不需要先创建一个存储库并删除产品,以避免错误吗?”
答案是否。由于EF Core 跟踪机制,当我们从数据库加载一个模型时,框架知道该模型具有哪些关系。如果我们删除它,EF Core知道它应该先递归地删除所有相关的模型。
我们可以在将类映射到数据库表时禁用此功能,但这超出了本教程的范围。如果你想了解这个特性,请查看这里。
现在是测试新端点的时候了。再次运行应用程序,并发送删除请求使用Postman如下:
我们可以通过发送一个GET请求来检查我们的API是否正确工作:
我们已经完成了categories API。现在该转向products API了。
# 步骤18 - 产品的API
到目前为止,你已经学习了如何使用ASP.NET Core实现所有基本的HTTP动词来处理CRUD操作。让我们进入实现产品API的下一层。
我不会再详细说明所有HTTP动词,因为它是详尽的。对于本教程的最后一部分,我将只讨论GET请求,以向你展示如何在查询数据库中的数据时包含相关实体,以及如何使用为EUnitOfMeasurement
枚举值定义的Description
属性。
在Controllers
文件夹中添加一个名为ProductsController
的新控制器。
在编写任何代码之前,我们必须创建产品资源。
让我刷新你的记忆,再次显示我们的资源应该是什么样子的:
{
[
{
"id": 1,
"name": "Sugar",
"quantityInPackage": 1,
"unitOfMeasurement": "KG"
"category": {
"id": 3,
"name": "Sugar"
}
},
… // Other products
]
}
我们需要一个包含数据库中所有产品的JSON数组。
JSON数据与产品模型有两个不同之处:
- 计量单位以较短的方式显示,只显示其缩写;
- 我们输出类别数据,但不包括
CategoryId
属性。
为了表示度量单位,我们可以使用一个简单的字符串属性来代替enum类型(顺便说一下,JSON数据没有默认的enum类型,所以我们必须将其转换为另一种类型)。
现在我们已经知道了如何塑造新资源,让我们来创建它。在Resources
文件夹中添加一个新的类ProductResource
:
现在,我们必须配置模型类和新资源类之间的映射。
映射配置将与用于其他映射的配置几乎相同,但是这里我们必须处理将 EUnitOfMeasurement
枚举转换为字符串。
还记得在枚举类型上应用的StringValue
属性吗? 现在,我将向你展示如何使用. net框架的一个强大特性:反射API来提取这些信息。
反射API是一组强大的资源,它允许我们提取和操作元数据。很多框架和库(包括ASP.NET Core自身利用这些资源在幕后处理许多事情。
现在让我们看看它在实践中是如何工作的。在Extensions
文件夹中添加一个名为EnumExtensions
的新类。
第一次看到代码时,你可能会感到害怕,但它并没有那么复杂。让我们分解一下代码定义来理解它是如何工作的。
首先,我们定义了一个泛型方法(该方法可以接收不止一种类型的参数,在本例中,由TEnum
声明表示),该方法接收一个给定的enum作为参数。
因为enum
是C#中的一个保留关键字,所以我们在参数名前面添加了一个@,以使它成为一个有效的名称。
此方法的第一个执行步骤是使用GetType
方法获取参数的类型信息(类、接口、枚举或结构定义)。
然后,该方法使用GetField(@enum.ToString())
获取特定的枚举值(例如,kg)。
下一行查找在枚举值上应用的所有Description
属性,并将它们的数据存储到一个数组中(在某些情况下,我们可以为同一属性指定多个属性)。
最后一行使用较短的语法检查枚举类型是否至少有一个description属性。如果有,则返回此属性提供的Description
值。如果没有,则使用默认的强制转换将枚举作为字符串返回。
?.
操作符(空条件操作符)在访问属性之前检查值是否为空。
的??
运算符(null-coalescing运算符)告诉应用程序,如果左边的值不为空,则返回左边的值,否则返回右边的值。
现在我们有了一个提取描述的扩展方法,让我们来配置模型和资源之间的映射。多亏了AutoMapper,我们只需要多一行就可以做到这一点。
打开ModelToResourceProfile
类,并通过以下方式更改代码:
这个语法告诉AutoMapper使用新的扩展方法将我们的EUnitOfMeasurement
值转换成一个包含它的描述的字符串。简单,是吧?你可以阅读官方文档以了解完整的语法。
注意,我们没有为category属性定义任何映射配置。因为我们之前为类别配置了映射,而且产品模型具有相同类型和名称的类别属性,AutoMapper隐式地知道应该使用相应的配置来映射它。
现在让我们添加端点代码。更改ProductsController
代码:
基本上,为categories控制器定义了相同的结构。
让我们进入服务部分。在Domain
层的Services
文件夹中添加一个新的IProductService
接口:
在真正实现新服务之前,你应该意识到我们需要一个存储库。
在相应的文件夹中添加一个名为IProductRepository
的新接口:
现在让我们实现存储库。除了在查询数据时需要返回每个产品的相应类别数据之外,我们必须以几乎与实现类别存储库相同的方式实现它。
默认情况下,当你查询数据时,EF Core 不会将相关实体包括到你的模型中,因为它可能非常慢(想象一个有10个相关实体的模型,所有的相关实体都有自己的关系)。
为了包含类别数据,我们只需要多一行:
注意Include(p => p. category)
的调用。在查询数据时,我们可以链接此语法以包含尽可能多的实体。当执行select时,EF Core将把它转换成一个join。
现在我们可以实现ProductService
类,就像我们实现categories一样:
让我们绑定新的依赖关系,改变Startup
类:
最后,在测试API之前,让我们更改AppDbContext类,在初始化应用程序时包含一些产品,这样我们可以看到结果:
在初始化应用程序时,我添加了两个虚构的产品,将它们关联到我们创建的类别。
考验时间!再次运行API,使用Postman向 /api/products
送GET请求:
就是它!恭喜你!
现在你已经了解了如何使用ASP.NET Core构建一个解耦的RESTful API架构。你学习了.Net Core框架的许多内容,如何使用C#, EF Core和AutoMapper的基础知识,以及在设计应用程序时可以使用的许多有用的模式。
你可以检查API的完整实现,包含产品的其他HTTP动词,检查Github仓库:
# 结语
ASP.NET Core是创建web应用程序时使用的一个很好的框架。它提供了许多有用的api,你可以使用它们来构建干净的、可维护的应用程序。在创建专业应用程序时,可以考虑使用它。
本文并没有涉及专业API的所有方面,但是你已经了解了所有的基础知识。你还学习了许多有用的模式来解决我们每天面对的模式。
我希望你喜欢这篇文章,我希望它对你有用。感谢你的反馈,以了解我如何改进这一点。