构建RESTful API.md 88 KB

一步一步的构建整洁、可维护的 RESTful APIs

总览

RESTful 不是一个新名词。它指的是一种架构风格,在这种风格下,Web 服务从客户端应用程序接收和发送数据,并向客户端应用程序发送数据。这些应用程序的目标是集中不同客户端应用程序将使用的数据。

选择合适的工具来编写 RESTful 服务是至关重要的,因为我们需要关心可扩展性、维护、文档和所有其他相关方面。ASP.NET Core 为我们提供了一个强大的、易于使用的 API,很好地实现了这些目标。

在这篇文章中,我将向你展示如何使用 ASP.NET Core 框架,为 "几乎 "的现实世界场景编写一个结构良好的 RESTful API。我将详细介绍常见的模式和策略,以简化开发过程。

我还将向您展示如何集成常见的框架和库,如 Entity Framework Core 和 AutoMapper,以提供必要的功能。

先决条件

我希望你有面向对象编程概念的知识。

尽管我将会涉及到 C#编程语言的很多细节,但我还是建议大家有这方面的基础知识。

我还假设你知道什么是 REST,HTTP 协议是如何工作的,什么是 API 端点,什么是 JSON。这里有一个很好的关于这个主题的入门教程。最后的要求是,你要了解关系型数据库的工作原理。

要想和我一起编码,你必须安装.NET Core,以及我将要用来测试 API 的工具 Postman。我建议你使用一个代码编辑器,如 Visual Studio Code 来开发 API。选择你喜欢的代码编辑器。如果你选择这个代码编辑器,我建议你安装 C#扩展,以便有更好的代码高亮。

你可以在文章最后找到 API 的 Github 仓库的链接,查看最终结果。

范围

让我们为一家超市写一个虚构的 Web API。让我们想象一下,我们必须实现以下范围。

  • 创建一个 RESTful 服务,允许客户端应用程序管理超市的产品目录。它需要暴露端点来创建、读取、编辑和删除产品类别,如乳制品和化妆品,并管理这些类别的产品。
  • 对于类别,我们需要存储它们的名称。对于产品,我们需要存储它们的名称、计量单位(例如,按重量计量的产品为 KG)、包装内的数量(例如,如果产品是一包饼干,则为 10)和它们各自的类别。

为了简化这个例子,我不会处理库存产品、产品运输、安全和任何其他功能。给定的范围足以向您展示 ASP.NET Core 的工作方式。

要开发这个服务,我们基本上需要两个 API 端点:一个是管理品类,一个是管理产品。在 JSON 通信方面,我们可以认为响应如下。

API endpoint: /api/categories
JSON Response (for GET requests):
{
  [
    { "id": 1, "name": "Fruits and Vegetables" },
    { "id": 2, "name": "Breads" },
    … // Other categories
  ]
}
API endpoint: /api/products
JSON Response (for GET requests):
{
  [
    {
      "id": 1,
      "name": "Sugar",
      "quantityInPackage": 1,
      "unitOfMeasurement": "KG"
      "category": {
        "id": 3,
        "name": "Sugar"
      }
    },
    … // Other products
  ]
}

让我们开始编写应用程序。

第 1 步 - 创建 API

首先,我们必须为 Web 服务创建文件夹结构,然后我们必须使用.NET CLI 工具来构建一个基本的 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();
    }
}

当应用程序启动时,程序类的 Main 方法被调用。它使用启动配置创建一个默认的 web 主机,通过特定的端口(默认情况下,HTTP 为 5000 端口,HTTPS 为 5001 端口)通过 HTTP 暴露应用程序。

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 端点时,我会详细介绍每一个。现在,简单地删除这个类,因为我们不打算使用它。

第 2 步 - 创建领域模型

我将应用一些设计理念,使应用程序保持简单和易于维护。

编写能够被自己理解和维护的代码并没有这么难,但你要记住,你将作为团队的一部分工作。如果你不注意如何编写代码,结果将是一个让你和你的队友不断头疼的怪物。这听起来很极端,对吧?但相信我,这就是事实。

领域

衡量好代码的标准是 WTF 的频率。原图来自 smitty42,发表于 filckr。该图遵循 CC-BY-2.0。

