共用方式為


使用 Entity Framework Core 實作基礎結構持續性層

小提示

此內容是適用於容器化 .NET 應用程式的電子書.NET 微服務架構摘錄,可在 .NET Docs 或免費下載的 PDF 中取得,可脫機讀取。

.NET 微服務架構的容器化 .NET 應用程式電子書封面縮圖。

當您使用 SQL Server、Oracle 或 PostgreSQL 等關係資料庫時,建議的方法是根據 Entity Framework (EF) 實作持續性層。 EF 支援 LINQ,並為您的模型提供強類型物件,並簡化進行資料庫持久化的過程。

Entity Framework 在 .NET Framework 中有著悠久的歷史。 當您使用 .NET 時,也應該使用 Entity Framework Core,它以與 .NET 相同的方式在 Windows 或 Linux 上執行。 EF Core 是 Entity Framework 的全面重寫,其具備更輕量的特性並在效能上有顯著的提升。

Entity Framework Core 簡介

Entity Framework (EF) Core 是熱門 Entity Framework 數據存取技術的輕量型、可延伸和跨平臺版本。 它於 2016 年年中與 .NET Core 一起推出。

由於 EF Core 的簡介已在Microsoft檔中提供,因此我們在這裡只提供該信息的連結。

其他資源

從 DDD 的觀點來看,Entity Framework Core 中的基礎結構

從 DDD 的觀點來看,EF 的重要功能是能夠使用 POCO 網域實體,也稱為 EF 術語中的 POCO 程式代碼優先實體。 如果您使用 POCO 定義域實體,您的領域模型類別對持續性和基礎架構不敏感,遵循 持續性不敏感基礎架構不敏感 原則。

根據 DDD 模式,您應該在實體類別本身內封裝定義域行為和規則,以便在存取任何集合時控制不變異、驗證和規則。 因此,在 DDD 中,不允許公開存取子實體或值物件的集合,因為這不是一個好的作法。 相反地,您想要公開方法,以控制字段和屬性集合的更新方式和時機,以及在發生這種情況時應該發生哪些行為和動作。

自EF Core 1.1起,為了滿足這些DDD需求,您可以在實體中使用直接字段而不是公用屬性。 如果您不想讓實體欄位在外部存取,您可以只建立屬性或欄位,而不是屬性。 您也可以使用私有屬性的設值方法。

同樣地,現在您可以使用類型為 IReadOnlyCollection<T> 的公用屬性來取得集合的唯讀存取,該屬性由依賴於 EF 持久化的實體中集合的私有字段成員支援。 舊版 Entity Framework 需要支援 ICollection<T>集合屬性,這表示任何使用父實體類別的開發人員都可以透過其屬性集合來新增或移除專案。 這種可能性會違反 DDD 中建議的模式。

您可以在公開唯讀 IReadOnlyCollection<T> 物件時使用私用集合,如下列程式代碼範例所示:

public class Order : Entity
{
    // Using private fields, allowed since EF Core 1.1
    private DateTime _orderDate;
    // Other fields ...

    private readonly List<OrderItem> _orderItems;
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

    protected Order() { }

    public Order(int buyerId, int paymentMethodId, Address address)
    {
        // Initializations ...
    }

    public void AddOrderItem(int productId, string productName,
                             decimal unitPrice, decimal discount,
                             string pictureUrl, int units = 1)
    {
        // Validation logic...

        var orderItem = new OrderItem(productId, productName,
                                      unitPrice, discount,
                                      pictureUrl, units);
        _orderItems.Add(orderItem);
    }
}

屬性 OrderItems 只能使用 IReadOnlyCollection<OrderItem>以唯讀方式存取。 此類型是只讀的,因此會受到保護,以防止一般外部更新。

EF Core 提供將領域模型對應至實體資料庫的方式,而不需要「污染」領域模型。 它是純 .NET POCO 程式碼,因為對應動作是在持久層中實作。 在該對應動作中,您必須設定欄位與資料庫的對應關係。 在 OnModelCreating 類別中的 OrderingContext 方法範例裡,對 OrderEntityTypeConfiguration 的呼叫會指示 EF Core 透過欄位存取 SetPropertyAccessMode 屬性。

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
        // Other configuration

        var navigation =
              orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        //EF access the OrderItem collection property through its backing field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        // Other configuration
    }
}

