ASP.NET Core 中的规约模式(Specification Pattern )——增强泛型仓储模式

在本文中,我们将讨论在 ASP.NET Core 应用程序中实现规约模式以及它如何增强现有的泛型仓储模式。我们将从头开始构建具有泛型仓储模式、Entity Framework CoreASP.NET Core WebAPI,并最终实现规约模式模式。您可以在此处找到此实现的完整源代码。让我们开始吧。

理解规约模式:为什么?

让我们通过一个简单的示例来了解使用规约模式的必要性。下面是 Developer 类的代码片段,它具有 NameEmailExperience 等所需的属性。

1
2
3
4
5
6
7
8
9
public class Developer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public int YearsOfExperience {get;set;}
    public decimal EstimatedIncome {get;set;}
    public int Followers { get; set; }
}

现在,我们可能会有一个服务层,它通过像 Entity Framework Core 这样的抽象从 DB 返回数据集。这是它的样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class DeveloperService : IDeveloperService
{
    private readonly ApplicationDbContext _context;
    
    public DeveloperService(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task<IEnumerable<Developer>> GetDeveloperCount()
    {
        // return a count of all developers in the database
    }
}

虽然您将获得所有开发人员的数量,但更实际和合乎逻辑的要求是使用某种过滤器获得开发人员的数量,同意吗?例如,获取估计收入为 100,000 美元或以上的开发人员的数量,或具有 5 年或以上经验的开发人员的数量。可能性是无限的。

但是,这最终会让您拥有大量的服务层函数,例如 GetDeveloperCountWithSalariesGreaterThan(decimal minSalary)GetDeveloperCountWithExperienceMoreThan(int minExp) 等等。需求越多,您最终拥有的功能数量就越多。如果您需要薪水高于 x 且经验高于 y 年的开发人员数量怎么办? 这是另一个可能导致额外方法的挑战。

您可能会争辩说您可以将这些过滤器直接应用于 Entity Framework Core 实体,例如:

1
await _context.Developers.Where(a=>a.Salary > 10000 && a.Experience > 6).ToListAsync()

但是,不,这与您需要的干净的应用程序代码库相去甚远。这种方法最终会很快破坏应用程序的可伸缩性,相信我,这根本无法维护。小提示,您的应用程序中始终需要一个位于应用程序和数据库之间的服务层,并全权负责处理业务逻辑

这是您的应用程序需要使用规约模式的地方。注意,泛型仓储模式有一些限制,这些限制是通过使用规约模式解决的。我们将建立一个项目,然后使用规约。

我们将建造什么

为了演示 ASP.NET Core 中的规约模式,我们将构建一个具有 2 个端点的简单 Web API 应用程序:

  • 返回特定的开发人员详细信息
  • 返回开发人员列表

但是,我们将添加泛型仓储模式和工作单元的组合,使这个实现更加合乎逻辑和实用。我们将在这里专门识别和实现规约模式的用例。这几乎是您使用 ASP.NET Core 5.0 构建完整应用程序时所需的一切。让我们开始吧。

PS,你可以在这里找到这个实现的完整源代码。

设置项目

首先,让我们打开 Visual Studio 2019+ 并创建一个新的解决方案和一个 WebAPI 项目。请注意,我们也将在此实现中遵循六边形架构,以保持解决方案的良好组织。

添加 API 项目后,让我们再向此解决方案添加 2 个类库项目。我们称之为 DataCore

  • Data 是与数据库和上下文相关的所有实现所在的地方。
  • Core 是我们将添加接口和域实体的地方。

这就是现阶段解决方案的样子。

添加所需的模型

如前所述,在 Core 项目中,创建一个名为 Entities 的新文件夹并向其中添加 2 个类,即 DeveloperAddress

1
2
3
4
5
6
public class Address
{
    public int Id { get; set; }
    public string City { get; set; }
    public string Street { get; set; }
}
1
2
3
4
5
6
7
8
9
public class Developer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public int YearsOfExperience { get; set; }
    public decimal EstimatedIncome { get; set; }
    public Address Address { get; set; }
}

添加 DBContext 、Migrations 和必需的包