让我们从编写领域层开始。这一层会有我们的模型类,代表我们的产品和类别的类,以及仓库和服务接口。我一会儿会解释这最后两个概念。

在 Supermarket.API 目录内,创建一个名为 Domain 的新文件夹。在新的 domain 文件夹里面,再创建一个叫做 Models 的文件夹。我们要添加到这个文件夹的第一个模型是 Category。最初,它将是一个简单的 Plain Old CLR Object(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 属性,用于识别类别,还有一个 Nameproperty。我们还有一个 Products 属性。最后一个属性将被 Entity Framework Core(大多数 ASP.NET Core 应用程序用来将数据持久化到数据库中的 ORM)使用,用来映射类别和产品之间的关系。从面向对象编程的角度考虑也是有意义的,因为一个类别有很多相关的产品。

我们还要创建产品模型。在同一个文件夹下,添加一个新的产品类。

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,它告诉我们一包产品有多少个单位(还记得应用范围的饼干例子吗),还有一个 UnitOfMeasurement 属性。这个用一个枚举类型来表示,它代表了一个可能的计量单位的枚举。最后两个属性 CategoryId 和 Category 将被 ORM 用来映射产品和类别之间的关系。它表示一个产品有一个,也只有一个类别。

让我们定义一下我们领域模型的最后一部分,EUnitOfMeasurement 枚举。

按照惯例,enums 的名字前面不需要以 "E "开头,但在一些库和框架中,你会发现这个前缀是区分 enums 与接口和类的一种方式。

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
    }
}

代码真的很简单。在这里,我们只定义了少数几种计量单位的可能性,然而,在一个真实的超市系统中,你可能会有很多其他的计量单位,也许还需要一个单独的模型来实现。

请注意应用在每个枚举可能性上的描述属性。属性是定义 C#语言的类、接口、属性和其他组件的元数据的一种方式。在本例中,我们将使用它来简化产品 API 端点的响应,但你现在不必关心它。我们以后再来这里。

我们的基本模型已经可以使用了。现在我们可以开始编写管理所有类别的 API 端点了。

第 3 步 - 分类 API

在 Controllers 文件夹中,添加一个名为 CategoriesController 的新类。

按照惯例,这个文件夹中所有后缀为 "Controller "的类都将成为我们应用程序的控制器。这意味着它们将处理请求和响应。你必须从 Controller 类继承这个类,它定义在命名空间 Microsoft.AspNetCore.Mvc 中。

一个命名空间由一组相关的类、接口、枚举和结构组成。你可以把它想象成类似于 Javascript 语言的模块,或者 Java 的包。

新的控制器应该通过路由/api/categories 进行响应。我们通过在类名上方添加 Route 属性,指定一个占位符来实现这一目的,该占位符表示该路由应该使用类名,而不使用控制器后缀,按照惯例。

using Microsoft.AspNetCore.Mvc;

namespace Supermarket.API.Controllers
{
    [Route("/api/[controller]")]
    public class CategoriesController : Controller
    {
    }
}

让我们开始处理 GET 请求。首先,当有人通过 GET 动词从/api/categories 请求数据时,API 需要返回所有类别。我们可以为此创建一个类别服务。

从概念上讲,服务基本上是一个定义了处理一些业务逻辑的方法的类或接口。在许多不同的编程语言中,创建服务来处理业务逻辑是一种常见的做法,例如认证和授权、支付、复杂的数据流、缓存和需要在其他服务或模型之间进行一些交互的任务。

使用服务,我们可以将请求和响应处理与完成任务所需的真实逻辑隔离开来。

我们最初要创建的服务将定义一个单一的行为,或方法:列表方法。我们希望这个方法能够返回数据库中所有现有的类别。

为了简单起见,我们在本例中不处理数据分页或过滤的问题。我将在未来写一篇文章来展示如何轻松处理这些功能。

为了在 C#中定义一个预期的行为(在其他面向对象语言中也是如此,例如 Java),我们定义了一个接口。一个接口告诉了某些东西应该如何工作,但并没有实现行为的真正逻辑。这些逻辑是在实现接口的类中实现的。如果这个概念对你来说还不清楚,不要担心。你一会儿就会明白的。