當您使用欄位而非屬性時, OrderItem 實體會保存為具有 List<OrderItem> 屬性一樣。 它會提供一個單一的存取子 AddOrderItem 方法,用於將新項目加入訂單。 因此,行為和數據會系結在一起,而且會在使用定義域模型的任何應用程式程序代碼中保持一致。

使用 Entity Framework Core 實作自定義存放庫

在實作層級,存放庫只是一個類別,其數據持續性程式代碼在執行更新時會由工作單位 (EF Core 中的 DBContext) 協調,如下列類別所示:

// using directives...
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
    public class BuyerRepository : IBuyerRepository
    {
        private readonly OrderingContext _context;
        public IUnitOfWork UnitOfWork
        {
            get
            {
                return _context;
            }
        }

        public BuyerRepository(OrderingContext context)
        {
            _context = context ?? throw new ArgumentNullException(nameof(context));
        }

        public Buyer Add(Buyer buyer)
        {
            return _context.Buyers.Add(buyer).Entity;
        }

        public async Task<Buyer> FindAsync(string buyerIdentityGuid)
        {
            var buyer = await _context.Buyers
                .Include(b => b.Payments)
                .Where(b => b.FullName == buyerIdentityGuid)
                .SingleOrDefaultAsync();

            return buyer;
        }
    }
}

介面 IBuyerRepository 來自領域模型層做為合約。 不過,存放庫實作是在持續性和基礎結構層完成。

EF DbContext 是透過相依性注入(Dependency Injection, DI)傳入建構函式的。 由於 IoC 容器中的預設存留期(ServiceLifetime.Scoped),它在相同 HTTP 請求範圍內的多個資料庫之間共用,也可以透過明確設定 services.AddDbContext<>

在儲存庫中實作的方法(更新或交易對比查詢)

在每個存放庫類別中,您應該放置持續性方法,以更新其相關匯總所包含的實體狀態。 請記住,匯總與其相關存放庫之間有一對一關聯性。 請考慮匯總根實體物件在其 EF 圖形中可能有內嵌的子實體。 例如,買家可能會有多個付款方式作為相關的子實體。

由於 eShopOnContainers 中訂購微服務的方法也是以 CQS/CQRS 為基礎,因此大部分查詢都不會在自定義存放庫中實作。 開發人員可以自由地建立呈現層所需的查詢和聯結,而不需要匯總、每個匯總的自定義存放庫和一般 DDD 所施加的限制。 本指南建議的大部分自定義存放庫中,雖然都有多種更新或交易方法,但其查詢方法則僅用於獲取需要更新的數據。 例如,BuyerRepository 存放庫會實作 FindAsync 方法,因為應用程式必須知道特定買家是否存在,才能建立與訂單相關的新買家。

然而,正如所述,真正的查詢方法是透過使用 Dapper 的彈性查詢在 CQRS 查詢中實作,以便將數據傳送到表示層或客戶端應用程式。

使用自訂儲存庫相較於直接使用 EF DbContext

Entity Framework DbContext 類別是以工作單位和存放庫模式為基礎,可以直接從程式代碼使用,例如從 ASP.NET Core MVC 控制器使用。 工作單位和存放庫模式會產生最簡單的程序代碼,如 eShopOnContainers 中的 CRUD 目錄微服務所示。 如果您想要盡可能使用最簡單的程式代碼,您可能會想要像許多開發人員一樣直接使用 DbContext 類別。

不過,實作自定義存放庫可在實作更複雜的微服務或應用程式時提供數個優點。 工作單位和存放庫模式旨在封裝基礎結構持續性層,使其與應用程式和領域模型層分離。 實作這些模式有助於使用模擬數據庫存取的模擬存放庫。

在圖 7-18 中,您可以看到不使用存放庫(直接使用 EF DbContext) 與使用存放庫之間的差異,這可讓您更輕鬆地模擬這些存放庫。

此圖顯示兩個存放庫中的元件和數據流。