现在,让我们将所需的 NuGet 包安装到相应的项目中。

打开包管理器控制台并从下拉列表中将 Data 项目设置为默认项目。运行以下命令以安装所需的软件包。

1
2
3
Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

接下来,将 API 项目设置为默认项目,并运行以下命令。

1
Install-Package Microsoft.EntityFrameworkCore.Design

在设置应用程序上下文类之前,让我们添加连接字符串。为此,从 API 项目打开 appsettings.json 并添加以下内容。

请注意,我们目前正在使用 SQLServer Local DB 进行此演示。

1
2
3
"ConnectionStrings": {
  "DefaultConnection": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=specification-pattern-demo;Integrated Security=True;MultipleActiveResultSets=True"
},

完成后,让我们创建所需的上下文类,以帮助我们访问数据库。为此,在数据项目下,添加一个新类并将其命名为 ApplicationDbContext

1
2
3
4
5
6
7
8
9
public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions options) : base(options)
    {
    }
    
    public DbSet<Developer> Developers { get; set; }
    public DbSet<Address> Addresses { get; set; }
}

在这里,您可以看到我们提到了要包含在 Application Db Context 中的 DeveloperAddress 类。

接下来,我们需要将此上下文添加到我们的 ASP.NET Core 应用程序的服务容器并配置连接详细信息。在 API 工程中打开 Startup.cs,在 ConfigureServices 方法下添加如下内容。

1
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

最后,我们准备添加迁移并更新数据库。再次打开包管理器控制台并将 Data 项目设置为默认项目。运行以下命令:

1
2
add-migration initial
update-database

这是演示相同内容的屏幕截图。请注意,您可能会收到有关上述小数属性精度的警告。我们暂时可以忽略它。

完成后,我们的数据库现在应该准备好了所需的表和相应的字段。出于演示目的,我使用 Visual Studio 2019 IDESQL Server 对象资源管理器工具将一些示例数据直接添加到数据库中。

实现泛型仓储模式

由于我们的需求是返回开发人员的结果集,所以我们创建一个泛型仓储模式,以便它可以使用 ApplicationDbContext 从数据库中查询数据。使用泛型仓储模式的重要性在于,此代码也可以重用于多个其他实体。

例如,我们稍后添加一个名为 Product 的新实体,您不一定需要添加用于从数据库访问 Product 数据的新类,但您可以在大多数用例中使用现有的泛型仓储库实现。请注意,我们将在本文后面的部分讨论和解决泛型仓储库模式的一些限制。

Core 项目下,添加一个新文件夹并将其命名为 Interfaces。在这里,添加一个新接口 IGenericRepository

1
2
3
4
5
public interface IGenericRepository<T> where T: class
{
    Task<T> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
}

创建泛型仓储实现

现在,让我们实现上面创建的接口。由于我们遵循六边形/洋葱架构,我们将不得不在应用程序核心之外添加实现。这意味着,所有与数据相关的实现都将添加到数据项目中。

在这里,添加一个新类 GenericRepository

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected readonly ApplicationDbContext _context;
    public GenericRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    public async Task<List<T>> GetAllAsync()
    {
        return await _context.Set<T>().ToListAsync();
    }
    public async Task<T> GetByIdAsync(int id)
    {
        return await _context.Set<T>().FindAsync();
    }
}

可以看到我们正在将 ApplicationDbContext 的实例注入到这个仓储实现的构造函数中。此实例进一步用于从数据库读取数据。

最后在 API 工程的 Startup.cs 中添加如下内容,将 IGenericRepository 接口注册到应用的服务容器中。

1
services.AddScoped(typeof(IGenericRepository<>), (typeof(GenericRepository<>)));

泛型仓储模式的问题:反模式?

一些开发人员认为泛型仓储是一种反模式。如果使用不当,是的,任何模式都会弄乱您的代码。对泛型仓储的主要抱怨是单个方法可能会将整个数据库访问代码暴露给用户。这也可能意味着需要针对每种需求组合使用多种方法(如本文开头所述)。例如,看下面的接口声明:

1
List<T> FindAsync(Expression<Func<T, bool>> query);