在 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 方法的实现必须异步返回一个类别的枚举。

任务类,封装了返回,表示异步。我们需要用异步方法来思考,由于我们要等待数据库完成一些操作来返回数据,而这个过程可能需要一段时间。还要注意 "async "的后缀。这是一个惯例,表示我们的方法应该是异步执行的。

我们有很多约定俗成的东西,对吧?我个人很喜欢,因为它能让应用程序易于阅读,即使你是一个使用.NET 技术的新公司。

"--好吧,我们定义了这个接口,但它什么都不做。它怎么会有用呢?"

如果你来自 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 请求(这个属性可以省略,但为了更容易辨认,最好写出来)。

该方法使用我们的类别服务实例来列出所有类别,然后将类别返回给客户端。框架流水线处理数据序列化为 JSON 对象。IEnumerable<Category>类型告诉框架,我们要返回一个类别的枚举,而 Task 类型,前面是 async 关键字,告诉管道这个方法应该异步执行。最后,当我们定义一个异步方法时,对于可能需要一段时间的任务,我们必须使用 await 关键字。

好了,我们定义了我们 API 的初始结构。现在,有必要真正实现分类服务。

第 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 步--分 Categories 储仓和持久层

在 Domain 文件夹内,创建一个名为 Repositories 的新目录,然后,添加一个名为 ICategoryRespository 的新接口。然后,添加一个名为 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 还允许我们先设计我们的应用程序,然后根据我们在代码中定义的内容生成数据库。这种技术叫做代码优先。我们将使用代码优先的方法来生成数据库(在这个例子中,事实上,我将使用一个内存数据库,但你将能够很容易地改变它,例如 SQL Server 或 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 属性。这些属性是将模型映射到数据库表的集合(唯一对象的集合)。

另外,我们还要将模型的属性映射到相应的表列上,指定哪些属性是主键,哪些是外键,列的类型等。我们可以通过重载 OnModelCreating 方法来完成,使用一个叫做 Fluent API 的功能来指定数据库映射。

修改 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)。

还有一种配置是通过 HasData 方法设置种子数据:

builder.Entity<Category>().HasData
(
  new Category { Id = 100, Name = "Fruits and Vegetables" },
  new Category { Id = 101, Name = "Dairy" }
);

这里我们只是默认添加了两个示例类别。这对于我们完成 API 端点后的测试是必要的。

注意:我们在这里手动设置 Id 属性,因为内存中的提供者要求它工作。我将标识符设置为大数,以避免自动生成的标识符和种子数据之间发生碰撞。 这种限制在真正的关系型数据库提供者中并不存在,所以如果你想使用 SQL Server 等数据库,你不必指定这些标识符。

在实现了数据库上下文类之后,我们就可以实现 categories 仓库了。在 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 数据库集来访问 category 表,然后调用扩展方法 ToListAsync,该方法负责将查询结果转化为类别的集合。

EF Core 将我们的方法调用转化为 SQL 查询,这是最有效的方式。只有当你调用一个方法,将你的数据转化为一个集合,或者当你使用一个方法来获取特定的数据时,才会执行查询。

我们现在有了一个干净的 categories 控制器、服务和存储库的实现。

我们把关注点分开,创建了只做自己应该做的事情的类。

在测试应用程序之前的最后一步是使用 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 实例作为构造参数的类时,都应该使用该类的同一个实例。如果内存中没有实例,管道将创建一个新的实例,并在给定的请求中,在所有需要它的类中重复使用它。这样,当你需要使用类实例时,你就不需要手动创建它。

还有其他的终身制范围,你可以阅读官方文档查看。

依赖注入技术给我们带来了很多优势,比如:

  • 代码的可重用性。
  • 更好的生产力,因为当我们必须改变实现时,我们不需要费心去改变一百个你使用该功能的地方。
  • 你可以很容易地测试应用程序,因为我们可以使用 mocks(类的假实现)隔离我们要测试的东西,我们必须将接口作为构造函数参数传递。
  • 当一个类需要通过构造函数来接收更多的依赖关系时,你不必手动改变所有创建实例的地方(这很赞!)。

配置完数据库上下文后,我们还要将服务和仓库绑定到相应的类中。

