EF Core 筆記 2 - Model 設計
6 |
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 設計相關設定:
- 包含及排除特定型別
依慣例,在 DbConent 宣告 DbSet<TEntity>,TEntity 即會被包含在 Model 範圍,TEntity 屬性涉及的其他型別也會被包含進來。OnModelCreating() modelBuilder.Entity<TMoreEntity>() 亦有將 TMoreEntity 納入的效果。
在類別加註 [NotMapped] 可排除指定類別,Fluent API 寫法為 modelBuilder.Ignore<TExcludeEnityt> - 包含及排除屬性(Property)
依慣例,public 且具有 get/set 的屬性會被包含於 Model,必要時可用 [NotMapped],Fluent API 寫法為modelBuilder.Entity<Blog>().Ignore(b => b.LoadedFromDatabase);
- 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 });
- 自動產生值
屬性可設為「新增自動給值」或是「新增及更新都自動給值」。 屬性值可從 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()
- 必要屬性及選擇性屬性(是否允許值為 null)
依慣例,型別可以為 null 的屬性為選擇性(例如:string, int?, byte[]...),反之為必要屬性(如:int, decimal, bool...),資料註解寫法為 [Required],Fluent API 為 .IsRequired()。 - 最大長度
適用 string 及 byte[],主要用於資料表 Script 指定欄位長度,EF 本身不會對長度進行檢核。若未以 [MaxLength(長度)] 指定,SQL 會指定 nvarchar(max)、PK 屬性則是 nvarchar(450),Fluent API 寫法為 HasMaxLength(長度)。 - Concurrency Token
Concurrency Token 用於衝突管理,可於寫入前檢查額外欄位是否被異動,防止多方寫入資料彼此覆寫。資料註解為 [ConcurrencyCheck],Fluent API 為 .IsConcurrencyToken()。
另外,SQL 提供了 rowversion 型別,在 byte[] 屬性透過 [TimeStamp] 或 .IsRowVersion() 用於避免資料衝突。 - Shadow 屬性
指未定義在 Entity 類別需透過 Change Tracker 維護及存取的隱藏屬性,一個例子是用於建立 Entity 關聯的 Foreign Key 屬性。
如下例,Post 類別會產生 Shadow 屬性 - BlogId:
自訂 Shadow 屬性只能使用 Fluent API 宣告,程式存取要透過 ChangeTracker API,例如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; } }
context.Entry(myBlog).Property("LastUpdated").CurrentValue = DateTime.Now;
,var blogs = ontext.Blogs.OrderBy(b => EF.Property<DateTime>(b, "LastUpdated"));
。 - 索引
依慣例,EF Core 會為 Foreign Key 會建立索引。 索引不能用資料註解宣告。 Fluent API 寫法為:HasIndex(b => b.Url)、HasIndex(b => b.Url).IsUnique() (不重複索引)、.HasIndex(p => new { p.FirstName, p.LastName }) (複合式索引)
注意:每組屬性組合只能定義一個索引,後宣告者會壓過慣例或原本設定。 - Alternative Key (替代索引鍵)
Primary Key 之外的其他不重複 Key,可用於 Foreign Key 關聯,需透過 Fluent API HasForeignKey() 指定。 若不透過 Foreign Key 指定,純粹只是要增加 Unique Index,作法為 modelBuilder.Entity<Car>().HasAlternateKey(c => c.LicensePlate);。 - 繼承關係
當 Entity 間有繼承關係,對映資料表方式由資料庫提供者決定。依慣例父子型別都有設為 DbSet<T> EF Core 會自行判斷,否則可用 modelBuilder.Entity<RssBlog>().HasBaseType<Blog>() 宣告,.HasBaseType((Type)null) 則可移除繼承關係。 - 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, "欄位名") 存取。 - 值轉換 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));
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。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);
常用轉換有捷徑,例如列舉轉字串,只要加上資料註解:
或是用 Fluent API 指定轉字串:public class Rider { public int Id { get; set; } [Column(TypeName = "nvarchar(24)")] public EquineBeast Mount { get; set; } }
modelBuilder.Entity<Rider>().Property(e => e.Mount).HasConversion<string>();
,這樣就可以囉。
注意:1) null 不會轉換 2) 不支援一屬性轉換成多欄 3) 加入轉換可能防礙將查詢轉成 SQL 能力,若遇到時 Log 會出現警告,此點未來會改善。 - 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(); }
- 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),請三思。 - 資料表分割 (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");
- EF Core 2.2+ 支援地理座標資料型別(空間資料 Spatial Data)
- 關聯式資料庫相關定義
- [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 也沒錯。
# by Nelson Yuan
確實用Table Name是不精確的,指定Table別名還是會用<type name>Id,我細想type當然是指class type不可能是指Proprty type,我反而覺得大大用 Entity Name 也比 Table Name 更健康好吃,謝謝您回覆~
# by Ray
您好, 找不到答案, 看到這篇, 想詢問一下, 我是 .net core 5 code first ,web api , 原 key 值都是用 guid , 現在要改回 int 的 identity 但 下了二行指令, 卻不起作用, 理論上 DatabaseGeneratedOption.Identity 應該會自行產生 Id的值, 其他欄位由程式給值, [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } 但在建 DB 時就會出現下行錯誤 The seed entity for entity type 'EmgcyRespHtal' cannot be added because a non-zero value is required for property 'Id'. Consider providing a negative value to avoid collisions with non-seed data. 也有加入 , 下行, 但一樣產生如上錯誤, 想詢問我還有哪要調整的, 謝謝您 builder.Entity<EmgcyRespHtal>() .Property(p => p.Id) .ValueGeneratedOnAdd();
# by Ray
您好, 找到問題了, 因需要有預設資料, 所以需先用 HasData 來建, 因此 HasData 中的 Id 一定要先手動給值, 之後用程式新增的則可不用給 Id 的值 謝謝您
# by Jeffrey
to Ray, 感謝分享經驗,幫補充填入預設資料(Data Seeding)的參考資料:https://docs.microsoft.com/en-us/ef/core/modeling/data-seeding