ASP.NET Core 中的 Razor 頁面和 EF Core - 數(shù)據(jù)模型

2019-04-17 08:58 更新

Contoso University Web 應(yīng)用演示了如何使用 EF Core 和 Visual Studio 創(chuàng)建 Razor 頁面 Web 應(yīng)用。若要了解系列教程,請參閱第一個(gè)教程。

前面的教程介紹了由三個(gè)實(shí)體組成的基本數(shù)據(jù)模型。 本教程將演示如何:

  • 添加更多實(shí)體和關(guān)系。
  • 通過指定格式設(shè)置、驗(yàn)證和數(shù)據(jù)庫映射規(guī)則來自定義數(shù)據(jù)模型。

已完成數(shù)據(jù)模型的實(shí)體類如下圖所示:

實(shí)體關(guān)系圖

如果遇到無法解決的問題,請下載已完成應(yīng)用。

使用特性自定義數(shù)據(jù)模型

此部分將使用特性自定義數(shù)據(jù)模型。

DataType 特性

學(xué)生頁面當(dāng)前顯示注冊日期。 通常情況下,日期字段僅顯示日期,不顯示時(shí)間。

用以下突出顯示的代碼更新 Models/Student.cs:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

DataType 特性指定比數(shù)據(jù)庫內(nèi)部類型更具體的數(shù)據(jù)類型。 在此情況下,應(yīng)僅顯示日期,而不是日期加時(shí)間。 DataType 枚舉提供多種數(shù)據(jù)類型,例如日期、時(shí)間、電話號碼、貨幣、電子郵件地址等。應(yīng)用還可通過 DataType 特性自動(dòng)提供類型特定的功能。 例如:

  • mailto: 鏈接將依據(jù) DataType.EmailAddress 自動(dòng)創(chuàng)建。
  • 大多數(shù)瀏覽器中都提供面向 DataType.Date 的日期選擇器。

DataType 特性發(fā)出 HTML 5 data-(讀作 data dash)特性供 HTML 5 瀏覽器使用。 DataType 特性不提供驗(yàn)證。

DataType.Date 不指定顯示日期的格式。 默認(rèn)情況下,日期字段根據(jù)基于服務(wù)器的 CultureInfo 的默認(rèn)格式進(jìn)行顯示。

DisplayFormat 特性用于顯式指定日期格式:

C#

[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

ApplyFormatInEditMode 設(shè)置指定還應(yīng)對編輯 UI 應(yīng)用該格式設(shè)置。 某些字段不應(yīng)使用 ApplyFormatInEditMode。 例如,編輯文本框中通常不應(yīng)顯示貨幣符號。

DisplayFormat 特性可由其本身使用。 搭配使用 DataType 特性和 DisplayFormat 特性通常是很好的做法。 DataType 特性按照數(shù)據(jù)在屏幕上的呈現(xiàn)方式傳達(dá)數(shù)據(jù)的語義。 DataType 特性可提供 DisplayFormat 中所不具有的以下優(yōu)點(diǎn):

  • 瀏覽器可啟用 HTML5 功能。 例如,顯示日歷控件、區(qū)域設(shè)置適用的貨幣符號、電子郵件鏈接、客戶端輸入驗(yàn)證等。
  • 默認(rèn)情況下,瀏覽器將根據(jù)區(qū)域設(shè)置采用正確的格式呈現(xiàn)數(shù)據(jù)。

有關(guān)詳細(xì)信息,請參閱 <input> 標(biāo)記幫助器文檔

運(yùn)行應(yīng)用。 導(dǎo)航到學(xué)生索引頁。 將不再顯示時(shí)間。 使用 Student 模型的每個(gè)視圖將顯示日期,不顯示時(shí)間。

“學(xué)生”索引頁顯示不帶時(shí)間的日期

StringLength 特性

可使用特性指定數(shù)據(jù)驗(yàn)證規(guī)則和驗(yàn)證錯(cuò)誤消息。 StringLength 特性指定數(shù)據(jù)字段中允許的字符的最小長度和最大長度。 StringLength 特性還提供客戶端和服務(wù)器端驗(yàn)證。 最小值對數(shù)據(jù)庫架構(gòu)沒有任何影響。

使用以下代碼更新 Student 模型:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]
        public string LastName { get; set; }
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

上面的代碼將名稱限制為不超過 50 個(gè)字符。 StringLength 特性不會(huì)阻止用戶在名稱中輸入空格。RegularExpression 特性用于向輸入應(yīng)用限制。 例如,以下代碼要求第一個(gè)字符為大寫,其余字符按字母順序排列:

C#

[RegularExpression(@"^[A-Z]+[a-zA-Z""'\s-]*$")]

運(yùn)行應(yīng)用:

  • 導(dǎo)航到學(xué)生頁。
  • 選擇“新建”并輸入不超過 50 個(gè)字符的名稱。
  • 選擇“創(chuàng)建”時(shí),客戶端驗(yàn)證會(huì)顯示一條錯(cuò)誤消息。

顯示字符串長度錯(cuò)誤的“學(xué)生索引”頁