services.AddScoped<ICategoryRepository, CategoryRepository>();
services.AddScoped<ICategoryService, CategoryService>();

在这里,我们也使用了一个范围化的寿命,因为这些类内部必须使用数据库上下文类。在这种情况下,指定相同的作用域是有意义的。

现在我们配置了我们的依赖绑定,我们必须在 Program 类上做一个小改动,以使数据库正确地播种我们的初始数据。这一步只有在使用内存数据库提供者时才需要(请看这个 Github 问题来了解原因)。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Supermarket.API.Persistence.Contexts;

namespace Supermarket.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var host = BuildWebHost(args);

            using(var scope = host.Services.CreateScope())
            using(var context = scope.ServiceProvider.GetService<AppDbContext>())
            {
                context.Database.EnsureCreated();
            }

            host.Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .Build();
    }
}

由于我们使用的是内存提供者,所以有必要改变 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 端点的规范,你已经注意到我们实际的 JSON 响应有一个额外的属性:产品的数组。请看一下所需响应的例子:

{
  [
    { "id": 1, "name": "Fruits and Vegetables" },
    { "id": 2, "name": "Breads" },
    … // Other categories
  ]
}

产品数组存在于我们当前的 JSON 响应中,因为我们的 Category 模型有一个 Products 属性,EF Core 需要这个属性来正确映射一个给定类别的产品。

我们不希望在我们的响应中出现这个属性,但是我们不能改变我们的模型类来排除这个属性。这将导致 EF Core 在我们试图管理类别数据时抛出错误,而且还会打破我们的领域模型设计,因为没有产品的产品类别是没有意义的。

为了返回只包含超市类别的标识符和名称的 JSON 数据,我们必须创建一个资源类。

资源类是一个只包含基本信息的类,这些信息将在客户端应用程序和 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 类之间创建一个映射。由于这两个类的属性具有相同的名称和类型,我们不必为它们使用任何特殊的配置。

最后一步包括改变类别控制器,使用 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)。

依赖注入使您的应用程序可维护且易于更改,因为您不必打破所有的代码实现来添加或删除功能。

你可能意识到,不仅是 controller 类,所有接收依赖关系的类(包括依赖关系本身)都根据绑定配置自动解析为接收正确的类。

依赖性注入很神奇,不是吗?

现在,再次使用 dotnet 运行命令启动 API,并前往 http://localhost:5000/api/categories,查看新的 JSON 响应。

Json

我们已经有了 GET 端点。现在,让我们创建一个新的端点来 POST(创建)类别。

第 9 步 - 创建新的类别

在处理资源创造的时候,我们要关心很多事情,比如说:

  • 数据验证和数据完整性。
  • 创建资源的授权。
  • 错误处理。
  • 日志记录。

我不会在本教程中展示如何处理认证和授权,但你可以看到如何轻松实现这些功能,阅读我的 JSON web token 认证教程。

另外,有一个非常流行的框架叫做 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 属性。这些属性被称为数据注释。ASP.NET Core 管道使用这些元数据来验证请求和响应。正如名称所示,类别名称是必填的,最大长度为 30 个字符。

现在让我们定义新 API 端点的形状。在 category 控制器中添加以下代码。

[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] SaveCategoryResource resource)
{
}

我们使用 HttpPost 属性告诉框架这是一个 HTTP POST 端点。

注意这个方法的响应类型,Task<IActionResult>。存在于控制器类中的方法被称为动作,它们具有这种签名,因为我们可以在应用程序执行动作后返回多个可能的结果。

在这种情况下,如果类别名称无效,或者出了问题,我们必须返回一个 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#编译器将其视为扩展方法。结果是我们可以像正常的方法一样调用这个类的方法,因为我们在要使用扩展的地方包含了各自的 usingdirective。

该扩展使用 LINQ 查询,这是.NET 的一个非常有用的功能,它允许我们使用可链式表达式来查询和转换数据。这里的表达式将验证错误方法转化为包含错误信息的字符串列表。

在进行下一步之前,先将命名空间 Supermarket.API.Extensions 导入到分类控制器中。

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 属性,Success 属性将告诉我们请求是否成功完成,Message 属性将在失败时提供错误信息。

注意这些属性是必须的,只有继承类才能设置这些数据,因为子类必须通过构造函数传递这些信息。

