Model 是 Entity Framework 運作的核心,EF Core 提供兩種建立 Model 做法:Code First 或逆向工程。 前者從程式需求出發設計及修改 Model,經由 Migration 機制生成建立及修改資料庫 Schema 指令,將資料庫變成我們想要的形狀。 逆向工程較偏向傳統資料庫開發,系統分析完先定義資料規格,在資料庫建好資料表,再依據它建立對映的 Model 物件。

EF Core 提供三種設定 Model 方式:慣例 (Convension)、Fluent API 及 Data Annotation(資料註解,以 Attribute 形式加註於 Property),有些功能支援多種設定方式,有些則只能透過 Fluent API 達成。 Fluent API 要寫在 DbContext.OnModelCreating(),例如:

class MyContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>() //Fluent API
            .Property(b => b.Url)
            .IsRequired();
    }
}
public class Blog //Entity 型別
{
    //慣例,屬性名稱為 Id 或 <type name>Id 會自動成為 Entity 的 Key
    public int BlogId { get; set; } 
    public string Url { get; set; }
    [Required] //Data Annotation
    public string Blogger { get; set; }
}

Fluent API 的優先權最高,可覆寫慣例及資料註解設定。

以下整理 Entity 設計相關設定:

  1. 包含及排除特定型別
    依慣例,在 DbConent 宣告 DbSet<TEntity>,TEntity 即會被包含在 Model 範圍,TEntity 屬性涉及的其他型別也會被包含進來。OnModelCreating() modelBuilder.Entity<TMoreEntity>() 亦有將 TMoreEntity 納入的效果。
    在類別加註 [NotMapped] 可排除指定類別,Fluent API 寫法為 modelBuilder.Ignore<TExcludeEnityt>
  2. 包含及排除屬性(Property)
    依慣例,public 且具有 get/set 的屬性會被包含於 Model,必要時可用 [NotMapped],Fluent API 寫法為 modelBuilder.Entity<Blog>().Ignore(b => b.LoadedFromDatabase);
  3. Key (索引鍵,Primary Key)
    依慣例,名為 Id 或 <type name>Id 的屬性會自動成為 Primary Key,透過加註 [Key] 也可指定。Fluent API 寫法為 modelBuilder.Entity<Car>().HasKey(c => c.LicensePlate);
    要多屬性組成複合 PK 只能使用 Fluent API:modelBuilder.Entity<Car>().HasKey(c => new { c.State, c.LicensePlate });
  4. 自動產生值
    屬性可設為「新增自動給值」或是「新增及更新都自動給值」。 屬性值可從 EF 端也可在 DB 端產生,若是在 DB 端產生(例如自動跳號、Schema 指定 GETDATE() 作為日期欄位預設值),EF 會先給一個暫時值,SaveChanges() 後再置換成 DB 產生的結果。 若加入 DbContext 時有指定值(string != null, int != 0, Guid != Guie.Empty),EF Core 會試著使用指定的值寫入資料庫。
    新增及更新都自動給值需依賴 DB 端設計,例如:SQL Server 每次更新會變化的 rowversion 資料型別、Trigger ... 等等。
    依慣例,當 Primary Key 為 short、int、long 或 Guid 時預設為新增時自動給值,加註 [DatabaseGenerated(DatabaseGeneratedOption.None)] 可取消之。 [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 可宣告屬性為新增時自動給值,[DatabaseGenerated(DatabaseGeneratedOption.Computed)] 則是新增及更新時自動給值。
    Fluent API 寫法為 modelBuilder.Entity<Blog>().Property(b => b.BlogId)..ValueGeneratedNever() 或 .ValueGeneratedOnAdd() 或 .ValueGeneratedOnAddOrUpdate()
  5. 必要屬性及選擇性屬性(是否允許值為 null)
    依慣例,型別可以為 null 的屬性為選擇性(例如:string, int?, byte[]...),反之為必要屬性(如:int, decimal, bool...),資料註解寫法為 [Required],Fluent API 為 .IsRequired()。
  6. 最大長度
    適用 string 及 byte[],主要用於資料表 Script 指定欄位長度,EF 本身不會對長度進行檢核。若未以 [MaxLength(長度)] 指定,SQL 會指定 nvarchar(max)、PK 屬性則是 nvarchar(450),Fluent API 寫法為 HasMaxLength(長度)。
  7. Concurrency Token
    Concurrency Token 用於衝突管理,可於寫入前檢查額外欄位是否被異動,防止多方寫入資料彼此覆寫。資料註解為 [ConcurrencyCheck],Fluent API 為 .IsConcurrencyToken()。
    另外,SQL 提供了 rowversion 型別,在 byte[] 屬性透過 [TimeStamp] 或 .IsRowVersion() 用於避免資料衝突。
  8. Shadow 屬性
    指未定義在 Entity 類別需透過 Change Tracker 維護及存取的隱藏屬性,一個例子是用於建立 Entity 關聯的 Foreign Key 屬性。
    如下例,Post 類別會產生 Shadow 屬性 - BlogId:
    public class Blog
    {
        public int BlogId { get; set; }
        public string Url { get; set; }
    
        public List<Post> Posts { get; set; }
    }
    
    public class Post
    {
        public int PostId { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }
    
        public Blog Blog { get; set; }
    }
    
    自訂 Shadow 屬性只能使用 Fluent API 宣告,程式存取要透過 ChangeTracker API,例如 context.Entry(myBlog).Property("LastUpdated").CurrentValue = DateTime.Now;var blogs = ontext.Blogs.OrderBy(b => EF.Property<DateTime>(b, "LastUpdated"));
  9. 索引
    依慣例,EF Core 會為 Foreign Key 會建立索引。 索引不能用資料註解宣告。 Fluent API 寫法為:HasIndex(b => b.Url)、HasIndex(b => b.Url).IsUnique() (不重複索引)、.HasIndex(p => new { p.FirstName, p.LastName }) (複合式索引)
    注意:每組屬性組合只能定義一個索引,後宣告者會壓過慣例或原本設定。
  10. Alternative Key (替代索引鍵)
    Primary Key 之外的其他不重複 Key,可用於 Foreign Key 關聯,需透過 Fluent API HasForeignKey() 指定。 若不透過 Foreign Key 指定,純粹只是要增加 Unique Index,作法為 modelBuilder.Entity<Car>().HasAlternateKey(c => c.LicensePlate);。
  11. 繼承關係
    當 Entity 間有繼承關係,對映資料表方式由資料庫提供者決定。依慣例父子型別都有設為 DbSet<T> EF Core 會自行判斷,否則可用 modelBuilder.Entity<RssBlog>().HasBaseType<Blog>() 宣告,.HasBaseType((Type)null) 則可移除繼承關係。
  12. Backing Field 支援欄位
    將資料庫讀寫的對象改為欄位(Field)而非屬性(Property)。 依慣例如有 _<小寫屬性名>、_<屬性名>、m_<小寫屬性名>、m_<屬性名> 欄位名與屬性名並存,EF Core 從資料庫讀取資料時會寫入這些欄位,讀入後的更新動作則試著透過屬性指定, 若屬性被設為唯讀,則更新至欄位。Fluent API .HasField("_validatedUrl")、.UsePropertyAccessMode(PropertyAccessMode.Field) 控制存取方式。
    特殊用法:將欄位當屬性用,但不設 Property set/get,modelBuilder.Entity<Blog>().Property("_validatedUrl") 並另設 GetBlah()/SetBlah(value) 方法讀寫。另也可仿照 Shadow 屬性,為欄位另取屬性名再透過 EF.Property<string>(b, "欄位名") 存取。
  13. 值轉換 Value Conversion (EF Core 2.1+ 支援)
    讀寫資料庫時加入轉換邏輯(例如:加解密、列舉轉字串)。做法為為 ModelClrType 及 ProviderClrType 定義轉換關係,例如列舉轉字串,ModelClrType 為 enum,ProviderClrType 為 string。
    modelBuilder
    .Entity<Rider>()
    .Property(e => e.Mount)
    .HasConversion( //值為 null 時不會觸發轉換邏輯
        v => v.ToString(),
        v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));
    
    轉換邏輯可寫成類別重複使用:
    var converter = new ValueConverter<EquineBeast, string>(
    v => v.ToString(),
    v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));
    
    modelBuilder.Entity<Rider>().Property(e => e.Mount).HasConversion(converter);
    
    EF Core 有提供一些內建轉換類別:BoolToZeroOneConverter(bool 變 0/1), BoolToStringConverter(bool 變 Y/N), BoolToTwoValuesConverter(bool 對映兩個值), BytesToStringConverter(Base64 轉換), CastingConverter(轉成特定型別), CharToStringConverter, DateTimeOffsetToBinaryConverter, DateTimeOffsetToBytesConverter, DateTimeOffsetToStringConverter, DateTimeToBinaryConverter, DateTimeToTicksConverter, EnumToNumberConverter, EnumToStringConverter, GuidToBytesConverter, GuidToStringConverter, NumberToBytesConverter, NumberToStringConverter, StringToBytesConverter, TimeSpanToStringConverter, TimeSpanToTicksConverter。
    常用轉換有捷徑,例如列舉轉字串,只要加上資料註解:
    public class Rider
    {
        public int Id { get; set; }
    
        [Column(TypeName = "nvarchar(24)")]
        public EquineBeast Mount { get; set; }
    }
    
    或是用 Fluent API 指定轉字串:modelBuilder.Entity<Rider>().Property(e => e.Mount).HasConversion<string>();,這樣就可以囉。
    注意:1) null 不會轉換 2) 不支援一屬性轉換成多欄 3) 加入轉換可能防礙將查詢轉成 SQL 能力,若遇到時 Log 會出現警告,此點未來會改善。
  14. Data Seeding
    資料初始化,有三個途徑:種子資料、Migration 客製化、自訂初始化邏輯。
    * 種子資料(Model Seed Data)
    寫法:modelBuilder.Entity<Blog>().HasData(new Blog {BlogId = 1, Url = "http://sample.com"});
    Migration 產生的建立資料表 Script 時包含上述資料新增,另呼叫 context.Database.EnsureCreated() 從程式新建資料表並插入初始資料(常用於 In-Memory Provider 測試情境)。 但若資料表已存在,則不會有任何更新。
    限制:必須指定 Primary Key(即使是自動跳號欄位)、Primary Key 如變更,先前的種子資料會被移除,種子資料多用於不會改變的靜態資料(如郵遞區號)。 若屬測試用暫時資料、依賴資料庫狀態、資料庫自動產生欄位、需特殊值轉換(例如:密碼雜湊)、依賴外部API(如 ASP.NET Core Identity 角色或使用者)之資料,建議用自訂初始化邏輯。
    * Migration 客製化
    在 OnModelCreating() 使用 InsertData()、UpdateData()、DeleteData() 等方法:
    migrationBuilder.InsertData(
        table: "Blogs",
        columns: new[] { "Url" },
        values: new object[] { "http://generated.com" });
    
    * 自訂初始化邏輯
    寫一段自訂邏輯於適當時機呼叫:
    using (var context = new DataSeedingContext())
    {
        context.Database.EnsureCreated();
    
        var testBlog = context.Blogs.FirstOrDefault(b => b.Url == "http://test.com");
        if (testBlog == null)
        {
            context.Blogs.Add(new Blog { Url = "http://test.com" });
        }
        context.SaveChanges();
    }
    
  15. Entity 建構式 (EF Core 2.1+)
    EF Core 2.1 Entity 型別可使用參數建構式,EF Core 從資料庫讀取資料時會用屬性名稱對應建構式參數名稱(Pascal 命名轉成 Camel 命名),建構式建議設成 public 或 protected。 使用參數建構式有助於將屬性轉為唯讀,自動產生的 Primary Key 必須可寫入,非要設成唯讀有特殊做法。(參考文件)
    另外建構式可注入以下服務:DbContext、ILazyLoader、Action<object, string>(Lazy-Loading 委派)、IEntityType (Metadata 用的型別), 使用時 EF Core 注入服務用的建構式要宣告為 private,再另外宣告 public 建構式供外部使用。不過,在 Entity 引用 DbContext 不利於降低耦合度, 常被視為不好的設計(Anti-Pattern),請三思。
  16. 資料表分割 (EF Core 2.0+)
    EF Core 允許一個資料列對映多個 Entity,稱為 Table Splitting 或 Table Sharing。例如:Order 是 DetailedOrder 的子集合:
    public class Order
    {
        public int Id { get; set; }
        public OrderStatus Status { get; set; }
        public DetailedOrder DetailedOrder { get; set; }
    }
    public class DetailedOrder : Order
    {
        public string BillingAddress { get; set; }
        public string ShippingAddress { get; set; }
        public byte[] Version { get; set; }
    }
    //...
    modelBuilder.Entity<DetailedOrder>()
        .ToTable("Orders")
        .HasBaseType((string)null)
        .Ignore(o => o.DetailedOrder);
    
    modelBuilder.Entity<Order>()
        .ToTable("Orders")
        .HasOne(o => o.DetailedOrder).WithOne()
        .HasForeignKey<DetailedOrder>(o => o.Id);
    //Entity共用資料表若有Concurrency Token,所有Entity都要加上宣告
    modelBuilder.Entity<Order>()
        .Property<byte[]>("Version").IsRowVersion().HasColumnName("Version");
    
    modelBuilder.Entity<DetailedOrder>()
        .Property(o => o.Version).IsRowVersion().HasColumnName("Version");
    
  17. EF Core 2.2+ 支援地理座標資料型別(空間資料 Spatial Data)
  18. 關聯式資料庫相關定義
    • [Table("TableName")] 、 [Table("TableName", Schema="SchemaName")] 、.ToTable("TableName")
    • [Column("ColName"] 、 .Property(b => b.PropName).HasColumnName("ColName")
    • [Column(TypeName = "varchar(200)")] 、.Property(b => b.PropName).HasColumnType("decimal(5,2)")
    • .HasKey(b => b.KeyName).HasName("PK_KeyName") //指定PK Index名稱
    • modelBuilder.HasDefaultSchema("blogging"); //指定預設 Schema
    • modelBuilder.Entity<Person>().Property(p => p.DisplayName).HasComputedColumnSql("[LastName] + ', ' + [FirstName]"); 指定計算欄位
    • 建立跳號器
          modelBuilder.HasSequence<int>("OrderNumbers", schema: "shared")
              .StartsAt(1000)
              .IncrementsBy(5);
      
          modelBuilder.Entity<Order>()
              .Property(o => o.OrderNo)
              .HasDefaultValueSql("NEXT VALUE FOR shared.OrderNumbers");
      
    • 預設值
      modelBuilder.Entity<Blog>()
          .Property(b => b.Rating)
          .HasDefaultValue(3);
      modelBuilder.Entity<Blog>()
          .Property(b => b.Created)
          .HasDefaultValueSql("getdate()");
      
    • 索引
      modelBuilder.Entity<Blog>()
          .HasIndex(b => b.Url)
          .HasName("Index_Url");
      modelBuilder.Entity<Blog>()
          .HasIndex(b => b.Url)
          .HasFilter("[Url] IS NOT NULL");
      modelBuilder.Entity<Blog>()
          .HasIndex(b => b.Url)
          .IsUnique()
          .HasFilter(null);    
      
    • SQL Index 效能提升特殊功能:Create Indexes with Included Columns
    • Foreign Key 限制
      modelBuilder.Entity<Post>()
          .HasOne(p => p.Blog)
          .WithMany(b => b.Posts)
          .HasForeignKey(p => p.BlogId)
          .HasConstraintName("ForeignKey_Post_Blog");
      
    • Alternate Key (Unique 限制)
      modelBuilder.Entity<Car>()
          .HasAlternateKey(c => c.LicensePlate)
          .HasName("AlternateKey_LicensePlate");
      
    • 繼承 只支援 Table-Per-Hierarchy(TPH),不支援 Table-Per-Type(TPT) 及 Table-Per-Concrete-Type(TPC)。 例:RssBlog 繼承 Blog,多了一個 RssUrl 屬性,兩種都存在 Blog 資料表,Blog 資料表有個 Discriminator 欄位,其值為 'Blog' 或 'RssBlog', 當 Discriminator 為 'Blog' 時,RssUrl 為 NULL。
      若不依循慣例要自訂 Discriminator 欄名及值: modelBuilder.Entity<Blog>().HasDiscriminator<string>("blog_type").HasValue<Blog>("blog_base").HasValue<RssBlog>("blog_rss");

Model 還有另一個重頭戲 - 關聯,下篇再續。

Notes about EF Core model design.


Comments

# by Nelson Yuan

"3.Key (索引鍵...名為 Id 或 <type name>Id 的屬性"有筆誤,應該是<table name>Id 才對

# by Jeffrey

to Nelson Yuan, 依據文件 https://docs.microsoft.com/en-us/ef/core/modeling/keys By convention, a property named Id or <type name>Id will be configured as the key of an entity. 但因為慣例上 Entity Name == Table Name,說是 Table Name 也沒錯。

Post a comment


82 + 18 =