在“SQL Server 對象資源管理器”(SSOX) 中,雙擊 Student 表,打開 Student 表設(shè)計(jì)器。

遷移前 SSOX 中的 Student 表

上圖顯示 Student 表的架構(gòu)。 名稱字段的類型為 nvarchar(MAX),因?yàn)閿?shù)據(jù)庫上尚未運(yùn)行遷移。 稍后在本教程中運(yùn)行遷移時(shí),名稱字段將變成 nvarchar(50)。

Column 特性

特性可以控制類和屬性映射到數(shù)據(jù)庫的方式。 在本部分,Column 特性用于將 FirstMidName 屬性的名稱映射到數(shù)據(jù)庫中的“FirstName”。

創(chuàng)建數(shù)據(jù)庫后,模型上的屬性名將用作列名(使用 Column 特性時(shí)除外)。

Student 模型使用 FirstMidName 作為名字字段,因?yàn)樵撟侄我部赡馨虚g名。

用以下突出顯示的代碼更新 Student.cs 文件:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]
        public string LastName { get; set; }
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        [Column("FirstName")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

進(jìn)行上述更改后,應(yīng)用中的 Student.FirstMidName 將映射到 Student 表的 FirstName 列。

添加 Column 特性后,SchoolContext 的支持模型會(huì)發(fā)生改變。 SchoolContext 的支持模型將不再與數(shù)據(jù)庫匹配。 如果在執(zhí)行遷移前運(yùn)行應(yīng)用,則會(huì)生成如下異常:

SQL

SqlException: Invalid column name 'FirstName'.

若要更新數(shù)據(jù)庫:

  • 生成項(xiàng)目。
  • 在項(xiàng)目文件夾中打開命令窗口。 輸入以下命令以創(chuàng)建新遷移并更新數(shù)據(jù)庫:
PMC
Add-Migration ColumnFirstName
Update-Database

migrations add ColumnFirstName 命令將生成如下警告消息:

text

An operation was scaffolded that may result in the loss of data.
Please review the migration for accuracy.

生成警告的原因是名稱字段現(xiàn)已限制為 50 個(gè)字符。 如果數(shù)據(jù)庫中的名稱超過 50 個(gè)字符,則第 51 個(gè)字符及后面的所有字符都將丟失。

  • 測試應(yīng)用。

在 SSOX 中打開 Student 表:

遷移后 SSOX 中的 Students 表

執(zhí)行遷移前,名稱列的類型為 nvarchar (MAX)。 名稱列現(xiàn)在的類型為 nvarchar(50)。 列名已從 FirstMidName 更改為 FirstName。

 備注

在下一部分中,在某些階段生成應(yīng)用會(huì)生成編譯器錯(cuò)誤。 說明用于指定生成應(yīng)用的時(shí)間。

Student 實(shí)體更新

Student 實(shí)體

用以下代碼更新 Models/Student.cs:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [Required]
        [StringLength(50)]
        [Display(Name = "Last Name")]
        public string LastName { get; set; }
        [Required]
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Enrollment Date")]
        public DateTime EnrollmentDate { get; set; }
        [Display(Name = "Full Name")]
        public string FullName
        {
            get
            {
                return LastName + ", " + FirstMidName;
            }
        }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

Required 特性

Required 特性使名稱屬性成為必填字段。 值類型(DateTime、int、double)等不可為 NULL 的類型不需要 Required 特性。 系統(tǒng)會(huì)將不可為 NULL 的類型自動(dòng)視為必填字段。

不能用 StringLength 特性中的最短長度參數(shù)替換 Required 特性:

C#

[Display(Name = "Last Name")]
[StringLength(50, MinimumLength=1)]
public string LastName { get; set; }

Display 特性

Display 特性指定文本框的標(biāo)題欄應(yīng)為“FirstName”、“LastName”、“FullName”和“EnrollmentDate”。標(biāo)題欄默認(rèn)不使用空格分隔詞語,如“Lastname”。

FullName 計(jì)算屬性

FullName 是計(jì)算屬性,可返回通過串聯(lián)兩個(gè)其他屬性創(chuàng)建的值。 FullName 不能設(shè)置并且僅具有一個(gè) get 訪問器。 數(shù)據(jù)庫中不會(huì)創(chuàng)建任何 FullName 列。

創(chuàng)建 Instructor 實(shí)體

Instructor 實(shí)體

用以下代碼創(chuàng)建 Models/Instructor.cs:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Instructor
    {
        public int ID { get; set; }

        [Required]
        [Display(Name = "Last Name")]
        [StringLength(50)]
        public string LastName { get; set; }

        [Required]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        [StringLength(50)]
        public string FirstMidName { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Hire Date")]
        public DateTime HireDate { get; set; }

        [Display(Name = "Full Name")]
        public string FullName
        {
            get { return LastName + ", " + FirstMidName; }
        }

        public ICollection<CourseAssignment> CourseAssignments { get; set; }
        public OfficeAssignment OfficeAssignment { get; set; }
    }
}

一行可包含多個(gè)特性。 可按如下方式編寫 HireDate 特性:

C#

[DataType(DataType.Date),Display(Name = "Hire Date"),DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

CourseAssignments 和 OfficeAssignment 導(dǎo)航屬性

CourseAssignments 和 OfficeAssignment 是導(dǎo)航屬性。

一名講師可以教授任意數(shù)量的課程,因此 CourseAssignments 定義為集合。

C#

public ICollection<CourseAssignment> CourseAssignments { get; set; }

如果導(dǎo)航屬性包含多個(gè)實(shí)體:

  • 它必須是可在其中添加、刪除和更新實(shí)體的列表類型。

導(dǎo)航屬性類型包括:

  • ICollection<T>
  • List<T>
  • HashSet<T>

如果指定了 ICollection<T>,EF Core 會(huì)默認(rèn)創(chuàng)建 HashSet<T> 集合。

CourseAssignment 實(shí)體在“多對多關(guān)系”部分進(jìn)行介紹。

Contoso University 業(yè)務(wù)規(guī)則規(guī)定一名講師最多可獲得一間辦公室。 OfficeAssignment 屬性包含一個(gè) OfficeAssignment 實(shí)體。 如果未分配辦公室,則 OfficeAssignment 為 NULL。

C#

public OfficeAssignment OfficeAssignment { get; set; }

創(chuàng)建 OfficeAssignment 實(shí)體

OfficeAssignment 實(shí)體

用以下代碼創(chuàng)建 Models/OfficeAssignment.cs:

C#

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class OfficeAssignment
    {
        [Key]
        public int InstructorID { get; set; }
        [StringLength(50)]
        [Display(Name = "Office Location")]
        public string Location { get; set; }

        public Instructor Instructor { get; set; }
    }
}

Key 特性

[Key] 特性用于在屬性名不是 classnameID 或 ID 時(shí)將屬性標(biāo)識(shí)為主鍵 (PK)。

Instructor 和 OfficeAssignment 實(shí)體之間存在一對零或一關(guān)系。 僅當(dāng)與分配到辦公室的講師之間建立關(guān)系時(shí)才存在辦公室分配。 OfficeAssignment PK 也是其到 Instructor 實(shí)體的外鍵 (FK)。 EF Core 無法自動(dòng)將 InstructorID 識(shí)別為 OfficeAssignment 的 PK,因?yàn)椋?/p>

  • InstructorID 不遵循 ID 或 classnameID 命名約定。