提示:为所有的东西定义基类并不是一个好的做法,因为基类耦合了你的代码,防止你轻易修改它。更倾向于使用组成而不是继承。 对于这个 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--Keep it Simple, Stupid 的缩写。基本上,它说你应该让你的应用程序尽可能的简单。

在设计你的应用程序时,请记住这一点:只应用你需要解决的问题。不要过度设计你的应用程序。

现在我们可以完成我们的端点逻辑了:

[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 步--数据库逻辑和工作单元模式

由于我们要将数据持久化到数据库中,我们需要在我们的仓库中使用一个新的方法。

为 ICategoryRepository 接口添加一个新的 AddAsync 方法:


public interface ICategoryRepository
{
    Task<IEnumerable<Category>> ListAsync();
    Task AddAsync(Category category);
}

现在,让我们在真正的仓库类中实现这个方法:

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),你一般可以。

  • 向其中添加新的项目(就像你向列表、数组和字典推送数据时一样)。
  • 查找或过滤项目。
  • 从集合中删除一个项目。
  • 替换一个给定的项目,或者更新它。

想一想现实世界中的清单。想象一下,你正在写一张购物清单,要去超市买东西(真是太巧了,不是吗?)

在列表中,你写下你需要购买的所有水果。你可以将水果添加到这个列表中,如果你放弃购买,可以将水果删除,也可以替换水果的名字。但是你不能把水果保存到列表中。这样的事情用简单的英语说是没有意义的。

提示:在面向对象的编程语言中设计类和接口时,尽量使用自然语言来检查你所做的事情看起来是否正确。 比如说,说一个人实现了一个人的接口是有意义的,但是说一个人实现了一个账号就没有意义了。

如果你想 "保存 "水果清单(在这种情况下,购买所有水果),你支付它,超市处理库存数据,以检查他们是否需要从供应商那里购买更多的水果。

编程时也可以采用同样的逻辑。存储库不应该保存、更新或删除数据。相反,它们应该将其委托给一个不同的类来处理这个逻辑。

直接将数据保存到存储库中还有一个问题:不能使用事务。

想象一下,我们的应用程序有一个日志机制,存储一些用户名和每次对 API 数据进行更改时执行的操作。

现在想象一下,由于某种原因,你有一个更新用户名的服务调用(这不是一个常见的情况,但我们考虑一下)。

你同意要改变虚构用户表中的用户名,首先要更新所有的日志,以正确地告诉谁执行了该操作,对吗?

现在想象一下,我们已经在不同的仓库中实现了用户和日志的更新方法,并且它们都调用了 SaveChanges。如果这些方法中的一个方法在更新过程中失败了,会发生什么?你最终会出现数据不一致的情况。

我们应该在所有事情完成后才将我们的修改保存到数据库中。要做到这一点,我们必须使用事务,这基本上是大多数数据库实现的一个功能,只有在复杂的操作完成后才保存数据。

"--好吧,如果我们在这里救不了东西,我们应该在哪里救?"

处理这个问题的一个常见模式是工作单元模式(UOF)。这种模式由一个类组成,该类接收我们的 AppDbContext 实例作为依赖,并暴露出启动、完成或中止事务的方法。

我们将在这里使用一个简单的工作单元的实现来处理我们的问题。

在 Domain 层的 Repositories 文件夹里面添加一个新的接口,叫做 IUnitOfWork。

using System.Threading.Tasks;

namespace Supermarket.API.Domain.Repositories
{
    public interface IUnitOfWork
    {
         Task CompleteAsync();
    }
}

如你所见,它只暴露了一个将异步完成数据管理操作的方法。

现在我们来添加真正的实现。

在持久层的 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 查询。如果你把查询逻辑和你的服务耦合在一起,就很难改变逻辑,因为你必须在很多类中进行。

使用仓库模式,你可以简单地实现一个新的仓库类,并使用依赖注入来绑定它。

所以,基本上,如果你直接使用 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 会调用一些虚构的日志服务,并返回一个表示失败的响应。

如果这个过程没有问题地完成,应用程序就会返回一个成功的响应,发送我们的类别数据。很简单,对吧?