此方法可以作为泛型仓储模式的一部分来解决我们遇到的问题。但是由于该方法过于笼统,泛型仓储不可能知道我们传递给它的表达式。另一个想法可能是从 IGenericRepository 接口中删除此方法并在新接口中使用它,例如,从 IGenericRepository 派生的 IDeveloperRepository。这可能会奏效,但考虑到未来实体的添加和需求的变化,这种变化不是一个明智的选择。

想象一下有 20-30 个新实体并且必须创建大量新仓储?不是个好主意,是吗?考虑在 IDevloperRepository 及其实现中具有多种方法,例如 GetDevelopersWithSalariesGreaterThan(decimal salary)和 GetDevelopersWithExperienceLessThan(int years),不简洁,是吗?

如果有更简洁的方法来解决这个需求呢?这正是规约模式派上用场的地方。

在 ASP.NET Core 中使用规约模式增强仓储模式

规约模式乍一看可能会觉得很复杂。我也感觉到了。但是,一旦您添加了某些基类和评估器,您所要做的就是创建规约类,根据您的要求,这些类通常为 2 到 10 行。让我们开始使用 ASP.NET Core 中的规约模式。

在 Core 项目下,添加一个新文件夹并将其命名为 Specifications。这是所有与规约相关的接口都要去的地方。

创建一个新接口并将其命名为 ISpecification.cs

1
2
3
4
5
6
7
public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    Expression<Func<T, object>> OrderBy { get; }
    Expression<Func<T, object>> OrderByDescending { get; }
}

这只是一个最小的实现。让我解释每个声明的方法定义。

  • Criteria - 您可以在此处添加基于实体的表达式。
  • Includes – 如果要包含外键表数据,可以使用此方法添加它。
  • OrderByOrderByDescending 是不言自明的。

接下来,在同一文件夹中,添加一个新类 BaseSpecifcation。这将是 ISpecification 接口的实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class BaseSpecifcation<T> : ISpecification<T>
{
    public BaseSpecifcation()
    {
    }
    public BaseSpecifcation(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }
    public Expression<Func<T, bool>> Criteria { get; }
    public List<Expression<Func<T, object>>> Includes { get; } = new List<Expression<Func<T, object>>>();
    public Expression<Func<T, object>> OrderBy { get; private set; }
    public Expression<Func<T, object>> OrderByDescending { get; private set; }
    protected void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }
    protected void AddOrderBy(Expression<Func<T, object>> orderByExpression)
    {
        OrderBy = orderByExpression;
    }
    protected void AddOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
    {
        OrderByDescending = orderByDescExpression;
    }
}

在这里,我们将添加 3 个基本方法和一个构造函数。

  • 将表达式添加到 Includes 属性
  • 将表达式添加到 OrderBy 属性
  • 将表达式添加到 OrderByDescending 属性
  • 您可以注意到我们还有一个接受条件的构造函数。Criteria 可以是 ( x=>x.Salary > 100 ) 等。你明白了,是吗?

升级泛型仓储

首先,让我们在 IGenericRepository 接口中添加一个方法。

1
IEnumerable<T> FindWithSpecificationPattern(ISpecification<T> specification = null);

接下来,让我们在 GenericRepository 类中实现新方法。

1
2
3
4
public IEnumerable<T> FindWithSpecificationPattern(ISpecification<T> specification = null)
{
    return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), specification);
}

现在,设置所有这些背后的想法是创建可以返回特定结果集的单独规约类。这些新规约类中的每一个都将从 BaseSpecification 类继承。明白了吗?现在让我们创建这些规约类,以便它有意义 😉

因此,让我们得出 2 个要求/规约:

1.按薪水降序返回开发人员列表的规约。 2.另一个规约返回具有 N 或以上经验的开发人员列表及其地址。

Core 项目的同一个 Specification 文件夹下,添加我们的第一个规约类 DeveloperByIncomeSpecification

1
2
3
4
5
6
7
public class DeveloperByIncomeSpecification : BaseSpecifcation<Developer>
{
    public DeveloperByIncomeSpecification()
    {            
        AddOrderByDescending(x => x.EstimatedIncome);
    }
}