因此,Key 特性用于將 InstructorID 識(shí)別為 PK:

C#

[Key]
public int InstructorID { get; set; }

默認(rèn)情況下,EF Core 將鍵視為非數(shù)據(jù)庫生成,因?yàn)樵摿忻嫦虻氖亲R(shí)別關(guān)系。

Instructor 導(dǎo)航屬性

Instructor 實(shí)體的 OfficeAssignment 導(dǎo)航屬性可以為 NULL,因?yàn)椋?/p>

  • 引用類型(例如,類可以為 NULL)。
  • 一名講師可能沒有辦公室分配。

OfficeAssignment 實(shí)體具有不可為 NULL 的 Instructor 導(dǎo)航屬性,因?yàn)椋?/p>

  • InstructorID 不可為 NULL。
  • 沒有講師則不可能存在辦公室分配。

當(dāng) Instructor 實(shí)體具有相關(guān) OfficeAssignment 實(shí)體時(shí),每個(gè)實(shí)體都具有對其導(dǎo)航屬性中的另一個(gè)實(shí)體的引用。

[Required] 特性可以應(yīng)用于 Instructor 導(dǎo)航屬性:

C#

[Required]
public Instructor Instructor { get; set; }

上面的代碼指定必須存在相關(guān)的講師。 上面的代碼沒有必要,因?yàn)?nbsp;InstructorID 外鍵(也是 PK)不可為 NULL。

修改 Course 實(shí)體

Course 實(shí)體

用以下代碼更新 Models/Course.cs:

C#

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Course
    {
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Display(Name = "Number")]
        public int CourseID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Title { get; set; }

        [Range(0, 5)]
        public int Credits { get; set; }

        public int DepartmentID { get; set; }

        public Department Department { get; set; }
        public ICollection<Enrollment> Enrollments { get; set; }
        public ICollection<CourseAssignment> CourseAssignments { get; set; }
    }
}

Course 實(shí)體具有外鍵 (FK) 屬性 DepartmentID。 DepartmentID 指向相關(guān)的 Department 實(shí)體。 Course 實(shí)體具有 Department 導(dǎo)航屬性。

當(dāng)數(shù)據(jù)模型具有相關(guān)實(shí)體的導(dǎo)航屬性時(shí),EF Core 不要求此模型具有 FK 屬性。

EF Core 可在數(shù)據(jù)庫中的任何所需位置自動(dòng)創(chuàng)建 FK。 EF Core 為自動(dòng)創(chuàng)建的 FK 創(chuàng)建陰影屬性。 數(shù)據(jù)模型中包含 FK 后可使更新更簡單和更高效。 例如,假設(shè)某個(gè)模型中不包含 FK 屬性 DepartmentID。當(dāng)提取 Course 實(shí)體進(jìn)行編輯時(shí):

  • 如果未顯式加載 Department 實(shí)體,則該實(shí)體將為 NULL。
  • 若要更新 Course 實(shí)體,則必須先提取 Department 實(shí)體。