提示:在实际应用中,你不应该把所有的东西都包在一个通用的 try-catch 块里面,而是应该单独处理所有可能的错误。 简单地添加一个 try-catch 块并不能覆盖大多数可能的失败情况。一定要正确实现错误处理。

在测试我们的 API 之前的最后一步是将工作单元的接口绑定到相应的类上。

在 Startup 类的 ConfigureServices 方法中添加这一行新内容:

services.AddScoped<IUnitOfWork, UnitOfWork>();

现在让我们来测试一下吧!

第 14 步 - 使用 Postman 测试我们的 POST 端点

再次使用 dotnet run 启动我们的应用程序。

我们不能使用浏览器来测试 POST 端点。让我们使用 Postman 来测试我们的端点。这是一个非常有用的测试 RESTful API 的工具。

打开 Postman,关闭介绍信息。你会看到一个像这样的屏幕。 PostMan

在选择框中把默认选择的 GET 改为 POST。

在输入请求 URL 字段中输入 API 地址。

我们必须提供请求体数据,以发送给我们的 API。点击 Body 菜单项,然后将其下方显示的选项改为 raw。

Postman 会在右边显示一个 Text 选项。将其改为 JSON(application/json),然后在下面粘贴以下 JSON 数据。

{
  "name": ""
}

PoatMan

正如你所看到的,我们将向我们的新端点发送一个空的名称字符串。

点击发送按钮。你会收到这样的输出:

PastMan

你还记得我们为端点创建的验证逻辑吗?这个输出就是它工作的证明!

还请注意右边显示的 400 状态码。BadRequest 的结果会自动将这个状态码添加到响应中。

现在让我们把 JSON 数据改为有效的数据,看看新的响应。

PoatMan

API 正确地创建了我们的新资源。

到现在为止,我们的 API 可以列出和创建类别。你学到了很多关于 C#语言、ASP.NET 核心框架的东西,也学到了构建你的 API 的常见设计方法。

让我们继续我们的类别 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 Core 流水线将这个片段解析为同名的参数。

现在我们要把 UpdateAsync 方法签名定义到 ICategoryService 接口中。

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 到数据库中:

postman

手中有一个有效的 Id,将 POST 选项改为 PUT 进入选择框,并在 URL 的最后添加 ID 值。将 name 属性改为其他名称,发送请求检查结果。

PastMan

你可以向 API 端点发送一个 GET 请求,以确保你正确编辑了类别名称。

PostMan

最后我们要对类别实现的操作是排除类别。我们来创建一个 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 来重命名这个类。

API

也一定要重命名文件名。

让我们将 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);
}

在实现删除逻辑之前,我们需要在仓库中新建一个方法。

在 ICategoryRepository 接口中添加 Remove 方法签名:

void Remove(Category category);

而现在在仓库类上添加真正的实现:


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 发送一个 DELETE 请求,如下所示。

PostMan

我们可以通过发送一个 GET 请求来检查我们的 API 是否正常工作:

PostMan

我们已经完成了 categories API。现在是时候转向 products API 了。

第 18 步 - 产品 API

到目前为止,您已经学会了如何实现所有基本的 HTTP 动词来处理 ASP.NET Core 的 CRUD 操作。让我们进入下一个层次,实现我们的产品 API。

我不会再详细介绍所有的 HTTP 动词,因为这将是详尽的。在本教程的最后一部分,我将只涉及 GET 请求,以向您展示如何在从数据库中查询数据时包含相关实体,以及如何使用我们为 EUnitOfMeasurement 枚举值定义的描述属性。

在 Controllers 文件夹中添加一个名为 ProductsController 的新控制器。

在这里进行任何编码之前,我们必须创建产品资源。

让我再次刷新你的记忆,展示我们的资源应该是怎样的:

{
 [
  {
   "id": 1,
   "name": "Sugar",
   "quantityInPackage": 1,
   "unitOfMeasurement": "KG"
   "category": {
   "id": 3,
   "name": "Sugar"
   }
  },
  … // Other products
 ]
}

我们想要一个 JSON 数组,其中包含数据库中的所有产品。

JSON 数据与产品模型有两点不同:

  • 计量单位的显示方式较短,只显示其缩写。
  • 我们输出类别数据时,不包括 CategoryId 属性。