圖 7-18。 使用自訂儲存庫與通常的 DbContext

圖 7-18 顯示,使用自定義存放庫會新增抽象層,可用來模擬存放庫來簡化測試。 在進行模擬測試時,有多種選擇方案。 您可以只模擬存放庫,也可以模擬整個工作單位。 通常只模擬存放庫就夠了,而且通常不需要抽象化和模擬整個工作單位的複雜性。

稍後,當我們專注於應用層時,您會看到相依性插入在 ASP.NET Core 中的運作方式,以及它在使用存放庫時實作的方式。

簡言之,自定義存放庫可讓您更輕鬆地使用不受數據層狀態影響的單元測試來測試程序代碼。 如果您執行的測試也會透過 Entity Framework 存取實際資料庫,它們不是單元測試,而是整合測試,速度會變慢很多。

如果您直接使用 DbContext,則必須模擬它,或使用記憶體內部 SQL Server 搭配單元測試的可預測數據來執行單元測試。 但是,模擬 DbContext 或控制假數據需要比在資料庫層級進行模擬花更多的功夫。 當然,您隨時可以測試MVC控制器。

IoC 容器中的 EF DbContext 和 IUnitOfWork 實例存留期

DbContext物件(公開為IUnitOfWork物件)應該在相同 HTTP 要求範圍內的多個存放庫之間共用。 例如,這在以下情況中是真實的:執行的操作必須處理多個聚合,或者僅僅因為您正在使用多個存儲庫實例。 此外,請務必提及 IUnitOfWork 介面是網域層的一部分,而不是EF Core 類型。

若要這樣做,對象的實例 DbContext 必須將其服務存留期設定為 ServiceLifetime.Scoped。 在 ASP.NET Core Web API 專案中,從DbContext檔案向 IoC 容器註冊builder.Services.AddDbContext時,這是預設的存留期。 下列程式代碼說明這點。

// Add framework services.
builder.Services.AddMvc(options =>
{
    options.Filters.Add(typeof(HttpGlobalExceptionFilter));
}).AddControllersAsServices();

builder.Services.AddEntityFrameworkSqlServer()
    .AddDbContext<OrderingContext>(options =>
    {
        options.UseSqlServer(Configuration["ConnectionString"],
                            sqlOptions => sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().
                                                                                Assembly.GetName().Name));
    },
    ServiceLifetime.Scoped // Note that Scoped is the default choice
                            // in AddDbContext. It is shown here only for
                            // pedagogic purposes.
    );

DbContext 具現化模式不應設定為 ServiceLifetime.Transient 或 ServiceLifetime.Singleton。

IoC 容器中的存放庫實例存留期

同樣地,存放庫的存留期通常應該設定為範圍 (Autofac 中的 InstancePerLifetimeScope)。 它也可能是暫時性的 (Autofac 中的 InstancePerDependency),但在使用限定範圍的存留期時,您的服務在記憶體方面會更有效率。

// Registering a Repository in Autofac IoC container
builder.RegisterType<OrderRepository>()
    .As<IOrderRepository>()
    .InstancePerLifetimeScope();

當您的 DbContext 設定為範圍存留期 (InstancePerLifetimeScope,這是 DBContext 的預設存留期) 時,使用資源庫的單例存留期可能會引發嚴重的並發問題。 只要存放庫和 DbContext 的服務存留期都已設定為 Scoped,您就會避免這些問題。

其他資源

表格映射

數據表對應會識別要從中查詢並儲存至資料庫的數據表數據。 您先前已瞭解如何使用網域實體(例如產品或訂單網域)來產生相關的資料庫架構。 EF 是針對 慣例概念進行強式設計。 慣例會解決「數據表的名稱為何?」或「主鍵是什麼屬性?」之類的問題。慣例通常是以傳統名稱為基礎。 例如,主鍵通常是以 Id結尾的屬性。

根據慣例,每個實體都會設定為對應至與在衍生內容上公開實體的屬性名稱相同的 DbSet<TEntity> 數據表。 DbSet<TEntity>如果未為指定的實體提供任何值,則會使用類別名稱。

