【EF Core】主從實體關係與常見實體關係的區別

上次老周扯了有關主、從實體的話題,本篇咱們再挖一下,主、從實體之間建立的關係,跟咱們常用的一對一、一對多這些關係之間有什麼不同。

先看看咱們從學習數據庫開始就特熟悉的常用關係——多對多、一對一、一對多說起。數據實體之間會建立什麼樣的關係,並不是規則性的,而是要看數據的功能。比如你家養的狗狗和水果(你家狗狗可能不喫水果,但老周養的動物基本是什麼都喫的,因爲從它們幼年起,老周就訓練它們,對食物要來者不拒,就算哪天它們不想跟着老周混,出去流浪也不會餓死,適應性更強)。

假設:

1、你的數據是以狗狗爲主,那麼一條狗狗會喫多種水果。即狗狗對水果是一對多;

2、你的數據以水果爲主,每種水果單獨記錄,然後在另一個表中記錄水果被哪幾條狗喜歡。例:雪梨,狗Y和狗B都喜歡喫。於是水果對狗狗也可以是一對多的關係。

再假設你有個幼兒園學生尿牀登記表,表中記錄每次尿牀的時間、牀號等。每一條尿牀記錄都有一個字段,引用自學生表,代表是哪們同學尿牀了。多條尿牀記錄可能都是同一個人的,比如,小明一週有三次尿牀。這樣,尿牀記錄和學生之間可以是多對一關係了。

數據是爲咱們人服務的,因此實體之間建立什麼樣的關係,得看咱們人類是怎麼理解,以及這些實體的用途。

還是用上一篇水文中的學生 - 作業的例子。

public class Student
{
    // 主鍵:學生ID
    public int StuID { get; set; }
    // 學生姓名
    public string? Name { get; set; }
    // 年級
    public ushort Grade { get; set; }
    // 作業(導航屬性)
    public IEnumerable<Homework> Homeworks { get; set; } = new List<Homework>();
}

public class Homework
{
    // 主鍵,ID
    public int WorkID { get; set; }
    // 作業描述
    public string? Description { get; set; }
    // 科目(導航屬性)
    public Subject? Subject { get; set; }
    // 引用學生對象
    public Student? Student { get; set; }
}

public class Subject
{
    // 主鍵:科目ID
    public int SubID { get; set; }
    // 科目名稱
    public string? Name { get; set; }
}

這次老周加了個實體——Subject,它表示作業的科目(數學、語文等)。

導航屬性是用於建立實體關係的。

1、學生類中,Homeworks 屬性建立與 Homework 對象的關係:一條學生信息可以對應多條作業信息,是一對多的關係;

2、作業類中,Subject 屬性建立與 Subject 對象的關係。一對一的關係。

在 DbContext 的自定義類型中,三個實體間的關係配置如下:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 設置主鍵
    modelBuilder.Entity<Student>().HasKey(s => s.StuID);
    modelBuilder.Entity<Homework>().HasKey(w => w.WorkID);
    modelBuilder.Entity<Subject>().HasKey(u => u.SubID);
    // 建立模型關係
    modelBuilder.Entity<Student>().HasMany(s => s.Homeworks).WithOne(w => w.Student);
    modelBuilder.Entity<Homework>().HasOne(w => w.Subject);
}

這是咱們常規的關係配置方法,從當前實體到另一實體的關係描述爲 HasXXX 方法;HasXXX 方法調用後,會順帶調用一個 WithXXX 方法。WithXXX 方法是反向描述,即描述另一個實體與當前實體的關係。這樣調用可以建立比較完整的相對關係。

在上述代碼中,Student -> Homework 是一對多,所以,Student 實體上調用 HasMany 方法;之後是反向關係,Homework -> Student 是一對一關係,也就是說,一條 Homework 記錄通過外鍵只引用一條學生記錄。因此調用了 WithOne 方法。

Homework -> Subject 是一對一,所以在 Homework 實體上調用 HasOne 方法。這裏,Homework 與 Subject 兩實體並沒有建立相互引用的關係,僅僅是作業中引用了科目信息,而 Subject 實體自身可以獨立,它不需要引用 Homework 的任何實例,因此沒有調用 WithXXX 方法。

由於實體之間建立的關係是相對的,即參照當前對象。所以,上面代碼也可以這樣寫:

modelBuilder.Entity<Homework>().HasOne(h => h.Student).WithMany(s => s.Homeworks);
modelBuilder.Entity<Homework>().HasOne(h => h.Subject);

要注意的是,這兩種關係配置其實是相同的,所以兩者任選一即可,不要重複配置。

兩種關係配置的差別就在選擇誰來做“當前實體”,即以當前實體爲參照而建立相對關係。第二種方法是以 Homework 實體爲當前實體,一條作業信息只關聯一位學生,所以是一對一,調用 HasOne 方法;反過來,一條學生信息可包含多條作業信息,所以是一對多,即調用 WithMany 方法。

定義幾個靜態方法,用於驗證模型建得對不對。

首先,InitDatabase 方法負責運行階段創建數據庫,並插入一些測試數據。

static void InitDatabase()
{
    using MyContext cxt = new();
    // 確保數據已創建
    bool v = cxt.Database.EnsureCreated();
    // 如果數據庫已存在,不用初始化數據
    if (!v)
        return;
    /*  初始化數據  */
    // 這是科目
    Subject s1 = new(){ Name = "語文"};
    Subject s2 = new(){ Name = "數學"};
    Subject s3 = new(){ Name = "英語"};
    Subject s4 = new(){ Name = "物理"};
    Subject s5 = new(){ Name = "地理"};
    cxt.Subjects.AddRange(new[]{
        s1, s2, s3, s4, s5
    });
    // 學生和作業可以一起添加
    cxt.Students.Add(
        new Student{
            Name = "小華",
            Grade = 4,
            Homeworks = new []
            {
                new Homework
                {
                    Description = "背單詞3500個",
                    Subject = s3
                },
                new Homework
                {
                    Description = "作文《我是誰,我在哪裏》",
                    Subject = s1
                },
                new Homework
                {
                   Description = "手繪廣州地鐵網絡圖",
                   Subject = s5
                }
            }
        }
    );
    cxt.Students.Add(
        new Student
        {
            Name = "王雙喜",
            Grade = 3,
            Homeworks = new[] {
                new Homework
                {
                    Description = "完型填空練習",
                    Subject = s3
                }
            }
        }
    );
    cxt.Students.Add(
        new Student
        {
            Name = "割麥小王子",
            Grade = 5,
            Homeworks = new[]{
                new Homework
                {
                    Description = "實驗:用激光給蟑螂美容",
                    Subject = s4
                },
                new Homework{
                    Description = "翻譯文言文《醉駕通鑑》",
                    Subject = s1
                }
            }
        }
    );
    // 保存到數據庫
    cxt.SaveChanges();
}

SaveChanges 方法記得調用,調用了纔會保存數據。

ShowData 方法負責在控制檯打印數據。

static void ShowData()
{
    using MyContext ctx = new();
    var students = ctx.Students.Include(s => s.Homeworks)
                .ThenInclude(hw => hw.Subject)
                .AsEnumerable();
    // 打印學生信息
    Console.WriteLine("{0,-5}{1,-10}{2,-6}", "學號", "姓名", "年級");
    Console.WriteLine("----------------------------------------------------");
    foreach(var stu in students)
    {
        Console.WriteLine($"{stu.StuID,-7}{stu.Name,-10}{stu.Grade,-4}");
        // 打印作業信息
        foreach(Homework wk in stu.Homeworks)
        {
            Console.Write(">> {0,-4}", wk.Subject!.Name);
            Console.WriteLine(wk.Description);
        }
        Console.Write("\n\n");
    }
}

在加載數據時得小心,因爲如果你只訪問 Students 集合,那麼,Homeworks 和 Subjects 集合不會加載,這會使得 Student 實體的 Homeworks 屬性變爲空。爲了讓訪問 Students 集合時同時加載關聯的數據,要用 Include 方法。

第一個 Include 方法加載 Homeworks 屬性引用的 Homework對象;第二個ThenInclude 方法是指在加載 Homework 後,Homework 實體的 Subject 屬性引用了 Subject 對象,所以 ThenInclude 方法是通知模型順便加載 Subjects 集合。

