ABP框架之——資料訪問基礎架構(下)

張飛洪[廈門] 發表於 2022-06-23
框架

大家好,我是張飛洪,感謝您的閱讀,我會不定期和你分享學習心得,希望我的文章能成為你成長路上的一塊墊腳石,我們一起精進。

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倉儲庫該怎麼辦?