在这里,您可以看到我们从 BaseSpecification 类派生并在构造函数中使用 AddOrderByDescending 方法。理想情况下,此规约将返回一个按收入递减顺序排列的开发人员列表。

接下来,让我们添加另一个类,DeveloperWithAddressSpecification

1
2
3
4
5
6
7
public class DeveloperWithAddressSpecification : BaseSpecifcation<Developer>
{
    public DeveloperWithAddressSpecification(int years) : base(x=>x.EstimatedIncome > years)
    {
        AddInclude(x => x.Address);
    }
}

因此,这里我们将查询表达式传递给 Specification Class 的基类,它是 BaseSpecification 的构造函数,然后将其添加到我们之前创建的 Criteria 属性中。其实很简单。

现在,随着我们的规约类准备就绪,让我们添加 api 端点。

API 项目下,在 Controllers 文件夹下添加一个新的 API Controller,并将其命名为 DevelopersController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DevelopersController : ControllerBase
{
    public readonly IGenericRepository<Developer> _repository;
    
    public DevelopersController(IGenericRepository<Developer> repository)
    {
        _repository = repository;
    }
    
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var developers = await _repository.GetAllAsync();
        return Ok(developers);
    }
    
    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var developer = await _repository.GetByIdAsync(id);
        return Ok(developer);
    }
    
    [HttpGet("specify")]
    public async Task<IActionResult> Specify()
    {
        var specification = new DeveloperWithAddressSpecification(3);
        //var specification = new DeveloperByIncomeSpecification();
        var developers = _repository.FindWithSpecificationPattern(specification);
        return Ok(developers);
    }
}

第 3 – 7 行:将 IGenericRepository 注入到 Controller 的构造函数中。第 8 – 19 行:使用仓储实例返回所有开发人员和具有特定 Id 的开发人员的标准端点。

第 20 – 27 行:这是控制器最有趣的部分。这里的第 23 行和第 24 行是我们之前创建的 2 个规约类。这只是为了证明可以在控制器或使用 GenericRepository 的任何地方创建任何此类规约实例。我们将使用 DeveloperWithAddressSpecification(3) 进行演示。

现在让我们运行应用程序并检查指定端点的结果。

可以看到还返回了地址数据。现在,回到控制器,注释掉第 24 行,让我们暂时使用 DeveloperByIncomeSpecification。再次运行应用程序。

现在您可以注意到没有返回地址数据。为什么?很简单,因为我们使用了不同的规约,没有提到添加 Address 实体。相反,该规约按收入的递减顺序返回开发人员的集合。简单,但整洁对吗?这可能是 ASP.NET Core 应用程序中最酷的设计模式之一。

很奇怪,但这实际上是您可以理解规约模式是什么的时候😛 根据维基百科 - 在计算机编程中,规约模式是一种特定的软件设计模式,其中可以通过使用布尔逻辑将业务规则链接在一起来重新组合业务规则。该模式经常用于领域驱动设计的上下文中。

现在更有意义了,是吗?业务规则(我们要求返回具有一定经验水平或更高级别的开发人员)通过链接标准(这发生在 DeveloperWithAddressSpecification 类中)组合在一起,这是一个布尔逻辑。很简单,但是太强大了😉

展望未来,这种模式的可能性是无穷无尽的,并且非常有助于扩展应用程序。这种模式也可能支持 Data-Shaping 和分页。非常强大的模式,学习曲线很小,是吗?这是这篇文章的总结。

总结

在本文中,我们介绍了 ASP.NET Core 应用程序中的规约模式,以及它如何通过占上风来增强泛型仓储模式。我们还构建了一个完整的 Web API 应用程序,该应用程序遵循洋葱架构以进行干净的代码管理。你也可以在我的 Github 上找到完整的源代码。有任何建议或问题吗?请随时将它们留在下面的评论部分。Thanks and Happy Coding!😀

参考

原文链接:https://codewithmukesh.com/blog/specification-pattern-in-aspnet-core/ 完整源代码: specification-pattern-asp-net-core

0%