大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的一块垫脚石,我们一起精进。
EF Core集成
EF Core是微软的ORM,可以使用它与主流的数据库提供商合作,如SQL Server、Oracle、MySQL、PostgreSQL和Cosmos DB。当您使用ABP命令行界面(CLI)创建新的ABP解决方案时,它是默认的数据库提供程序。
默认情况下,启动模板使用SQL Server。如果您更喜欢其他的数据库管理系统(DBMS),可以在创建新解决方案时指定-DBMS参数,如下所示:
abp new DemoApp -dbms MySQL
您可以参考ABP的文档,了解最新支持的数据库选项,以及如何切换到其他现成数据库提供程序。
在接下来您将了解到:
- 如何配置DBMS;
- 如何定义DbContext类;
- 如何注册到依赖注入(DI)系统;
- 如何将实体映射到数据库表;
- 如何使用Code First和为实体创建自定义存储库;
- 如何为实体加载相关数据的不同方式。
3.1 配置 DBMS
我们使用AbpDbContextOptions
在模块的ConfigureServices
方法中配置DBMS
。以下示例使用SQL Server
作为DBMS
进行配置:
Configure<AbpDbContextOptions>(options =>
{
options.UseSqlServer();
});
当然,如果希望配置不同的DBMS,那么UseSqlServer()
方法调用将有所不同。我们不需要设置连接字符串,因为它是从ConnectionString:Default
配置自动获得的。你可以查看appsettings.json
文件,以查看和更改连接字符串。
配置了DBMS,但还没有定义DbContext对象,这是在EF Core中使用数据库所必需的,我接下来看看如何配置:
3.2 定义 DbContext
DbContext是EF Core中与数据库交互的主要对象。通常创建一个继承自DbContext的类来创建自己的DbContext。使用ABP框架,我们将继承AbpDbContext。
下面是一个使用ABP框架的DbContext类定义示例:
using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace FormsApp
{
public class FormsAppDbContext : AbpDbContext<FormsAppDbContext>
{
public DbSet<Form> Forms { get; set; }
public FormsAppDbContext(DbContextOptions<FormsAppDbContext> options)
: base(options)
{
}
}
}
FormsAppDbContext
继承自AbpDbContext<FormsAppDbContext>
。AbpDbContext
是一个泛型类,将DbContext
类型作为泛型参数。它还迫使我们创建一个构造函数。然后,我们就可以为实体添加DbSet
属性。
一旦定义了DbContext,我们就应该向DI系统注册它,以便在应用程序中使用它。
3.3 向 DI 注册 DbContext
AddAbpDbContext
扩展方法用于向DI系统注册DbContext
类。您可以在模块的ConfigureServices
方法中使用此方法(它位于启动解决方案的EntityFrameworkCore
项目中),如以下代码块所示:
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddAbpDbContext<FormsAppDbContext> (options =>
{
//启用默认通用存储库,DDD应始终通过聚合根访问子实体
options.AddDefaultRepositories();
//开启后,非聚合根实体也支持IRepository注入
//options.AddDefaultRepositories(includeAllEntities: true);
});
}
AddDefaultRepositories()
用于为与DbContext
相关的实体启用默认通用存储库。默认情况下,它仅为聚合根实体启用通用存储库,因为在域驱动设计(DDD)中,子实体应始终通过聚合根进行访问。如果还想将存储库用于其他实体类型,可以将可选的includealentities
参数设置为true
options.AddDefaultRepositories(includeAllEntities: true);
使用此选项,意味着您可以为应用程序的任何实体注入IRepository
服务。
注意:因为从事关系数据库的开发人员习惯于从所有数据库表中查询,如果要严格应用 DDD 原则,则应始终使用聚合根来访问子实体。
我们已经了解了如何注册DbContext
类,我们可以为DbContext
类中的所有实体注入和使用IRepository
接口。接下来,我们应该首先为实体配置EF Core映射。
3.4 配置实体映射
EF Core是一个对象到关系的映射器,它将实体映射到数据库表。我们可以通过以下两种方式配置这些映射的详细信息:
- 在实体类上使用数据注释属性
- 通过重写
OnModelCreating
方法在内部使用 Fluent API(推荐)
使用数据注释属性会领域层依赖于EF Core,如果这对您来说不是问题,您可以遵循EF Core的文档使用这些属性。为了解脱依赖,同时也为了保持实体类的纯洁度,推荐使用Fluent API方法。
要使用Fluent API方法,可以在DbContext类中重写OnModelCreating方法,如以下代码块所示:
public class FormsAppDbContext : AbpDbContext<FormsAppDbContext>
{
...
//1.override覆盖后,依然会调用父类的base.OnModelCreating(),因为内置审计日志和数据过滤
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
2.Fluent API,这里可以继续封装(TODO)
builder.Entity<Form>(b =>
{
b.ToTable("Forms");
b.ConfigureByConvention(); //3.重要,默认配置预定义的Entity或AggregateRoot,无需再配置,剩下的配置就显得整洁而规范了。
b.Property(x => x.Name)
.HasMaxLength(100)
.IsRequired();
b.HasIndex(x => x.Name);
});
//4.一对多的配置
builder.Entity<Question>(b =>
{
b.ToTable("FormQuestions");
b.ConfigureByConvention();
b.Property(x => x.Title)
.HasMaxLength(200)
.IsRequired();
b.HasOne<Form>() //5.一个问题对应一个表单,一个表单有多个问题。
.WithMany(x => x.Questions)
.HasForeignKey(x => x.FormId)
.IsRequired();
});
}
}
重写OnModelCreating
方法时,始终调用base.OnModelCreating()
,因为该方法内执行默认配置(如审核日志和数据过滤器)。然后,使用builder
对象执行配置。
例如,我们可以为本章中定义的表单类配置映射,如下所示:
builder.Entity<Form>(b => {
b.ToTable("Forms");
b.ConfigureByConvention();
b.Property(x => x.Name).HasMaxLength(100) .IsRequired();
b.HasIndex(x => x.Name);
});
在这里调用b.ConfigureByConvention
方法很重要。如果实体派生自ABP的预定义实体或AggregateRoot
类,它将配置实体的基本属性。剩下的配置代码非常干净和标准,您可以从EF Core的文档中了解所有细节。
下面是另一个配置实体之间关系的示例:
builder.Entity<Question>(b => {
b.ToTable("FormQuestions");
b.ConfigureByConvention();
b.Property(x => x.Title).HasMaxLength(200).IsRequired();
b.HasOne<Form>().WithMany(x => x.Questions).HasForeignKey(x => x.FormId).IsRequired();
});
在这个例子中,我们定义了表单和问题实体之间的关系:一个表单可以有许多问题,而一个问题属于一个表单。
EF的 Code First Migrations
系统提供了一种高效的方法来增量更新数据库,使其与实体保持同步。
Code First相比较传统迁移的好处:
- 高效快速
- 增量更新
- 版本管理
3.5 实现自定义存储库
我们在“自定义存储库”部分创建了一个IFormRepository
接口。现在,是时候使用EF Core
实现这个存储库接口了。
在解决方案的EF Core
集成项目中实现存储库,如下所示:
//1.集成自EfCoreRepository,传入三个泛型参数,继承了所有标准存储库的方法。
public class FormRepository : EfCoreRepository<FormsAppDbContext, Form, Guid>,IFormRepository
{
public FormRepository(IDbContextProvider<FormsAppDbContext> dbContextProvider)
: base(dbContextProvider){ }
public async Task<List<Form>> GetListAsync(string name, bool includeDrafts = false)
{
var dbContext = await GetDbContextAsync();
var query = dbContext.Forms.Where(f => f.Name.Contains(name));
if (!includeDrafts)
{
query = query.Where(f => !f.IsDraft);
}
return await query.ToListAsync();
}
}
该类派生自ABP的EfCoreRepository
类。通过这种方式,我们继承了所有标准的存储库方法。EfCoreRepository
类获得三个通用参数:DbContext
类型、实体类型和实体类的PK
类型。
FormRepository
还实现了IFormRepository
,它定义了一个GetListAsync
方法,DbContext
实例在这个方法中可以使用EF Core API的所有功能。
关于WhereIf
的提示:
条件过滤是一种广泛使用的模式,ABP提供了一种很好的WhereIf
扩展方法,可以简化我们的代码。
我们可以重写GetListAsync方法,如下代码块所示:
var dbContext = await GetDbContextAsync();
return await dbContext.Forms
.Where(f => f.Name.Contains(name))
.WhereIf(!includeDrafts, f => !f.IsDraft)
.ToListAsync();
因为我们有DbContext
实例,所以可以使用它执行结构化查询语言(SQL)命令或存储过程。下面是执行“删除所有表单”命令:
public async Task DeleteAllDraftsAsync()
{
var dbContext = await GetDbContextAsync();
//执行SQL查询
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM Forms WHERE IsDraft = 1");
}
执行存储过程和函数,请参考EF的核心文档学习如何执行存储过程和函数。
一旦实现了IFormRepository
,就可以注入并使用它,而不是IRepository<Form,Guid>
,如下所示:
1)自定义存储库的调用
public class FormService : ITransientDependency
{
private readonly IFormRepository _formRepository;//自定义仓储库
public FormService(IFormRepository formRepository)
{
_formRepository = formRepository;
}
public async Task<List<Form>> GetFormsAsync(string name)
{
return await _formRepository.GetListAsync(name, includeDrafts: true);
}
}
FormService
类使用IFormRepository
的自定义GetListAsync
方法。即使为表单实现了自定义存储库类,仍然可以为该实体注入并使用默认的通用存储库(例如,IRepository<Form,Guid>
),尤其是刚开始不熟悉,可以从通用存储库上手,等熟悉后就可以使用自定义存储库。
2)自定义存储库的配置
如果重写EfCoreRepository
类中的基方法并,可能会出现一个潜在问题:使用通用存储库的服务将继续使用非重写方法。要防止这种情况,请在向DI注册DbContext
时使用AddRepository
方法,如下所示:
context.Services.AddAbpDbContext<FormsAppDbContext>(options =>
{
options.AddDefaultRepositories();
//实现仓储库后,建议进行注入
options.AddRepository<Form, FormRepository>();
});
通过这种配置,AddRepository
方法将通用存储库重定向到自定义存储库类。
3.7 数据加载
如果您的实体具有指向其他实体的导航属性或具有其他实体的集合,则在使用主实体时,您经常需要访问这些相关实体。例如,前面介绍的表单实体有一组问题实体,您可能需要在使用表单对象时访问这些问题集。
访问相关实体有多种方式,包括:
- 显式加载
- 延迟加载
- 即时加载
1)显式加载
存储库提供了EnsureRepropertyLoadedAsync
和EnsureRecollectionLoadedAsync
扩展方法,以显式加载导航属性或子集合。
例如,我们可以显式加载表单的问题,如以下代码块所示:
public async Task<IEnumerable<Question>> GetQuestionsAsync(Form form)
{
//
await _formRepository.EnsureCollectionLoadedAsync(form, f => f.Questions);
return form.Questions;
}
如果不用EnsureCollectionLoadedAsync
,Questions
可能是空的,如果已经加载过,不会重复加载,所以多次调用对性能没有影响。
2)延迟加载
延迟加载是EF Core
的一项功能,它在您首次访问相关属性和集合时加载它们。默认情况下不启用延迟加载。如果要为DbContext
启用它,请执行以下步骤:
- 在 EF Core 层中安装
Microsoft.EntityFrameworkCore.Proxies
- 配置时使用
UseLazyLoadingProxies
方法
Configure<AbpDbContextOptions>(options =>
{
options.PreConfigure<FormsAppDbContext>(opts =>
{
opts.DbContextOptions.UseLazyLoadingProxies();
});
options.UseSqlServer();
});
- 确保导航属性和集合属性在实体中是
virtual
public class Form : BasicAggregateRoot<Guid>
{
...
public virtual ICollection<Question> Questions { get; set; }
public virtual ICollection<FormManager> Owners { get; set; }
}
当您启用延迟加载时,您无需再使用显式加载。
延迟加载是一个被讨论过的ORM
概念。一些开发人员发现它很实用,而其他人则建议不要使用它。我之所以不使用它,是因为它有一些潜在的问题,比如:
- 无法使用异步
延迟加载不能使用异步编程,无法使用async/await
模式访问属性。因此,它会阻止调用线程,这对于吞吐量和可伸缩性来说是一种糟糕的做法。
1+N
性能问题
如果在使用foreach
循环之前没有预先加载相关数据,则可能会出现1+N
加载问题。1+N
加载意味着通过单个数据库操作1次(比如,从数据库中查询实体列表),然后执行一个循环来访问这些实体的导航属性(或集合)。在这种情况下,它会延迟加载每个循环内的相关属性(N=第一次数据库操作中查询的实体数)。因此,进行1+N
数据库调用,会显著降低应用程序性能。
- 断言和代码优化问题
因为您可能不容易看到相关数据何时从数据库加载。我建议采用一种更可控的方法,尽可能使用即时加载。
3)即时加载
顾名思义,即时加载是在首先查询主实体时加载相关数据的一种方式。假设您已经创建了一个自定义存储库,以便在从数据库获取表单对象时加载相关问题,如下所示:
- 在
EF Core
层,在自定义仓储库中使用EF Core API
public async Task<Form> GetWithQuestions(Guid formId)
{
var dbContext = await GetDbContextAsync();
return await dbContext.Forms
.Include(f => f.Questions)
.SingleAsync(f => f.Id == formId);
}
自定义存储库方法,可以使用完整的EF Core API
。但是,如果您使用的是ABP
的存储库,并且不想在应用程序层依赖EF Core
,那么就不能使用EF Core
的Include
扩展方法(用于快速加载相关数据)。
假如你不想在应用层依赖
EF Core API
该怎么办?
在本例中,您有两个选项:
1)IRepository.WithDetailsAsync
IRepository
的WithDetailsSync
方法通过包含给定的属性或集合来返回IQueryable
实例,如下所示:
public async Task EagerLoadDemoAsync(Guid formId)
{
var queryable = await _formRepository.WithDetailsAsync(f => f.Questions);
var query = queryable.Where(f => f.Id == formId);
var form = await _asyncExecuter.FirstOrDefaultAsync(query);
foreach (var question in form.Questions)
{
//...
}
}
WithDetailsAsync(f=>f.Questions)
返回IQueryable<Form>
,其中包含form.Questions
,因此我们可以安全地循环表单。IAsyncQueryableExecuter
在本章的“通用存储库”部分进行了介绍。如果需要,WithDetailsSync
方法可以获取多个表达式以包含多个属性。如果需要嵌套包含(EF Core中的ThenClude
扩展方法),则不能使用WithDetailsAsync
。
2)聚合模式
聚合模式将在第10章DDD——领域层中详细介绍。可以简单地理解:一个聚合被认为是一个单一的单元,它与所有子集合一起作为单个单元进行读取和保存。这意味着您在加载Form
时总是加载相关Questions
。
ABP很好地支持聚合模式,并允许您在全局点为实体配置即时加载。我们可以在模块类的ConfigureServices
方法中编写以下配置(在解决方案的EntityFrameworkCore
项目中):
Configure<AbpEntityOptions>(options =>
{
options.Entity<Form>(orderOptions =>
{
//全局点为实体配置预加载
orderOptions.DefaultWithDetailsFunc = query => query
.Include(f => f.Questions)
.Include(f => f.Owners);
});
});
建议包括所有子集合。如上所示配置DefaultWithDetailsFunc
方法后,将发生以下情况
- 默认情况下,返回单个实体(如
GetAsync
)的存储库方法将加载相关实体,除非通过在方法调用中将includeDetails
参数指定为false
来明确禁用该行为。 - 返回多个实体(如
GetListAsync
)的存储库方法将允许相关实体的即时加载,而默认情况下它们不会即时加载。
下面是一些例子,获取包含子集合的单一表单,如下所示:
//获取一个包含子集合的表单
var form = await _formRepository.GetAsync(formId);
//获取一个没有子集合的表单
var form = await _formRepository.GetAsync(formId, includeDetails: false);
//获取没有子集合的表单列表
var forms = await _formRepository.GetListAsync(f => f.Name.StartsWith("A"));
//获取包含子集合的表单列表
var forms = await _formRepository.GetListAsync(f => f.Name.StartsWith("A"), includeDetails: true);
聚合模式在大多数情况下简化了应用程序代码,而在需要性能优化的情况下,您可以进行微调。请注意,如果真正实现聚合模式,则不会使用导航属性(指向其他聚合),我们将在第10章DDD——领域层中再次回到这个主题。
了解UoW
UoW是ABP用来启动、管理和处理数据库连接和事务的主要系统。UoW采用环境上下文模式(Ambient Context pattern)设计。这意味着,当我们创建一个新的UoW时,它会创建一个作用域上下文,该上下文中共享所有数据库操作=。UoW中完成的所有操作都会一起提交(成功时)或回滚(异常时)。
配置UoW选项
在ASP.NET Core
中,默认设置下,HTTP请求被视为一个UoW。ABP在请求开始时启动UoW,如果请求成功完成,则将更改保存到数据库中。如果请求因异常而失败,它将回滚。
ABP根据HTTP请求类型确定数据库事务使用情况。HTTP GET
请求不会创建数据库事务。UoW仍然可以工作,但在这种情况下不使用数据库事务。如果您没有对所有其他HTTP请求类型(POST
, PUT
, DELETE
和其他)进行配置,则它们将使用数据库事务。
HTTP请求 | 是否创建事务 |
---|---|
GET | 不创建事务 |
PUT | 创建事务 |
POST | 创建事务 |
最好不要在GET请求中更改数据库。如果在一个GET
请求中进行了多个写操作,但请求以某种方式失败,那么数据库状态可能会处于不一致的状态,因为ABP不会为GET
请求创建数据库事务。在这种情况下,可以使用AbpUnitOfWorkDefaultOptions
为GET
请求启用事务,也可以手动控制UoW。
为GET启用请求事务的配置:
在模块(在数据库集成项目中)的ConfigureServices
方法中使用AbpUnitOfWorkDefaultOptions
,如下所示:
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpUnitOfWorkDefaultOptions>(options =>
{
options.TransactionBehavior = UnitOfWorkTransactionBehavior.Enabled;
options.Timeout = 300000; // 5 minutes
options.IsolationLevel = IsolationLevel.Serializable;
});
}
TransactionBehavior的三个值:
Auto
(默认):自动使用事务(为非GET
HTTP
请求启用事务)Enabled
:始终使用事务,即使对于HTTP
GET
请求Disabled
: 从不使用事务
Auto
是默认值,对于大多数应用推荐使用。IsolationLevel
仅对关系数据库有效。如果未指定,ABP将使用基础提供程序的默认值。最后,Timeout
选项允许将事务的默认超时值设置为毫秒,如果UoW操作未在给定的超时值内完成,将引发超时异常。
以上,我们学习了如何在全局配置UOW默认选项,也可以为单个UoW手动配置这些值。
手动控制UoW
对于web应用,一般很少需要手动控制UoW。但是,对于后台作业或非web应用程序,您可能需要自己创建UoW作用域。
使用特性
创建UoW作用域的一种方法是在方法上使用[UnitOfWork]
属性,如下所示:
[UnitOfWork(isTransactional: true)]
public async Task DoItAsync()
{
await _formRepository.InsertAsync(new Form() { ... });
await _formRepository.InsertAsync(new Form() { ... });
}
如果周围的UoW已经就位,那么UnitOfWork
特性将被忽略。否则,ABP会在进入DoItAsync
方法之前启动一个新的事务UoW,并在不引发异常的情况下提交事务。如果该方法引发异常,事务将回滚。
使用注入服务
如果要精细控制UoW,可以注入并使用IUnitOfWorkManager
服务,如以下代码块所示:
public async Task DoItAsync()
{
using (var uow = _unitOfWorkManager.Begin(requiresNew: true,isTransactional: true, timeout: 15000))
{
await _formRepository.InsertAsync(new Form() { });
await _formRepository.InsertAsync(new Form() { });
await uow.CompleteAsync();
}
}
在本例中,我们将启动一个新的事务性UoW作用域,timeout
参数的值为15秒。使用这种用法(requiresNew: true
),ABP总是启动一个新的UoW,即使周围已经有一个UoW。如果一切正常,会调用uow.CompleteAsync()
方法。如果要回滚当前事务,请使用uow.RollbackAsync()
方法。
如前所述,UoW使用环境作用域。您可以使用IUnitOfWorkManager.Current
访问此范围内的任何位置的当前UoW。如果没有正在进行的UoW,则可以为null
。
下面的代码段将SaveChangesAsync
方法与IUnitOfWorkManager.Current
属性一起使用:
await _unitOfWorkManager.Current.SaveChangesAsync();
我们将所有挂起的更改保存到数据库中。但是,如果这是事务性UoW,那么如果回滚UoW或在UoW范围内引发任何异常,这些更改也会回滚。
小结 & 思考
- 小结:ABP 框架可以与任何数据库系统一起工作,同时它提供了与EF Core和MongoDB的内置集成包。
- 思考:假如你不想在应用层依赖EF Core API,或者用的是ABP仓储库该怎么办?