資料註解與 Fluent API

有許多額外的 EF Core 慣例,其中大部分都可以使用在 OnModelCreating 方法內實作的數據批注或 Fluent API 來變更。

數據批註必須用於實體模型類別本身,這是從 DDD 觀點更侵入的方式。 這是因為您正使用與基礎結構資料庫相關的數據批注來污染模型。 另一方面,Fluent API 是一種便利方式,可以變更數據持久層內的大部分慣例和映射,從而使實體模型保持乾淨並與持久層解耦。

Fluent API 和 OnModelCreating 方法

如前所述,若要變更慣例和對應,您可以在 DbContext 類別中使用 OnModelCreating 方法。

eShopOnContainers 中的排序微服務會視需要實作明確的對應和組態,如下列程式代碼所示。

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);

        orderConfiguration.HasKey(o => o.Id);

        orderConfiguration.Ignore(b => b.DomainEvents);

        orderConfiguration.Property(o => o.Id)
            .UseHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

        //Address value object persisted as owned entity type supported since EF Core 2.0
        orderConfiguration
            .OwnsOne(o => o.Address, a =>
            {
                a.WithOwner();
            });

        orderConfiguration
            .Property<int?>("_buyerId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("BuyerId")
            .IsRequired(false);

        orderConfiguration
            .Property<DateTime>("_orderDate")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderDate")
            .IsRequired();

        orderConfiguration
            .Property<int>("_orderStatusId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderStatusId")
            .IsRequired();

        orderConfiguration
            .Property<int?>("_paymentMethodId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("PaymentMethodId")
            .IsRequired(false);

        orderConfiguration.Property<string>("Description").IsRequired(false);

        var navigation = orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        // DDD Patterns comment:
        //Set as field (New since EF 1.1) to access the OrderItem collection property through its field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        orderConfiguration.HasOne<PaymentMethod>()
            .WithMany()
            .HasForeignKey("_paymentMethodId")
            .IsRequired(false)
            .OnDelete(DeleteBehavior.Restrict);

        orderConfiguration.HasOne<Buyer>()
            .WithMany()
            .IsRequired(false)
            .HasForeignKey("_buyerId");

        orderConfiguration.HasOne(o => o.OrderStatus)
            .WithMany()
            .HasForeignKey("_orderStatusId");
    }
}

您可以在相同的 OnModelCreating 方法內設定所有 Fluent API 對應,但建議您分割該程式代碼並具有多個組態類別,每個實體各一個組態類別,如範例所示。 特別是對於大型模型,建議有個別的組態類別來設定不同的實體類型。

範例中的程式代碼會顯示一些明確的宣告和對應。 不過,EF Core 慣例會自動執行其中許多對應,因此您案例中所需的實際程式代碼可能較小。

EF Core 中的 Hi/Lo 演算法

上述範例中程式代碼的一個有趣層面是,它會使用 Hi/Lo演演算法 作為密鑰產生策略。

當您在認可變更之前需要使用 Hi/Lo 演算法來產生唯一索引鍵時,這個演算法很有用。 摘要來說,Hi-Lo 演算法會將唯一標識符指派給表列,而不需依賴立即將列寫入資料庫中。 這可讓您立即開始使用標識碼,就像一般循序資料庫標識符一樣。

Hi/Lo 演算法描述從相關資料庫序列取得一批唯一標識符的機制。 這些標識碼是安全的使用,因為資料庫保證唯一性,因此用戶之間不會發生衝突。 基於下列原因,此演算法很有趣:

  • 它不會中斷工作單位模式。

  • 它會分批取得序列標識符,以將資料庫的來回行程降至最低。

  • 它會產生人類可讀取的標識碼,與使用 GUID 的技術不同。

EF Core 支援使用方法UseHiLo,如上述範例所示。

對應欄位而非屬性

透過這項功能,自EF Core 1.1 起,您可以直接將數據行對應至欄位。 不可能在實體類別中使用屬性,而只能將數據表中的數據行對應至欄位。 的常見用法是不需要從實體外部存取之任何內部狀態的私人欄位。

