目錄
- Infrastructure Layer 的角色與責任
- ABP 中的 EF Core
- Infrastructure Layer 設計
- 建立 BookStoreScratchDbContext
- Repository 實作
- 測試專案、遷移腳本、DataSeeder
Infrastructure Layer 的角色與責任
在前篇文章中,我們已經完成了領域層的設計。現在回顧一次各個分層概念:
- Domain Layer(領域層):包含商業邏輯、Entity、DomainService。
- Application Layer(應用層):實作 Use Case,不包含商業邏輯細節。
- Infrastructure Layer(基礎設施層):負責技術實作,例如資料庫、檔案系統。
- Presentation Layer(表現層):UI 與外部介面(API、MVC、Blazor)。
基礎設施層本質上是一個外部服務的供應商,它的工作就是支援並滿足上層的需求,所以它只關心「怎麼做」,而不是「為什麼要做」, 因此不應該在這一層看到任何驗證規則或商業邏輯,這些都應該在 Domain 層。
例如一個會員系統需要新增會員時,不應該把檢查會員帳號是否存在的邏輯放在基礎設施層,正確要放在領域層或是應用層, 基礎設施層建議在新增會員失敗後直接拋出插入失敗相關的 Exception 即可。
同時建議使用 Repository Pattern 建立抽象介面並使用依賴反轉避免直接耦合,這樣只需要透過抽象的倉庫介面就能訪問資料庫。
不建議直接在領域層或應用層指定特定的資料庫實做,因為模組應該要保持可替換性,所以資料庫的實做選擇應該要留給模組使用者決定,
假如你直接在模組中已經指定使用 PostgresSQL,那使用你模組的人也只能被迫使用 PostgresSQL,所以在 ABP 中最常見的做法就是模組只依賴
Volo.Abp.EntityFrameworkCore,把資料庫的選擇權留給使用人。
在本系列中,我們使用 BookStoreScratch.EntityFrameworkCore 作為 Infrastructure 專案。
ABP 中的 EF Core
ABP 對 EFCore 做了額外封裝,所以不直接繼承原始的 DbContext,而是繼承 AbpDbContext<T>。
並且有一點需要特別注意,就是 ABP 中的 AbpDbContext 生命週期是註冊為 Transient,並不是微軟官方建議的 Scope,
是因為 ABP 自行實做了 Unit Of Work 功能來達到事務的能力,當一個方法標記為 UnitOfWork 時,ABP 會確保整個 API 請求都在一個
UOW 範圍內,即使請求 AbpDbContext 非常多次,實際上還是共用同一個底層連線與資料庫事務,而不是透過 Scope 的請求週期特性來管理連線與事務。
ABP 是透過 AOP 設計註冊 UOW 攔截器,所以當有新請求時會自動開啟事務,如果有操作資料庫也會在完成時自動呼叫 CompleteAsync,
如果只是一般的 CURD 功能通常只需要繼承 ABP 提供的通用倉庫類別 EfCoreRepository 可以少寫很多基礎的程式碼。
假如只想要純粹使用 EFCore 那只需注入 IDbContextProvider<TDbContext> 並透過 GetDbContext 或 GetDbContextAsync 方法取得連線即可,
這樣 ABP 的 UOW 攔截器才會透過 UnitOfWorkDbContextProvider 介入管理,才能擁有事務的功能。
要注意就算是安裝 Dapper,ABP 背後也是走上面提到的取得連線流程才能獲得事務功能,也就是說 Dapper 在 ABP 中並非獨立存在的,而是作為 EF Core 的輔助,
是直接依賴 DbContext 的,也不建議自行建立 SqlConnection,因為無法與 ABP 的 UOW 狀態同步,除非你打算全部透過 SQL 事務來處理所有功能。
Infrastructure Layer 設計
整理上兩段的內容列出以下設計重點:
- 建立新類別並繼承
AbpDbContext<T>類別。 - 領域層建立抽象的倉庫介面。
- 基礎設施層建立新類別繼承
EfCoreRepository並實做倉庫介面。 - 模組不要安裝特定資料庫函式庫
所以我們的書店會建立以下類別:
BookStoreScratchDbContextIBookStoreScratchEventRepositoryEfCoreBookStoreScratchRepository
建立 BookStoreScratchDbContext
在 BookStoreScratch.EntityFrameworkCore 專案中建立 BookStoreScratchDbContext,並確定繼承 AbpDbContext。
並且在 OnModelCreating 生命週期設定資料表欄位方便建立遷移腳本,並透過 ConfigureByConvention 方法直接設定常用欄位。
using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.Modeling;
namespace BookStoreScratch.EntityFrameworkCore;
public class BookStoreScratchDbContext: AbpDbContext<BookStoreScratchDbContext>
{
public DbSet<Book> Books { get; set; }
public BookStoreScratchDbContext(DbContextOptions<BookStoreScratchDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Book>(b =>
{
b.ToTable("Books");
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
});
}
}
Repository 實作
在 BookStoreScratch.Domain 專案中建立 IBookStoreScratchEventRepository 介面,這邊能有幾種介面能選擇:
- IRepository: 最基礎的介面,什麼方法都沒有定義。
- IReadOnlyBasicRepository: 繼承 IRepository 介面,有額外定義查詢方法。
- IReadOnlyRepository: 繼承 IReadOnlyBasicRepository 介面,額外增加獲取
IQueryable的方法。 - IBasicRepository: 繼承 IReadOnlyBasicRepository 介面,有定義更新、插入、刪除方法。
- IRepository(泛型版): 繼承 IReadOnlyRepository 與 IBasicRepository 介面,功能最多的介面。
實際上只會考慮 IBasicRepository 與 IRepository(泛型版)。
如果考慮的是快速開發那就繼承 IRepository(泛型版),差別就是會不會對外傳 IQueryable,由於延遲查詢的特性所以最終發起連線的地方可能會延遲到領域層或應用層才發起查詢,
導致破壞了基礎設施層的設計意圖,但好處就是開發起來彈性很高。
如果你的專案想要走的是嚴格的 DDD 設計或是預計要使用的資料庫不支援 IQueryable,那建議實做 IBasicRepository,由於不能對外傳 IQueryable
所以要求查詢必須在基礎設施層完成,因此可以確保查詢都封裝在基礎設施層並不會擴散到其他層級去,
缺點就是一段邏輯就要對應一個方法彈性比較差,但是維護起來比較方便並且容易單元測試。
另外注意模組中的倉庫介面最好只使用 Volo.Abp.Domain.Repositories 底下定義的介面,也就是上面列出的那幾種,不要使用到
Volo.Abp.Domain.Repositories.EntityFrameworkCore 底下的 IEfCoreRepository,因為會導致領域層直接依賴 EFCore,
未來如果想要使用 MongoDB 會沒辦法直接轉換。
我們在專案 BookStoreScratch.Domain 建立新介面 IBookStoreScratchEventRepository 選擇繼承 IBasicRepository 介面,並定義一個根據書名找書的方法介面。
using System;
using Volo.Abp.Domain.Repositories;
namespace BookStoreScratch;
public interface IBookStoreScratchEventRepository : IBasicRepository<Book, Guid>
{
Task<Book?> FindByBookNameAsync(Guid id);
}
切換到 BookStoreScratch.EntityFrameworkCore 專案下,新增倉庫實做 EfCoreBookStoreScratchRepository。
這裡可以直接實做 IBookStoreScratchEventRepository 介面,但是需要自己實做所有 CRUD 的方法,由於這些功能功能很單純,
自己實做沒有多大好處,直接繼承 EfCoreRepository 是比較省時省力的選擇。
繼承類別選擇正確的話只需要實做 FindByBookNameAsync 方法。
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace BookStoreScratch.EntityFrameworkCore;
public class EfCoreBookStoreScratchRepository: EfCoreRepository<BookStoreScratchDbContext, Book, Guid>, IBookStoreScratchEventRepository
{
public EfCoreBookStoreScratchRepository(IDbContextProvider<BookStoreScratchDbContext> dbContextProvider) : base(dbContextProvider)
{
}
public async Task<Book?> FindByBookNameAsync(string name)
{
return await (await GetDbSetAsync())
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Name == name);
}
}
測試專案、遷移腳本、DataSeeder
在一般的應用程式開發流程中,會順便在基礎設施層安裝 Microsoft.EntityFrameworkCore.Tools 初始化遷移腳本,但是我們目前開發的是一個模組,
要站在使用者的角度看待,由於模組最終要整合在使用者的資料庫或是獨立一個資料庫是由使用者自行決定,因此我們在模組中建立遷移腳本沒有任何意義。
不過我們可以嘗試建立一個測試專案來測試資料庫流程,這樣做也有另一個好處就是分離模組開發者跟應用程式開發者的工作流程, 可以先在模組完整測試後在安裝到應用程式內,避免一堆模組互相干擾。
大部分建立的過程跟第一章建立的 Console 專案一致,由於要透過 DataSeeder 新增預設的資料,因此這裡安裝 Volo.Abp.Autofac 方便使用依賴注入。
dotnet new worker -o app/BookStoreScratch.DbMigrator
dotnet sln add app/BookStoreScratch.DbMigrator
rm app/BookStoreScratch.DbMigrator/Worker.cs
rm app/BookStoreScratch.DbMigrator/Program.cs
dotnet add app/BookStoreScratch.DbMigrator package Volo.Abp.Autofac
dotnet add app/BookStoreScratch.DbMigrator package Serilog.Extensions.Hosting
dotnet add app/BookStoreScratch.DbMigrator package Serilog.Extensions.Logging
dotnet add app/BookStoreScratch.DbMigrator package Serilog.Sinks.Async
dotnet add app/BookStoreScratch.DbMigrator package Serilog.Sinks.Console
dotnet add app/BookStoreScratch.DbMigrator package Microsoft.EntityFrameworkCore.Tools
dotnet add app/BookStoreScratch.DbMigrator package Volo.Abp.EntityFrameworkCore.PostgreSql
dotnet add app/BookStoreScratch.DbMigrator/BookStoreScratch.DbMigrator.csproj reference src/BookStoreScratch.EntityFrameworkCore/BookStoreScratch.EntityFrameworkCore.csproj
接下來要模擬使用者安裝我們模組的流程,在微服務設計中會把資料分散到多個資料庫中,並不會集中到一個 DBContext 內,
但在傳統應用中會考慮將所有表集中到使用者自己的 DBContext 內,那就不需要用到我們定義的 BookStoreScratchDbContext 了,
所以我們要把模型的定義提取出來不要再放在 BookStoreScratchDbContext,以便兼容兩種應用。
在 BookStoreScratch.EntityFrameworkCore 建立 BookStoreScratchDbContextModelCreatingExtensions 用來放定義模型的擴充方法。
using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.Modeling;
namespace BookStoreScratch.EntityFrameworkCore;
public static class BookStoreScratchDbContextModelCreatingExtensions
{
public static void ConfigureBookStoreScratch(this ModelBuilder builder)
{
builder.Entity<Book>(b =>
{
b.ToTable("Books");
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
});
}
}
修改 BookStoreScratchDbContext 改用擴充方法定義模型。
using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace BookStoreScratch.EntityFrameworkCore;
public class BookStoreScratchDbContext : AbpDbContext<BookStoreScratchDbContext>
{
public DbSet<Book> Books { get; set; }
public BookStoreScratchDbContext(DbContextOptions<BookStoreScratchDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureBookStoreScratch();
}
}
接下來回到 BookStoreScratch.DbMigrator 專案中,我們將要模擬使用者使用自己 DBContext 的情況。
建立 UnifiedDbContext 類別,可以看到跟我們自己的 BookStoreScratchDbContext 幾乎一樣,但是實際上會透過模型的擴充方法將所有模型
都集中到 UnifiedDbContext 內部,類如我們要用 ABP 會員系統就只要呼叫 builder.ConfigureIdentity() 設定模型即可
using BookStoreScratch.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace BookStoreScratch.DbMigrator;
public class UnifiedDbContext : AbpDbContext<UnifiedDbContext>
{
public UnifiedDbContext(DbContextOptions<UnifiedDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureBookStoreScratch();
}
}
建立 UnifiedDbContextFactory,EFCore 要此類別才能建立或更新遷移腳本
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace BookStoreScratch.DbMigrator;
public class UnifiedDbContextFactory : IDesignTimeDbContextFactory<UnifiedDbContext>
{
public UnifiedDbContext CreateDbContext(string[] args)
{
// https://www.npgsql.org/efcore/release-notes/6.0.html#opting-out-of-the-new-timestamp-mapping-logic
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
var configuration = BuildConfiguration();
var builder = new DbContextOptionsBuilder<UnifiedDbContext>()
.UseNpgsql(configuration.GetConnectionString("Default"));
return new UnifiedDbContext(builder.Options);
}
private static IConfigurationRoot BuildConfiguration()
{
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false);
return builder.Build();
}
}
appsettings.json 新增連線字串
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=BookStoreScratch;User ID=postgres;Password=myPassw0rd;"
}
}
建立遷移腳本並更新資料庫,完成後會看到 Books 資料表建立成功。
dotnet ef migrations add Initial
dotnet ef database update
接下來準備插入測試資料,可以使用 ABP 的 IDataSeedContributor 介面,ABP 會自動註冊實做 IDataSeedContributor 介面的類別,
未來只要注入 IDataSeeder 服務就能取得所有 Contributor,非常適合用在模組中,自動新增必要的資料,
像是 Identity 模組就會註冊一個建立 admin 會員的 Contributor,這樣就算使用者完全不了解 Identity 模組也可以使用預設的 admin 會員。
在專案 BookStoreScratch.Domain 建立 BookStoreDataSeedContributor,它會在啟動時自動插入一本預設的書,
另外透過注入 BookManager 建立書籍,這樣能夠共用檢查邏輯。
using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
namespace BookStoreScratch;
public class BookStoreDataSeedContributor : IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly ICurrentTenant _currentTenant;
private readonly BookManager _bookManager;
public BookStoreDataSeedContributor(
IRepository<Book, Guid> bookRepository,
ICurrentTenant currentTenant,
BookManager bookManager)
{
_bookRepository = bookRepository;
_currentTenant = currentTenant;
_bookManager = bookManager;
}
public async Task SeedAsync(DataSeedContext context)
{
using (_currentTenant.Change(context?.TenantId))
{
var book = _bookManager.CreateBook(
"The Hitchhiker's Guide to the Galaxy",
BookType.ScienceFiction,
new DateTime(1979, 10, 12),
price: 42
);
await _bookRepository.InsertAsync(book);
}
}
}
新增 Program.cs,這裡我們將要採用 ABP 另一種啟動方式,首先注入一個 IHostedService
它的內部會使用工廠模式設定好 Autofac 環境再啟動 Seeder,這種方式不會把服務註冊到 Generic Host 內,等於外層的 Host 是一個空殼,
真正的 ABP 是透過工廠模式啟動,對於這種工具式的程式比較方便切換環境。
using Serilog;
using Serilog.Events;
namespace BookStoreScratch.DbMigrator;
class Program
{
static async Task Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Volo.Abp", LogEventLevel.Warning)
#if DEBUG
.MinimumLevel.Override("EventHub", LogEventLevel.Debug)
#else
.MinimumLevel.Override("EventHub", LogEventLevel.Information)
#endif
.Enrich.FromLogContext()
.WriteTo.Async(c => c.Console())
.CreateLogger();
await CreateHostBuilder(args).RunConsoleAsync();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.AddAppSettingsSecretsJson()
.ConfigureLogging((context, logging) => logging.ClearProviders())
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<DbMigratorHostedService>();
});
}
新增 DbMigratorHostedService,要注意我們用的是工廠模式所以實際的 DI 是保存在 application 的 ServiceProvider 內部,
並不是 Host 環境,所以使用平常的注入方式會跳出依賴報錯。
using Serilog;
using Volo.Abp;
using Volo.Abp.Data;
namespace BookStoreScratch.DbMigrator;
public class DbMigratorHostedService : IHostedService
{
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IConfiguration _configuration;
public DbMigratorHostedService(IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration)
{
_hostApplicationLifetime = hostApplicationLifetime;
_configuration = configuration;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using (var application = await AbpApplicationFactory.CreateAsync<DbMigratorModule>(options =>
{
options.Services.ReplaceConfiguration(_configuration);
options.UseAutofac();
options.Services.AddLogging(c => c.AddSerilog());
options.AddDataMigrationEnvironment();
}))
{
await application.InitializeAsync();
// 初始完畢可以開始執行程式
await application
.ServiceProvider
.GetRequiredService<IDataSeeder>()
.SeedAsync();
await application.ShutdownAsync();
_hostApplicationLifetime.StopApplication();
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
建立 DbMigratorModule 並依賴基礎設施層與 Autofac 模組,另外我們要模擬使用者的應用程式,所以在這個專案已經決定要使用 PostgreSql,
因此也要添加依賴並呼叫 UseNpgsql 方法啟動資料庫服務。
using BookStoreScratch.EntityFrameworkCore;
using Volo.Abp.Autofac;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.PostgreSql;
using Volo.Abp.Modularity;
namespace BookStoreScratch.DbMigrator;
[DependsOn(
typeof(AbpAutofacModule),
typeof(AbpEntityFrameworkCorePostgreSqlModule),
typeof(BookStoreScratchEntityFrameworkCoreModule)
)]
public class DbMigratorModule : AbpModule
{
public override void PreConfigureServices(ServiceConfigurationContext context)
{
// https://www.npgsql.org/efcore/release-notes/6.0.html#opting-out-of-the-new-timestamp-mapping-logic
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpDbContextOptions>(options =>
{
options.UseNpgsql();
});
}
}
完成後只要啟動程式,到資料庫查詢就會發現有一筆新的資料出現了。
本次文章進度 Github