如果數(shù)據(jù)模型中包含 FK 屬性 DepartmentID,則無需在更新前提取 Department 實(shí)體。

DatabaseGenerated 特性

[DatabaseGenerated(DatabaseGeneratedOption.None)] 特性指定 PK 由應(yīng)用程序提供而不是由數(shù)據(jù)庫生成。

C#

[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Display(Name = "Number")]
public int CourseID { get; set; }

默認(rèn)情況下,EF Core 假定 PK 值由數(shù)據(jù)庫生成。 由數(shù)據(jù)庫生成 PK 值通常是最佳方法。 Course 實(shí)體的 PK 由用戶指定。 例如,對于課程編號,數(shù)學(xué)系可以使用 1000 系列的編號,英語系可以使用 2000 系列的編號。

DatabaseGenerated 特性還可用于生成默認(rèn)值。 例如,數(shù)據(jù)庫可以自動(dòng)生成日期字段以記錄數(shù)據(jù)行的創(chuàng)建或更新日期。 有關(guān)詳細(xì)信息,請參閱生成的屬性

外鍵和導(dǎo)航屬性

Course 實(shí)體中的外鍵 (FK) 屬性和導(dǎo)航屬性可反映以下關(guān)系:

課程將分配到一個(gè)系,因此將存在 DepartmentID FK 和 Department 導(dǎo)航屬性。

C#

public int DepartmentID { get; set; }
public Department Department { get; set; }

參與一門課程的學(xué)生數(shù)量不定,因此 Enrollments 導(dǎo)航屬性是一個(gè)集合:

C#

public ICollection<Enrollment> Enrollments { get; set; }

一門課程可能由多位講師講授,因此 CourseAssignments 導(dǎo)航屬性是一個(gè)集合:

C#

public ICollection<CourseAssignment> CourseAssignments { get; set; }

CourseAssignment 在后文介紹。

創(chuàng)建 Department 實(shí)體

Department 實(shí)體

用以下代碼創(chuàng)建 Models/Department.cs:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Name { get; set; }

        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

        public Instructor Administrator { get; set; }
        public ICollection<Course> Courses { get; set; }
    }
}

Column 特性

Column 特性以前用于更改列名映射。 在 Department 實(shí)體的代碼中,Column 特性用于更改 SQL 數(shù)據(jù)類型映射。 Budget 列通過數(shù)據(jù)庫中的 SQL Server 貨幣類型進(jìn)行定義:

C#

[Column(TypeName="money")]
public decimal Budget { get; set; }

通常不需要列映射。 EF Core 通?;趯傩缘?CLR 類型選擇相應(yīng)的 SQL Server 數(shù)據(jù)類型。 CLR decimal 類型會(huì)映射到 SQL Server decimal 類型。 Budget 用于貨幣,但貨幣數(shù)據(jù)類型更適合貨幣。

外鍵和導(dǎo)航屬性

FK 和導(dǎo)航屬性可反映以下關(guān)系:

  • 一個(gè)系可能有也可能沒有管理員。
  • 管理員始終由講師擔(dān)任。 因此,InstructorID 屬性作為到 Instructor 實(shí)體的 FK 包含在其中。

導(dǎo)航屬性名為 Administrator,但其中包含 Instructor 實(shí)體:

C#

public int? InstructorID { get; set; }
public Instructor Administrator { get; set; }

上面代碼中的問號 (?) 指定屬性可以為 NULL。

一個(gè)系可以有多門課程,因此存在 Course 導(dǎo)航屬性:

C#

public ICollection<Course> Courses { get; set; }

注意:按照約定,EF Core 能針對不可為 NULL 的 FK 和多對多關(guān)系啟用級聯(lián)刪除。 級聯(lián)刪除可能導(dǎo)致形成循環(huán)級聯(lián)刪除規(guī)則。 循環(huán)級聯(lián)刪除規(guī)則會(huì)在添加遷移時(shí)引發(fā)異常。

例如,如果未將 Department.InstructorID 屬性定義為可以為 NULL:

  • EF Core 會(huì)配置將在刪除系時(shí)刪除講師的級聯(lián)刪除規(guī)則。
  • 在刪除系時(shí)刪除講師并不是預(yù)期行為。

如果業(yè)務(wù)規(guī)則要求 InstructorID 屬性不可為 NULL,請使用以下 Fluent API 語句:

C#

modelBuilder.Entity<Department>()
   .HasOne(d => d.Administrator)
   .WithMany()
   .OnDelete(DeleteBehavior.Restrict)

上面的代碼會(huì)針對“系-講師”關(guān)系禁用級聯(lián)刪除。

更新 Enrollment 實(shí)體

一份注冊記錄面向一名學(xué)生所注冊的一門課程。

Enrollment 實(shí)體