为了表示测量单位,我们可以使用一个简单的字符串属性来代替枚举类型(顺便说一下,我们没有 JSON 数据的默认枚举类型,所以我们必须将其转换为不同的类型)。

现在我们现在如何塑造新的资源,让我们创建它。在 Resources 文件夹中添加一个新类 ProductResource。


namespace Supermarket.API.Resources
{
    public class ProductResource
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int QuantityInPackage { get; set; }
        public string UnitOfMeasurement { get; set; }
        public CategoryResource Category {get;set;}
    }
}

现在我们必须配置模型类和新资源类之间的映射。

映射配置将与其他映射的配置几乎相同,但在这里我们必须处理我们的 EUnitOfMeasurement 枚举到字符串的转换。

你还记得应用在枚举类型上的 StringValue 属性吗?现在我将向你展示如何使用.NET 框架的一个强大功能:反射 API 来提取这些信息。

Reflection API 是一个强大的资源集,它允许我们提取和操作元数据。很多框架和库(包括 ASP.NET Core 本身)都利用这些资源在幕后处理很多事情。

现在让我们来看看它在实际中是如何工作的。在 Extensions 文件夹中添加一个名为 EnumExtensions 的新类.

using System.ComponentModel;
using System.Reflection;

namespace Supermarket.API.Extensions
{
    public static class EnumExtensions
    {
        public static string ToDescriptionString<TEnum>(this TEnum @enum)
        {
            FieldInfo info = @enum.GetType().GetField(@enum.ToString());
            var attributes = (DescriptionAttribute[])info.GetCustomAttributes(typeof(DescriptionAttribute), false);

            return attributes?[0].Description ?? @enum.ToString();
        }
    }
}

第一次看代码的时候可能会觉得很吓人,但其实没那么复杂。让我们分解一下代码定义,了解它是如何工作的。

首先,我们定义了一个通用方法(一个可以接收多种类型参数的方法,在本例中,由 TEnum 声明表示),它接收一个给定的 enum 作为参数。

由于 enum 在 C#中是一个保留关键字,所以我们在参数的名称前面加了一个@,使其成为一个有效的名称。

该方法的第一个执行步骤是使用 GetType 方法获取参数的类型信息(类、接口、枚举或 struct 定义)。

然后,该方法使用 GetField(@enum.ToString())获取具体的枚举值(例如,Kilogram)。

下一行找到应用在枚举值上的所有描述属性,并将它们的数据存储到一个数组中(在某些情况下,我们可以为同一个属性指定多个属性)。

最后一行使用较短的语法来检查我们是否有至少一个枚举类型的描述属性。如果有,我们返回由这个属性提供的描述值。如果没有,我们使用默认的铸造方式将枚举返回为一个字符串。

操作符(空条件操作符)在访问它的属性之前检查值是否为空。

运算符(一个空凝聚运算符)告诉应用程序,如果它不是空的,就返回左边的值,否则就返回右边的值。

现在我们有了一个提取描述的扩展方法,让我们配置一下模型和资源之间的映射。多亏了 AutoMapper,我们只需要多写一行就可以完成。

打开 ModelToResourceProfile 类,然后这样修改代码:

using AutoMapper;
using Supermarket.API.Domain.Models;
using Supermarket.API.Extensions;
using Supermarket.API.Resources;

namespace Supermarket.API.Mapping
{
    public class ModelToResourceProfile : Profile
    {
        public ModelToResourceProfile()
        {
            CreateMap<Category, CategoryResource>();

            CreateMap<Product, ProductResource>()
                .ForMember(src => src.UnitOfMeasurement,
                           opt => opt.MapFrom(src => src.UnitOfMeasurement.ToDescriptionString()));
        }
    }
}

这个语法告诉 AutoMapper 使用新的扩展方法将我们的 EUnitOfMeasurement 值转换为包含其描述的字符串。很简单,对吧?您可以阅读官方文档来了解完整的语法。

注意我们没有为 category 属性定义任何映射配置。因为我们之前为类别配置了映射,而且因为产品模型有一个相同类型和名称的类别属性,AutoMapper 隐含地知道它应该使用相应的配置来映射它。