最後,要調用一下實際觸發查詢的方法,如 AsEnumerable 方法,這樣纔會讓查詢執行,你在內存中才能訪問到數據。當然,像 ToArray、ToList 之類的方法也可以,這個和 LINQ 語句的情況類似。要調用到相應的方法才觸發查詢真正執行。

RemoveDatabase 方法是可選的,刪除數據庫。咱們這是演示,免得在數據庫中存太多不必要的東西。測試完代碼可以調用一下它,刪除數據庫。這裏老周照例用 SQL Server LocalDB 來演示。

static void RemoveDatabase()
{
    using MyContext c = new();
    c.Database.EnsureDeleted();
}

-------------------------------------------------------------------------------------------

用的時候,按順調用這些方法,就可以測試了。

   Console.WriteLine("** 第一步:初始化數據庫。【請按任意鍵繼續】");
   _ = Console.ReadKey(true);
   InitDatabase();

   Console.WriteLine("** 第二步:顯示數據。【請按任意鍵繼續】");
   _ = Console.ReadKey(true);
   ShowData();

   //Console.WriteLine("** 第三步:刪除數據庫。【請按任意鍵繼續】");
   //_ = Console.ReadKey();
   //RemoveDatabase();

產生的數據表如下圖所示:

 

我們上面的這個模型還是有點問題的,可以看一下,生成的數據表是沒有刪除約束的。

CREATE TABLE [dbo].[Homeworks] (
    [WorkID]       INT            IDENTITY (1, 1) NOT NULL,
    [Description]  NVARCHAR (MAX) NULL,
    [SubjectSubID] INT            NULL,
    [StudentStuID] INT            NULL,
    CONSTRAINT [PK_Homeworks] PRIMARY KEY CLUSTERED ([WorkID] ASC),
    CONSTRAINT [FK_Homeworks_Students_StudentStuID] FOREIGN KEY ([StudentStuID]) REFERENCES [dbo].[Students] ([StuID]),
    CONSTRAINT [FK_Homeworks_Subjects_SubjectSubID] FOREIGN KEY ([SubjectSubID]) REFERENCES [dbo].[Subjects] ([SubID])
);

假如現在我要刪掉一條學生記錄。

using(MyContext dbcontext = new())
{
    // 刪第一條記錄
    var one = dbcontext.Students.FirstOrDefault();
    if(one != null)
    {
        dbcontext.Students.Remove(one);
        dbcontext.SaveChanges();
    }
}

但刪除的時候會遇到錯誤。

這表明咱們要配置級聯刪除。

public class MyContext : DbContext
{
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Homework> Homeworks => Set<Homework>();
    public DbSet<Subject> Subjects => Set<Subject>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(@"server=(localdb)\MSSQLLocalDB;Database=TestDB;Integrated Security=True");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ……
        // 建立模型關係
        modelBuilder.Entity<Student>()
                    .HasMany(s => s.Homeworks)
                    .WithOne(w => w.Student)
                    .OnDelete(DeleteBehavior.Cascade);
        modelBuilder.Entity<Homework>().HasOne(w => w.Subject);
    }
}

現在再刪一次看看。

可以看到,與第一位學生有關的作業記錄也一併被刪除了。生成的數據表也與前面有一點差異。

CREATE TABLE [dbo].[Homeworks] (
    [WorkID]       INT            IDENTITY (1, 1) NOT NULL,
    [Description]  NVARCHAR (MAX) NULL,
    [SubjectSubID] INT            NULL,
    [StudentStuID] INT            NULL,
    CONSTRAINT [PK_Homeworks] PRIMARY KEY CLUSTERED ([WorkID] ASC),
    CONSTRAINT [FK_Homeworks_Students_StudentStuID] FOREIGN KEY ([StudentStuID]) REFERENCES [dbo].[Students] ([StuID]) ON DELETE CASCADE,
    CONSTRAINT [FK_Homeworks_Subjects_SubjectSubID] FOREIGN KEY ([SubjectSubID]) REFERENCES [dbo].[Subjects] ([SubID])
);

約束裏面顯然多了 ON DELETE CASCADE 語句。

回憶一下,在上一篇水文中,咱們使用主從對象後,我們在模型中沒有明確配置級聯刪除,但生成的數據表中自動加上級聯刪除了。

這是不是說明:主從關係的實體對象裏,主實體對從屬實體的控制更強烈,咱們再對比對比看。