用以下代碼更新 Models/Enrollment.cs:

C#

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public enum Grade
    {
        A, B, C, D, F
    }

    public class Enrollment
    {
        public int EnrollmentID { get; set; }
        public int CourseID { get; set; }
        public int StudentID { get; set; }
        [DisplayFormat(NullDisplayText = "No grade")]
        public Grade? Grade { get; set; }

        public Course Course { get; set; }
        public Student Student { get; set; }
    }
}

外鍵和導(dǎo)航屬性

FK 屬性和導(dǎo)航屬性可反映以下關(guān)系:

注冊記錄面向一門課程,因此存在 CourseID FK 屬性和 Course 導(dǎo)航屬性:

C#

public int CourseID { get; set; }
public Course Course { get; set; }

一份注冊記錄針對一名學(xué)生,因此存在 StudentID FK 屬性和 Student 導(dǎo)航屬性:

C#

public int StudentID { get; set; }
public Student Student { get; set; }

多對多關(guān)系

Student 和 Course 實(shí)體之間存在多對多關(guān)系。 Enrollment 實(shí)體充當(dāng)數(shù)據(jù)庫中“具有有效負(fù)載”的多對多聯(lián)接表。 “具有有效負(fù)載”表示 Enrollment 表除了聯(lián)接表的 FK 外還包含其他數(shù)據(jù)(本教程中為 PK 和 Grade)。

下圖顯示這些關(guān)系在實(shí)體關(guān)系圖中的外觀。 (此關(guān)系圖是使用適用于 EF 6.X 的 EF Power Tools 生成的。 本教程不介紹如何創(chuàng)建此關(guān)系圖。)

學(xué)生-課程之間的多對多關(guān)系

每條關(guān)系線的一端顯示 1,另一端顯示星號 (*),這表示一對多關(guān)系。

如果 Enrollment 表不包含年級信息,則它只需包含兩個(gè) FK(CourseID 和 StudentID)。 無有效負(fù)載的多對多聯(lián)接表有時(shí)稱為純聯(lián)接表 (PJT)。

Instructor 和 Course 實(shí)體具有使用純聯(lián)接表的多對多關(guān)系。

注意:EF 6.x 支持多對多關(guān)系的隱式聯(lián)接表,但 EF Core 不支持。 有關(guān)詳細(xì)信息,請參閱 EF Core 2.0 中的多對多關(guān)系。

CourseAssignment 實(shí)體

CourseAssignment 實(shí)體

用以下代碼創(chuàng)建 Models/CourseAssignment.cs:

C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class CourseAssignment
    {
        public int InstructorID { get; set; }
        public int CourseID { get; set; }
        public Instructor Instructor { get; set; }
        public Course Course { get; set; }
    }
}

講師-課程

講師-課程 m:M

講師-課程的多對多關(guān)系:

  • 要求必須用實(shí)體集表示聯(lián)接表。
  • 為純聯(lián)接表(無有效負(fù)載的表)。

常規(guī)做法是將聯(lián)接實(shí)體命名為 EntityName1EntityName2。 例如,使用此模式的“講師-課程”聯(lián)接表是 CourseInstructor。 但是,我們建議使用可描述關(guān)系的名稱。

數(shù)據(jù)模型開始時(shí)很簡單,其內(nèi)容會(huì)逐漸增加。 無有效負(fù)載聯(lián)接 (PJT) 通常會(huì)發(fā)展為包含有效負(fù)載。 該名稱以描述性實(shí)體名稱開始,因此不需要隨聯(lián)接表更改而更改。 理想情況下,聯(lián)接實(shí)體在業(yè)務(wù)領(lǐng)域中可能具有專業(yè)名稱(可能是一個(gè)詞)。 例如,可以使用名為“閱讀率”的聯(lián)接實(shí)體鏈接“書籍”和“讀客”。 對于“講師-課程”的多對多關(guān)系,使用 CourseAssignment 比使用CourseInstructor更合適。

組合鍵

FK 不能為 NULL。 CourseAssignment 中的兩個(gè) FK(InstructorID 和 CourseID)共同唯一標(biāo)識(shí) CourseAssignment 表的每一行。 CourseAssignment 不需要專用的 PK。 InstructorID 和 CourseID屬性充當(dāng)組合 PK。 使用 Fluent API 是向 EF Core 指定組合 PK 的唯一方法。 下一部分演示如何配置組合 PK。

組合鍵可確保:

  • 允許一門課程對應(yīng)多行。
  • 允許一名講師對應(yīng)多行。
  • 不允許相同的講師和課程對應(yīng)多行。

Enrollment 聯(lián)接實(shí)體定義其自己的 PK,因此可能會(huì)出現(xiàn)此類重復(fù)。 若要防止此類重復(fù):

  • 請?jiān)?FK 字段上添加唯一索引,或
  • 配置具有主要組合鍵(與 CourseAssignment 類似)的 Enrollment。 有關(guān)詳細(xì)信息,請參閱索引。

更新數(shù)據(jù)庫上下文

將以下突出顯示的代碼添加到 Data/SchoolContext.cs:

C#

using ContosoUniversity.Models;
using Microsoft.EntityFrameworkCore;

namespace ContosoUniversity.Models
{
    public class SchoolContext : DbContext
    {
        public SchoolContext(DbContextOptions<SchoolContext> options) : base(options)
        {
        }

        public DbSet<Course> Courses { get; set; }
        public DbSet<Enrollment> Enrollment { get; set; }
        public DbSet<Student> Student { get; set; }
        public DbSet<Department> Departments { get; set; }
        public DbSet<Instructor> Instructors { get; set; }
        public DbSet<OfficeAssignment> OfficeAssignments { get; set; }
        public DbSet<CourseAssignment> CourseAssignments { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Course>().ToTable("Course");
            modelBuilder.Entity<Enrollment>().ToTable("Enrollment");
            modelBuilder.Entity<Student>().ToTable("Student");
            modelBuilder.Entity<Department>().ToTable("Department");
            modelBuilder.Entity<Instructor>().ToTable("Instructor");
            modelBuilder.Entity<OfficeAssignment>().ToTable("OfficeAssignment");
            modelBuilder.Entity<CourseAssignment>().ToTable("CourseAssignment");

            modelBuilder.Entity<CourseAssignment>()
                .HasKey(c => new { c.CourseID, c.InstructorID });
        }
    }
}

上面的代碼添加新實(shí)體并配置 CourseAssignment 實(shí)體的組合 PK。

用 Fluent API 替代特性

上面代碼中的 OnModelCreating 方法使用 Fluent API 配置 EF Core 行為。 API 稱為“Fluent”,因?yàn)樗ǔT趯⒁幌盗蟹椒ㄕ{(diào)用連接成單個(gè)語句后才能使用。 下面的代碼是 Fluent API 的示例:

C#

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property(b => b.Url)
        .IsRequired();
}

在本教程中,F(xiàn)luent API 僅用于不能通過特性完成的數(shù)據(jù)庫映射。 但是,F(xiàn)luent API 可以指定可通過特性完成的大多數(shù)格式設(shè)置、驗(yàn)證和映射規(guī)則。

MinimumLength 等特性不能通過 Fluent API 應(yīng)用。 MinimumLength 不會(huì)更改架構(gòu),它僅應(yīng)用最小長度驗(yàn)證規(guī)則。

某些開發(fā)者傾向于僅使用 Fluent API 以保持實(shí)體類的“純凈”。 特性和 Fluent API 可以相互混合。 某些配置只能通過 Fluent API 完成(指定組合 PK)。 有些配置只能通過特性完成 (MinimumLength)。使用 Fluent API 或特性的建議做法:

  • 選擇以下兩種方法之一。
  • 盡可能以前后一致的方法使用所選的方法。

本教程中使用的某些特性可用于:

  • 僅限驗(yàn)證(例如,MinimumLength)。
  • 僅限 EF Core 配置(例如,HasKey)。
  • 驗(yàn)證和 EF Core 配置(例如,[StringLength(50)])。

有關(guān)特性和 Fluent API 的詳細(xì)信息,請參閱配置方法

顯示關(guān)系的實(shí)體關(guān)系圖

下圖顯示 EF Power Tools 針對已完成的學(xué)校模型創(chuàng)建的關(guān)系圖。

實(shí)體關(guān)系圖

上面的關(guān)系圖顯示:

  • 幾條一對多關(guān)系線(1 到 *)。
  • Instructor 和 OfficeAssignment 實(shí)體之間的一對零或一關(guān)系線(1 到 0..1)。
  • Instructor 和 Department 實(shí)體之間的零或一到多關(guān)系線(0..1 到 *)。

使用測試數(shù)據(jù)為數(shù)據(jù)庫設(shè)定種子

更新 Data/DbInitializer.cs 中的代碼:

C#

using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ContosoUniversity.Models;

