Jackyfei

ABP框架之——数据访问基础架构(下)

大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的一块垫脚石,我们一起精进。

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)显式加载

存储库提供了EnsureRepropertyLoadedAsyncEnsureRecollectionLoadedAsync扩展方法,以显式加载导航属性或子集合。

例如,我们可以显式加载表单的问题,如以下代码块所示:

public async Task<IEnumerable<Question>> GetQuestionsAsync(Form form)
{
	//
    await _formRepository.EnsureCollectionLoadedAsync(form, f => f.Questions);
    return form.Questions;
}

如果不用EnsureCollectionLoadedAsyncQuestions可能是空的,如果已经加载过,不会重复加载,所以多次调用对性能没有影响。

2)延迟加载

延迟加载是EF Core的一项功能,它在您首次访问相关属性和集合时加载它们。默认情况下不启用延迟加载。如果要为DbContext启用它,请执行以下步骤:

  1. 在 EF Core 层中安装Microsoft.EntityFrameworkCore.Proxies
  2. 配置时使用 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 CoreInclude 扩展方法(用于快速加载相关数据)。

假如你不想在应用层依赖EF Core API该怎么办?

在本例中,您有两个选项:

1)IRepository.WithDetailsAsync

IRepositoryWithDetailsSync方法通过包含给定的属性或集合来返回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请求类型(POSTPUTDELETE和其他)进行配置,则它们将使用数据库事务

HTTP请求 是否创建事务
GET 不创建事务
PUT 创建事务
POST 创建事务

最好不要在GET请求中更改数据库。如果在一个GET请求中进行了多个写操作,但请求以某种方式失败,那么数据库状态可能会处于不一致的状态,因为ABP不会为GET请求创建数据库事务。在这种情况下,可以使用AbpUnitOfWorkDefaultOptionsGET请求启用事务,也可以手动控制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仓储库该怎么办?
posted @ 2022-06-23 14:38  张飞洪[厦门]  阅读(2408)  评论(0编辑  收藏  举报