现在我们来添加端点代码。修改 ProductsController 的代码:

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 ProductsController : Controller
    {
        private readonly IProductService _productService;
        private readonly IMapper _mapper;

        public ProductsController(IProductService productService, IMapper mapper)
        {
            _productService = productService;
            _mapper = mapper;
        }

        [HttpGet]
        public async Task<IEnumerable<ProductResource>> ListAsync()
        {
            var products = await _productService.ListAsync();
            var resources = _mapper.Map<IEnumerable<Product>, IEnumerable<ProductResource>>(products);
            return resources;
        }
    }
}

基本上,与类别控制器定义的结构相同。

我们再来看看服务部分。在 Domain 层存在的 Services 文件夹中添加一个新的 IProductService 接口。

using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;

namespace Supermarket.API.Domain.Services
{
    public interface IProductService
    {
         Task<IEnumerable<Product>> ListAsync();
    }
}

在真正实现新服务之前,你应该已经意识到我们需要一个仓库。

在相应的文件夹中添加一个名为 IProductRepository 的新接口。

using System.Collections.Generic;
using System.Threading.Tasks;
using Supermarket.API.Domain.Models;

namespace Supermarket.API.Domain.Repositories
{
    public interface IProductRepository
    {
         Task<IEnumerable<Product>> ListAsync();
    }
}

现在我们来实现仓库。我们要实现它的方式几乎和类别库一样,只是在查询数据的时候,我们需要返回每个产品各自的类别数据。

EF Core 在默认情况下,在你查询数据的时候,并不会将相关实体包含到你的模型中,因为这可能会非常慢(想象一个有十个相关实体的模型,所有的相关实体都有自己的关系)。

如果要包含类别数据,我们只需要多写一行:

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 ProductRepository : BaseRepository, IProductRepository
    {
        public ProductRepository(AppDbContext context) : base(context)
        {
        }

        public async Task<IEnumerable<Product>> ListAsync()
        {
            return await _context.Products.Include(p => p.Category)
                                          .ToListAsync();
        }
    }
}

请注意对 Include(p => p.Category)的调用。在查询数据时,我们可以连锁这个语法来包含任意多的实体。EF Core 在执行 select 时要将其翻译为 join。

现在,我们可以像实现 Category 一样实现 ProductService 类:

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 ProductService : IProductService
    {
        private readonly IProductRepository _productRepository;

        public ProductService(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<IEnumerable<Product>> ListAsync()
        {
            return await _productRepository.ListAsync();
        }
    }
}

让我们绑定新的依赖关系,改变 Startup 类。

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<IProductRepository, ProductRepository>();
    services.AddScoped<IUnitOfWork, UnitOfWork>();

    services.AddScoped<ICategoryService, CategoryService>();
    services.AddScoped<IProductService, ProductService>();

    services.AddAutoMapper();
}

最后,在测试 API 之前,我们先修改 AppDbContext 类,在初始化应用程序时加入一些产品,这样我们就可以看到结果了。

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().HasValueGenerator<InMemoryIntegerValueGenerator<int>>();
    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();

    builder.Entity<Product>().HasData
    (
        new Product
        {
            Id = 100,
            Name = "Apple",
            QuantityInPackage = 1,
            UnitOfMeasurement = EUnitOfMeasurement.Unity,
            CategoryId = 100
        },
        new Product
        {
            Id = 101,
            Name = "Milk",
            QuantityInPackage = 2,
            UnitOfMeasurement = EUnitOfMeasurement.Liter,
            CategoryId = 101,
        }
    );
}

我添加了两个虚构的产品,关联到我们在初始化应用时种子的类别。

是时候测试了! 再次运行 API,使用 Postman 向/api/products 发送 GET 请求:

PostMan

就是这样!恭喜你!现在你已经有了如何使用解耦架构的 ASP.NET Core 构建 RESTful API 的基础。

现在你已经掌握了如何使用 ASP.NET Core 使用解耦架构构建一个 RESTful API 的基础。你学到了 .NET Core 框架的许多东西,如何与 C#一起工作,EF Core 和 AutoMapper 的基础知识,以及在设计应用程序时可以使用的许多有用的模式。

你可以检查 API 的完整实现,检查 Github 仓库。

github