現在,讓 Student 和 Homework 成爲主從關係。

public class MyContext : DbContext
{
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Homework> Homeworks => Set<Homework>();
    public DbSet<Subject> Subjects => Set<Subject>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        ……
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 設置主鍵
        modelBuilder.Entity<Student>().HasKey(s => s.StuID);
        modelBuilder.Entity<Subject>().HasKey(u => u.SubID);
        // 建立模型關係
        modelBuilder.Entity<Student>()
                    .OwnsMany(s => s.Homeworks, mrb =>
                    {
                        mrb.WithOwner(w => w.Student);
                        mrb.HasKey(w => w.WorkID);
                        mrb.HasOne(w => w.Subject);
                    });
                    
    }
}

上次我們也證實過,凡成爲從屬的實體是無法單獨進行配置的(如主鍵等),只能在配置主從關係的時候通過 OwnsMany 方法的委託來配置。

主從關係會自動生成級聯刪除語句。

CREATE TABLE [dbo].[Homeworks] (
    ……,
    CONSTRAINT [PK_Homeworks] PRIMARY KEY CLUSTERED ([WorkID] ASC),
    CONSTRAINT [FK_Homeworks_Students_StudentStuID] FOREIGN KEY ([StudentStuID]) REFERENCES [dbo].[Students] ([StuID]) ON DELETE CASCADE,
    ……
);

還有一點更關鍵的,Homework 成爲 Student 的從對象後,你甚至無法直接訪問 Homeworks 集合,必須通過 Sudents 集合來訪問。

using (MyContext ctx = new MyContext())
{
    foreach(Homework hw in ctx.Homeworks)
    {
        Console.WriteLine($"{hw.Description}");
    }
}

上述代碼會拋異常。

這很明瞭,就是說你必須通過 Student 實體才能訪問 Homework。所以,正確的做法要這樣:

using (MyContext ctx = new MyContext())
{
    ctx.Subjects.Load();    // 這個可不會自動加載,必須Load
    foreach(Student stu in ctx.Students)
    {
        Console.WriteLine("【{0}】同學", stu.Name);
        foreach(Homework work in stu.Homeworks)
        {
            Console.WriteLine("  {0}:{1}", work.Subject?.Name, work.Description);
        }
    }
}

Subjects 集合爲什麼要顯式地調用 Load 方法呢?因爲 Homework 與 Subject 實體並沒有建立主從關係,Subject 對象要手動加載。

這樣訪問就不出錯了。

-----------------------------------------------------------------------------------

最後,咱們來總結一下:

1、普通關係的數據未自動加載,要顯式Load,或者 Include 方法加載。主從關係會自動加載從屬數據;

2、建立主從關係後,主實體對從實體是完全控制了,不僅自動生成級聯刪除等約束,而且你還不能直接訪問從實體,只能透過主實體訪問;普通關係的實體需要手動配置約束。

 

========================================================

下面是老周講故事時間。

上大學的時候,在《程序員》雜誌上看過一句很“權威”的話:程序員是世上最有尊嚴的職業,不用酒局飯局,不用看人臉色,想幹啥幹啥,自由得很。然而,“多年以後一場大雨驚醒沉睡的我,突然之間都市的霓虹都不再閃爍”。客戶說需求要這樣這樣,你改不改?改完之後客戶又說還是改回那樣那樣,你改不改?總奸,哦不,總監說要這樣這樣,你能那樣那樣嗎?客戶說:“我們希望增加XXX功能,最好可以分開YYY、KKK 來管理。這些對你們來很簡單的,動動鼠標就好了嘛!” 你動動鼠標試試?

再說了,哪個公司哪個單位的領導不是酒囊飯袋?IT 公司沒有嗎?哪兒都有,這世界最不缺的就是酒囊飯袋,最缺的是成吉思汗。

所以說,最TM自由、耍得最爽的就寫博客,愛寫啥寫啥,套用土杰倫的歌詞就是“你愛看就看,不愛看拉倒”。至於碼農,就如同被壓迫數千年的農民一樣,沒本質區別。所以,我們在給後輩講碼農生涯時,千萬不要給他們畫大餅,充不了飢。我們更應該教會他們程序員的最基本職業道德—— sudo rm -rf /*。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章