namespace ContosoUniversity.Data
{
    public static class DbInitializer
    {
        public static void Initialize(SchoolContext context)
        {
            //context.Database.EnsureCreated();

            // Look for any students.
            if (context.Student.Any())
            {
                return;   // DB has been seeded
            }

            var students = new Student[]
            {
                new Student { FirstMidName = "Carson",   LastName = "Alexander",
                    EnrollmentDate = DateTime.Parse("2010-09-01") },
                new Student { FirstMidName = "Meredith", LastName = "Alonso",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Arturo",   LastName = "Anand",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Gytis",    LastName = "Barzdukas",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Yan",      LastName = "Li",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Peggy",    LastName = "Justice",
                    EnrollmentDate = DateTime.Parse("2011-09-01") },
                new Student { FirstMidName = "Laura",    LastName = "Norman",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Nino",     LastName = "Olivetto",
                    EnrollmentDate = DateTime.Parse("2005-09-01") }
            };

            foreach (Student s in students)
            {
                context.Student.Add(s);
            }
            context.SaveChanges();

            var instructors = new Instructor[]
            {
                new Instructor { FirstMidName = "Kim",     LastName = "Abercrombie",
                    HireDate = DateTime.Parse("1995-03-11") },
                new Instructor { FirstMidName = "Fadi",    LastName = "Fakhouri",
                    HireDate = DateTime.Parse("2002-07-06") },
                new Instructor { FirstMidName = "Roger",   LastName = "Harui",
                    HireDate = DateTime.Parse("1998-07-01") },
                new Instructor { FirstMidName = "Candace", LastName = "Kapoor",
                    HireDate = DateTime.Parse("2001-01-15") },
                new Instructor { FirstMidName = "Roger",   LastName = "Zheng",
                    HireDate = DateTime.Parse("2004-02-12") }
            };

            foreach (Instructor i in instructors)
            {
                context.Instructors.Add(i);
            }
            context.SaveChanges();

            var departments = new Department[]
            {
                new Department { Name = "English",     Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Abercrombie").ID },
                new Department { Name = "Mathematics", Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Fakhouri").ID },
                new Department { Name = "Engineering", Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Harui").ID },
                new Department { Name = "Economics",   Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Kapoor").ID }
            };

            foreach (Department d in departments)
            {
                context.Departments.Add(d);
            }
            context.SaveChanges();

            var courses = new Course[]
            {
                new Course {CourseID = 1050, Title = "Chemistry",      Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Engineering").DepartmentID
                },
                new Course {CourseID = 4022, Title = "Microeconomics", Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Economics").DepartmentID
                },
                new Course {CourseID = 4041, Title = "Macroeconomics", Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Economics").DepartmentID
                },
                new Course {CourseID = 1045, Title = "Calculus",       Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "Mathematics").DepartmentID
                },
                new Course {CourseID = 3141, Title = "Trigonometry",   Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "Mathematics").DepartmentID
                },
                new Course {CourseID = 2021, Title = "Composition",    Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "English").DepartmentID
                },
                new Course {CourseID = 2042, Title = "Literature",     Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "English").DepartmentID
                },
            };

            foreach (Course c in courses)
            {
                context.Courses.Add(c);
            }
            context.SaveChanges();

            var officeAssignments = new OfficeAssignment[]
            {
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Fakhouri").ID,
                    Location = "Smith 17" },
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Harui").ID,
                    Location = "Gowan 27" },
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Kapoor").ID,
                    Location = "Thompson 304" },
            };

            foreach (OfficeAssignment o in officeAssignments)
            {
                context.OfficeAssignments.Add(o);
            }
            context.SaveChanges();

            var courseInstructors = new CourseAssignment[]
            {
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Kapoor").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Microeconomics" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Zheng").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Macroeconomics" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Zheng").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Calculus" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Fakhouri").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Trigonometry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Composition" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Abercrombie").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Literature" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Abercrombie").ID
                    },
            };

            foreach (CourseAssignment ci in courseInstructors)
            {
                context.CourseAssignments.Add(ci);
            }
            context.SaveChanges();

            var enrollments = new Enrollment[]
            {
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    Grade = Grade.A
                },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics" ).CourseID,
                    Grade = Grade.C
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Macroeconomics" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                        StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Calculus" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                        StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Trigonometry" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Composition" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics").CourseID,
                    Grade = Grade.B
                    },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Barzdukas").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry").CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Li").ID,
                    CourseID = courses.Single(c => c.Title == "Composition").CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Justice").ID,
                    CourseID = courses.Single(c => c.Title == "Literature").CourseID,
                    Grade = Grade.B
                    }
            };

            foreach (Enrollment e in enrollments)
            {
                var enrollmentInDataBase = context.Enrollment.Where(
                    s =>
                            s.Student.ID == e.StudentID &&
                            s.Course.CourseID == e.CourseID).SingleOrDefault();
                if (enrollmentInDataBase == null)
                {
                    context.Enrollment.Add(e);
                }
            }
            context.SaveChanges();
        }
    }
}

前面的代碼為新實(shí)體提供種子數(shù)據(jù)。 大多數(shù)此類代碼會(huì)創(chuàng)建新實(shí)體對象并加載示例數(shù)據(jù)。 示例數(shù)據(jù)用于測試。 有關(guān)如何對多對多聯(lián)接表進(jìn)行種子設(shè)定的示例,請參閱 Enrollments 和 CourseAssignments。

添加遷移

生成項(xiàng)目。

PMC
Add-Migration ComplexDataModel

前面的命令顯示可能存在數(shù)據(jù)丟失的相關(guān)警告。

text

An operation was scaffolded that may result in the loss of data.
Please review the migration for accuracy.
Done. To undo this action, use 'ef migrations remove'

如果運(yùn)行 database update 命令,則會(huì)生成以下錯(cuò)誤:

text

The ALTER TABLE statement conflicted with the FOREIGN KEY constraint "FK_dbo.Course_dbo.Department_DepartmentID". The conflict occurred in
database "ContosoUniversity", table "dbo.Department", column 'DepartmentID'.

應(yīng)用遷移

現(xiàn)已有一個(gè)數(shù)據(jù)庫,需要考慮如何將未來的更改應(yīng)用到其中。 本教程演示兩種方法:

刪除并重新創(chuàng)建數(shù)據(jù)庫