您可以使用單一欄位或集合來執行此動作,例如 List<> 欄位。 當我們稍早討論建立領域模型類別時曾提及這一點,而在這裡您可以看到該對應如何透過先前程式碼中已醒目顯示的PropertyAccessMode.Field組態來執行。

在 EF Core 中使用陰影屬性,隱藏在基礎結構層級

EF Core 中的陰影屬性是實體類別模型中不存在的屬性。 這些屬性的值和狀態純粹會保留在基礎結構層級的 ChangeTracker 類別中。

實作查詢規格模式

如設計一節稍早所介紹,查詢規格模式是一種 Domain-Driven 設計模式,其設計方式是可讓您使用選擇性排序和分頁邏輯來放置查詢定義的位置。

查詢規格模式會定義 物件中的查詢。 例如,為了封裝搜尋某些產品的分頁查詢,您可以建立一個 PagedProduct 規格,以接受必要的輸入參數(頁碼、頁面大小、篩選條件等等)。 然後,在任何儲存庫方法(通常是 List() 重載)中,它會接受 IQuerySpecification,並根據該規格執行預期的查詢。

泛型 Specification 介面的範例是下列程式代碼,類似於 eShopOnWeb 參考應用程式中所使用的程式代碼。

// GENERIC SPECIFICATION INTERFACE
// https://github.com/dotnet-architecture/eShopOnWeb

public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    List<string> IncludeStrings { get; }
}

然後,泛型規格基類的實作如下。

// GENERIC SPECIFICATION IMPLEMENTATION (BASE CLASS)
// https://github.com/dotnet-architecture/eShopOnWeb

public abstract class BaseSpecification<T> : ISpecification<T>
{
    public BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }
    public Expression<Func<T, bool>> Criteria { get; }

    public List<Expression<Func<T, object>>> Includes { get; } =
                                           new List<Expression<Func<T, object>>>();

    public List<string> IncludeStrings { get; } = new List<string>();

    protected virtual void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }

    // string-based includes allow for including children of children
    // for example, Basket.Items.Product
    protected virtual void AddInclude(string includeString)
    {
        IncludeStrings.Add(includeString);
    }
}

下列規格會載入一個購物籃實體,這是根據購物籃的標識碼或購物籃所屬買家的標識碼來決定的。 它會 急切地載入 籃子的 Items 集合。

// SAMPLE QUERY SPECIFICATION IMPLEMENTATION

public class BasketWithItemsSpecification : BaseSpecification<Basket>
{
    public BasketWithItemsSpecification(int basketId)
        : base(b => b.Id == basketId)
    {
        AddInclude(b => b.Items);
    }

    public BasketWithItemsSpecification(string buyerId)
        : base(b => b.BuyerId == buyerId)
    {
        AddInclude(b => b.Items);
    }
}

最後,您可以在下方查看泛型 EF 存放庫如何利用這類規格來篩選以及預先載入與特定實體類型 T 相關的數據。

// GENERIC EF REPOSITORY WITH SPECIFICATION
// https://github.com/dotnet-architecture/eShopOnWeb

public IEnumerable<T> List(ISpecification<T> spec)
{
    // fetch a Queryable that includes all expression-based includes
    var queryableResultWithIncludes = spec.Includes
        .Aggregate(_dbContext.Set<T>().AsQueryable(),
            (current, include) => current.Include(include));

    // modify the IQueryable to include any string-based include statements
    var secondaryResult = spec.IncludeStrings
        .Aggregate(queryableResultWithIncludes,
            (current, include) => current.Include(include));

    // return the result of the query using the specification's criteria expression
    return secondaryResult
                    .Where(spec.Criteria)
                    .AsEnumerable();
}

除了封裝篩選邏輯之外,規格還可以指定要傳回之數據的形狀,包括要填入的屬性。

雖然我們不建議您從存放庫中返回 IQueryable,但在存放庫內使用它們來建立一組結果完全沒有問題。 您可以在上述 List 方法中看到此方法,此方法會使用中繼 IQueryable 表示式來建置查詢的 include 清單,再於最後一行以規格的準則執行查詢。

瞭解 eShopOnWeb 範例中如何套用規格模式

其他資源