Ardalis.Specification 规范模式
概述
规范模式将查询逻辑封装在它自己的类中,这有助于类遵循单一职责原则(SRP) 并促进常见查询的重用。规范可以独立进行单元测试。当与Repository模式结合使用时,它还可以帮助防止它随着太多额外的自定义查询方法而增长。规范通常用于利用领域驱动设计的项目。
规范是领域驱动设计中的一种模式,有助于以可重用的方式封装领域知识。
规范通常包含验证实体或从持久层检索实体所必需的标准。规范允许开发人员在领域层中定义通用逻辑,而不是在数据访问层或验证层中单独定义该领域知识。这使开发人员能够跨系统的多个层和组件重用此逻辑。
好处
一般来说,规范模式提供的主要好处,特别是这个包是:
- 将数据访问查询逻辑放在一处
- 将数据访问查询逻辑保留在领域层
- 在整个应用程序中重复使用常见查询
- 为常见查询提供好的名称以促进重用并提升用于描述应用程序行为的语言
- 消除 Repository 模式的常见痛点(隐藏 ORM 数据整形特性,需要很多自定义查询方法)
安装 Ardalis.Specification
从 NuGet 安装 Ardalis.Specification。最新版本可在此处获得:
https://www.nuget.org/packages/Ardalis.Specification/
或者,使用此 CLI 命令将其添加到项目中:
dotnet add package Ardalis.Specification
规范模式
在规范模式中,规范用于定义查询。使用规范消除了在整个代码库中分散 LINQ 逻辑的需要,因为 LINQ 表达式可以改为封装在规范对象中。此外,使用规范来定义给定查询中所需的确切数据可确保一次只需要进行一个查询(而不是在需要时延迟加载每条数据),从而提高性能。
存储库
通过将规范添加到存储库实现,许多SOLID 原则可以更好地应用于最终的解决方案。这些通常仅适用于更大、更复杂的应用程序中的存储库,但如果您发现应用程序中存储库的数量或大小在增长,那么这就是一种代码味道,表明您可能会从使用规范中受益。
存储库的一个常见问题是自定义查询需要在存储库接口和实现上使用额外的方法。随着新需求的到来,越来越多的方法被附加到曾经很简单的接口上。
一次又一次打开和修改相同的类型显然违反了Open/Closed Principle。
向存储库类型添加越来越多的职责(持久化,还有查询)违反了单一职责原则。
创建越来越大的存储库接口违反了接口隔离原则。
使用与规范一起工作的更小、更简单的存储库接口可以解决所有这些问题。
如何创建规范
基本规格
Specification 类应该继承自Specification<T>
,T
查询中检索的类型在哪里:
public class ItemByIdSpec : Specification<Item>
规范可以在其构造函数中获取参数,并使用这些参数进行适当的查询。由于上述类的名称表明它将Item
通过 id检索,因此其构造函数应接受一个id
参数:
public ItemByIdSpec(int Id)
在其构造函数中,规范应定义一个Query
表达式,使用其参数来检索所需的对象:
Query.Where(x => x.Id == Id);
基于以上内容,最基本的规范应该是这样的:
public class ItemByIdSpec : Specification<Item> { public ItemByIdSpec(int Id) { Query.Where(x => x.Id == Id); } }
最后:上面的 Specification 还应该实现 marker interface ISingleResultSpecification
,这清楚地表明这个 Specification 将只返回一个结果。任何“ById”规范,以及任何其他旨在仅返回一个结果的规范,都应实现此接口以明确它返回单个结果。
public class ItemByIdSpec : Specification<Item>, ISingleResultSpecification { public ItemByIdSpec(int Id) { Query.Where(x => x.Id == Id); } }
public class ProjectByProjectStatusSpec : Specification<Project> { public ProjectByProjectStatusSpec(ProjectStatus projectStatus) { Guard.Against.Null(projectStatus, nameof(projectStatus)); Query.Where(p => p.Status == projectStatus); } }
如果我们想要修改我们的规范以包含相关实体,我们可以通过Include
向 Query 属性添加一个子句来实现。
public class ProjectByProjectStatusWithToDoItemsSpec : Specification<Project> { public ProjectByProjectStatusWithToDoItemsSpec(ProjectStatus projectStatus) { Guard.Against.Null(projectStatus, nameof(projectStatus)); Query.Where(p => p.Status == projectStatus) .Include(p => p.Items); } }
ISingleResultSpecification
接口用于指示规范将返回实体的单个实例。
public class ProjectByIdSpec : Specification<Project>, ISingleResultSpecification { public ProjectByIdSpec(long id) { Guard.Against.Default(id, nameof(id)); Query.Where(p => p.Id == id); } }
多条件查询
public class HeroByNameAndSuperPowerContainsFilterSpec : Specification<Hero> { public HeroByNameAndSuperPowerContainsFilterSpec(string name, string superPower) { if (!string.IsNullOrEmpty(name)) { Query.Where(h => h.Name.Contains(name)); } if (!string.IsNullOrEmpty(superPower)) { Query.Where(h => h.SuperPower.Contains(superPower)); } } }
规范作为验证器
public class ValidateCompletedProjectSpec : Specification<Project> { public ValidateCompletedProjectSpec() { Query .Include(p => p.Items) .Where(p => p.Status == ProjectStatus.Complete && p.Items.All(i => i.IsDone)); } }
要验证给定实例是否满足上述约束,我们可以使用IsSatisfiedBy
Project 实例作为参数调用规范实例上的方法。这将返回一个布尔值,指示项目是否符合给定规范。由于规范和实体实例在内存中,因此该示例非常适合单元测试。
[Fact] public void ShouldReturnTrueWhenTodoItemsAreDone() { var project = new Project("Blog", PriorityStatus.Critical); project.AddItem( new ToDoItem { Title = "Write Specification Blog", Description = "Write a blog post about the specification pattern", }); var spec = new ValidateCompletedProjectSpec(); bool result = spec.IsSatisfiedBy(project); // returns false Assert.True(result); }
此测试将失败,因为创建项目的单个 ToDoItem 时 IsDone 的值为 false。如果我们调用MarkComplete()
ToDoItem 实例,则结果变量将为真。现在,我们可以编写通过测试了。
[Fact] public void ShouldReturnTrueWhenTodoItemsAreDone() { var project = new Project("Blog", PriorityStatus.Critical); project.AddItem( new ToDoItem { Title = "Write Specification Blog", Description = "Write a blog post about the specification pattern", }); project.Items.Single().MarkComplete(); var spec = new ValidateCompletedProjectSpec(); bool result = spec.IsSatisfiedBy(project); // returns true Assert.True(result); }
如何将规范与 DbContext 一起使用
可以使用规范来定义直接使用 EF6 或 EF Core 执行的查询DbContext
int id = 1; var specification = new CustomerByIdSpec(id); var customer = dbContext.Customers .WithSpecification(specification) .FirstOrDefault();
注意,WithSpecification
扩展方法公开了一个IQueryable<T>
扩展方法,因此可能会在规范之后应用其他扩展方法。
bool isFound = dbContext.Customers.WithSpecification(specification).Any(); int customerCount = dbContext.Customers.WithSpecification(specification).Count(); var customers = dbContext.Customers.WithSpecification(specification).ToList();
使用内置抽象存储库 Abstract Repository
使用此库中提供的抽象通用存储库,首先定义一个存储库类,该类继承自RepositoryBase<T>
项目的基础结构或数据访问层
public class HeroService { private readonly YourRepository<Hero> _heroRepository; public HeroService(YourRepository<Hero> heroRepository) { _heroRepository = heroRepository; } public async Task<Hero> Create(string name, string superPower, bool isAlive, bool isAvenger) { var hero = new Hero(name, superPower, isAlive, isAvenger); await _heroRepository.AddAsync(hero); await _heroRepository.SaveChangesAsync(); return hero; } public async Task Delete(Hero hero) { await _heroRepository.DeleteAsync(hero); await _heroRepository.SaveChangesAsync(); } public async Task DeleteRange(List<Hero> heroes) { await _heroRepository.DeleteRangeAsync(heroes); await _heroRepository.SaveChangesAsync(); } public async Task<Hero> GetById(int id) { return await _heroRepository.GetByIdAsync(id); } public async Task<Hero> GetByName(string name) { var spec = new HeroByNameSpec(name); return await _heroRepository.FirstOrDefaultAsync(spec); } public async Task<List<Hero>> GetHeroesFilteredByNameAndSuperPower(string name, string superPower) { var spec = new HeroByNameAndSuperPowerFilterSpec(name, superPower); return await _heroRepository.ListAsync(spec); } public async Task<Hero> SetIsAlive(int id, bool isAlive) { var hero = await _heroRepository.GetByIdAsync(id); hero.IsAlive = isAlive; await _heroRepository.UpdateAsync(hero); await _heroRepository.SaveChangesAsync(); return hero; } public async Task SeedData(Hero[] heroes) { // only seed if no Heroes exist if (!await _heroRepository.AnyAsync()) { return; } // alternatively if (await _heroRepository.CountAsync() > 0) { return; } foreach (var hero in heroes) { await _heroRepository.AddAsync(hero); } await _heroRepository.SaveChangesAsync(); } }
客户端调用
public async Task Run() { var seedData = new[] { new Hero( name: "Batman", superPower: "Intelligence", isAlive: true, isAvenger: false), new Hero( name: "Iron Man", superPower: "Intelligence", isAlive: true, isAvenger: true), new Hero( name: "Spiderman", superPower: "Spidey Sense", isAlive: true, isAvenger: true), }; await heroService.SeedData(seedData); var captainAmerica = await heroService.Create("Captain America", "Shield", true, true); var ironMan = await heroService.GetByName("Iron Man"); var alsoIronMan = await heroService.GetById(ironMan.Id); await heroService.SetIsAlive(ironMan.Id, false); var shouldOnlyContainBatman = await heroService.GetHeroesFilteredByNameAndSuperPower("Bat", "Intel"); await heroService.Delete(captainAmerica); var allRemainingHeroes = await heroService.GetHeroesFilteredByNameAndSuperPower("", ""); await heroService.DeleteRange(allRemainingHeroes); }
查询表达式
Where
接受Expression<Func<TSource, bool>>
表达式作为参数。
Query.Where(x => x.Id == Id);
OrderBy
接受Expression<Func<TSource, TKey>>
表达式作为参数
Query.OrderByDescending(x => x.Name) .ThenByDescending(x => x.Id) .ThenBy(x => x.DateCreated);
Skip
int[] numbers = { 1, 3, 2, 5, 7, 4 }; IEnumerable<int> subsetOfNumbers = numbers.OrderBy(n => n).Skip(2);
subsetOfNumbers
将包含{ 3, 4, 5, 7 }
.
Skip
通常与Take结合使用来实现Paging,但如上所示,Skip
也可以单独使用。
Take
int[] numbers = { 1, 3, 2, 5, 7, 4 }; IEnumerable<int> subsetOfNumbers = numbers.OrderBy(n => n).Take(3);
Paging
public class StoresByCompanyPaginatedSpec : Specification<Store> { public StoresByCompanyPaginatedSpec(int companyId, int skip, int take) { Query.Where(x => x.CompanyId == companyId) .Skip(skip) .Take(take); } }
Select
查询现在返回不同的类型,即 的类型Name
,而不是 的类型x
,规范的基类将需要反映这一点。Specification不是继承于Specification<T>
,而是继承于Specification<T, TReturn>
:
public class StoreNamesSpec : Specification<Store, string?> { public StoreNamesSpec() { Query.Select(x => x.Name); } }
Evaluate 评估
将规范应用于内存中的集合
可以定义一个规范来过滤给定的类型
public class StringsWhereValueContainsSpec : Specification<string> { public StringsWhereValueContainsSpec(string filter) { Query.Where(s => s.Contains(filter)); } }
您可以使用该方法将上述规范应用于内存中的集合Evaluate
。此方法将 anIEnumerable<T>
作为代表集合的参数来应用规范。下面演示一个简单的例子。
var trainingResources = new[] { "Articles", "Blogs", "Documentation", "Pluralsight", }; var specification = new StringsWhereValueContainsSpec("ti"); var results = specification.Evaluate(trainingResources);
AsNoTracking
该功能将此方法应用于由EF6或EF CoreAsNoTracking
执行的结果查询。
当结果用于只读场景时,没有跟踪查询是有用的。它们执行起来更快,因为不需要设置更改跟踪信息。如果您不需要更新从数据库中检索到的实体,则应使用无跟踪查询。
public class CustomerByNameReadOnlySpec : Specification<Customer> { public CustomerByNameReadOnlySpec(string name) { Query.Where(x => x.Name == name) .AsNoTracking() .OrderBy(x => x.Name) .ThenByDescending(x => x.Address); } }
注意:最好在规范使用时注明AsNoTracking
,这样规范的使用者就不会尝试修改和保存使用规范的查询返回的实体。ReadOnly
为此,将上述规范添加到其名称中。
AsNoTrackingWithIdentityResolution
该AsNoTrackingWithIdentityResolution
功能将此方法应用于EF Core执行的结果查询。EF 6 不支持它。
强制无跟踪查询执行身份解析AsNoTrackingWithIdentityResolution<TEntity>(IQueryable<TEntity>)
。然后查询将跟踪返回的实例(不以正常方式跟踪它们)并确保在查询结果中不创建重复项。
public class CustomerByNameReadOnlySpec : Specification<Customer> { public CustomerByNameReadOnlySpec(string name) { Query.Where(x => x.Name == name) .AsNoTrackingWithIdentityResolution() .OrderBy(x => x.Name) .ThenByDescending(x => x.Address); } }
AsSplitQuery 拆分查询
EF Core 5 引入了对拆分查询的支持,这将在从多个表返回数据时执行单独的查询而不是复杂的连接。包含来自多个表的数据的单个查询结果可能会导致跨多个列和行的重复数据的“笛卡尔爆炸”。
EF 允许您指定应将给定的 LINQ 查询拆分为多个 SQL 查询。拆分查询不是 JOIN,而是为每个包含的集合导航生成一个额外的 SQL 查询。
下面是一个规范,用于AsSplitQuery
生成几个单独的查询而不是跨公司、商店和产品表的大型连接:
public class CompanyByIdAsSplitQuery : Specification<Company>, ISingleResultSpecification { public CompanyByIdAsSplitQuery(int id) { Query.Where(company => company.Id == id) .Include(x => x.Stores) .ThenInclude(x => x.Products) .AsSplitQuery(); } }
IgnoreQueryFilters
该IgnoreQueryFilters
功能用于向 EF Core(EF 6 不支持)指示它应该忽略此查询的全局查询筛选器(例如:全局查询增加了:删除标示==1的条件)。它只是将此调用传递给用于禁用全局过滤器的基础 EF Core 功能。
public class CompanyByIdIgnoreQueryFilters : Specification<Company>, ISingleResultSpecification { public CompanyByIdIgnoreQueryFilters(int id) { Query .Where(company => company.Id == id) .IgnoreQueryFilters(); } }
Include
该Include
功能用于向 ORM 指示相关导航属性应与被查询的基本记录一起返回。它用于扩展实体返回的相关数据量,提供相关数据的预先加载。
注意:不建议在基于 Web 的 .NET 应用程序中使用延迟加载。
下面是加载公司实体及其商店集合的规范。
public class CompanyByIdWithStores : Specification<Company>, ISingleResultSpecification { public CompanyByIdWithStores(int id) { Query.Where(company => company.Id == id) .Include(x => x.Stores) } }
ThenInclude
该ThenInclude
功能用于向 ORM 指示先前Include
d 属性的相关属性应与查询结果一起返回。
下面是一个规范,它加载一个公司实体及其商店集合,然后是每个商店的产品集合。
public class CompanyByIdWithStoresAndProducts : Specification<Company>, ISingleResultSpecification { public CompanyByIdWithStoresAndProducts(int id) { Query.Where(company => company.Id == id) .Include(x => x.Stores) .ThenInclude(x => x.Products) } }
Search
该Search
扩展通过对其应用“SQL LIKE”操作来过滤查询源。的参数Search
包括Selector,它是 LIKE 应该应用的属性/列,以及 SearchTerm ,它是与 LIKE 一起使用的值。%
SearchTerm 中必须包含任何通配符 ( )。
public class CustomerSpec : Specification<Customer> { public CustomerSpec(CustomerFilter filter) { // other criteria omitted if (!string.IsNullOrEmpty(filter.Address)) { Query .Search(x => x.Address, "%" + filter.Address + "%"); } } }