已更新 DbInitializer 中的代碼將為新實(shí)體添加種子數(shù)據(jù)。 若要強(qiáng)制 EF Core 創(chuàng)建新的 DB,請刪除并更新 DB:

在“包管理器控制臺(tái)”(PMC) 中運(yùn)行以下命令:

PMC
Drop-Database
Update-Database

從 PMC 運(yùn)行 Get-Help about_EntityFrameworkCore,獲取幫助信息。

運(yùn)行應(yīng)用。 運(yùn)行應(yīng)用后將運(yùn)行 DbInitializer.Initialize 方法。 DbInitializer.Initialize 將填充新數(shù)據(jù)庫。

在 SSOX 中打開數(shù)據(jù)庫:

  • 如果之前已打開過 SSOX,請單擊“刷新”按鈕。
  • 展開“表”節(jié)點(diǎn)。 隨后將顯示出已創(chuàng)建的表。

SSOX 中的表

查看 CourseAssignment 表:

  • 右鍵單擊 CourseAssignment 表,然后選擇“查看數(shù)據(jù)”。
  • 驗(yàn)證 CourseAssignment 表包含數(shù)據(jù)。

SSOX 中的 CourseAssignment 數(shù)據(jù)

將遷移應(yīng)用到現(xiàn)有數(shù)據(jù)庫

本部分是可選的。 只有當(dāng)跳過之前的刪除并重新創(chuàng)建數(shù)據(jù)庫部分時(shí)才可以執(zhí)行上述步驟。

當(dāng)現(xiàn)有數(shù)據(jù)與遷移一起運(yùn)行時(shí),可能存在不滿足現(xiàn)有數(shù)據(jù)的 FK 約束。 使用生產(chǎn)數(shù)據(jù)時(shí),必須采取步驟來遷移現(xiàn)有數(shù)據(jù)。 本部分提供修復(fù) FK 約束沖突的示例。 務(wù)必在備份后執(zhí)行這些代碼更改。 如果已完成上述部分并更新數(shù)據(jù)庫,則不要執(zhí)行這些代碼更改。

{timestamp}_ComplexDataModel.cs 文件包含以下代碼:

C#

migrationBuilder.AddColumn<int>(
    name: "DepartmentID",
    table: "Course",
    type: "int",
    nullable: false,
    defaultValue: 0);

上面的代碼將向 Course 表添加不可為 NULL 的 DepartmentID FK。 前面教程中的數(shù)據(jù)庫在 Course中包含行,以便遷移時(shí)不會(huì)更新表。

若要使 ComplexDataModel 遷移可與現(xiàn)有數(shù)據(jù)搭配運(yùn)行:

  • 請更改代碼以便為新列 (DepartmentID) 賦予默認(rèn)值。
  • 創(chuàng)建名為“臨時(shí)”的虛擬系來充當(dāng)默認(rèn)的系。

修復(fù)外鍵約束

更新 ComplexDataModel 類 Up 方法:

  • 打開 {timestamp}_ComplexDataModel.cs 文件。
  • 對將 DepartmentID 列添加到 Course 表的代碼行添加注釋。

C#

migrationBuilder.AlterColumn<string>(
    name: "Title",
    table: "Course",
    maxLength: 50,
    nullable: true,
    oldClrType: typeof(string),
    oldNullable: true);
            
//migrationBuilder.AddColumn<int>(
//    name: "DepartmentID",
//    table: "Course",
//    nullable: false,
//    defaultValue: 0);

添加以下突出顯示的代碼。 新代碼在 .CreateTable( name: "Department" 塊后:

C#

migrationBuilder.CreateTable(
    name: "Department",
    columns: table => new
    {
        DepartmentID = table.Column<int>(type: "int", nullable: false)
            .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
        Budget = table.Column<decimal>(type: "money", nullable: false),
        InstructorID = table.Column<int>(type: "int", nullable: true),
        Name = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
        StartDate = table.Column<DateTime>(type: "datetime2", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Department", x => x.DepartmentID);
        table.ForeignKey(
            name: "FK_Department_Instructor_InstructorID",
            column: x => x.InstructorID,
            principalTable: "Instructor",
            principalColumn: "ID",
            onDelete: ReferentialAction.Restrict);
    });

 migrationBuilder.Sql("INSERT INTO dbo.Department (Name, Budget, StartDate) VALUES ('Temp', 0.00, GETDATE())");
// Default value for FK points to department created above, with
// defaultValue changed to 1 in following AddColumn statement.

migrationBuilder.AddColumn<int>(
    name: "DepartmentID",
    table: "Course",
    nullable: false,
    defaultValue: 1);

經(jīng)過上面的更改,Course 行將在 ComplexDataModel Up 方法運(yùn)行后與“臨時(shí)”系建立聯(lián)系。

生產(chǎn)應(yīng)用可能:

  • 包含用于將 Department 行和相關(guān) Course 行添加到新 Department 行的代碼或腳本。
  • 不會(huì)使用“臨時(shí)”系或 Course.DepartmentID 的默認(rèn)值。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號