認識 Entity Framework Core 載入關聯資料的三種方法 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

認識 Entity Framework Core 載入關聯資料的三種方法

Entity Framework Core 讓你可以透過「導覽屬性」快速的取得「關聯」資料,不過方便的背後可能會犧牲一些效能,早期在 Entity Framework 的年代,預設啟用「延遲載入」機制,這個預設值可能會導致許多意外的效能問題,以致於 "Entity Framework 很慢" 的臭名一直延續至今,即便 Entity Framework Core 已經沒有這個問題,許多初學者還是不太清楚如何正確的使用 Entity Framework Core 來存取關聯資料。這篇文章我就來說說 Entity Framework Core 提供的三種關聯資料載入策略,以及如何判斷何時該用哪種策略載入資料。

不載入關聯資料

在 Entity Framework 6 之前,預設可以透過「導覽屬性」自動載入關聯資料,而且是在「第一次」存取導覽屬性時「延遲載入」資料。不過從 Entity Framework Core 開始,已經不會自動載入關聯資料,任何需要透過「導覽屬性」載入關聯資料的情境,都需要明確指定你要如何載入。

我為這篇文章準備了一個 ASP.NET Core 6 的範例專案,所有程式碼都會從 master 分支的最新版開始改起,初始化專案的步驟如下:

  1. 取回原始碼

    git clone https://github.com/doggy8088/EFCore6Demo.git
    cd EFCore6Demo
    dotnet build
    

    這份原始碼已經包含了一個 ContosoUniversity.db 資料庫檔案 (SQLite),如果你有修改 Entity Framework Core 的實體模型,才需要用以下命令更新資料庫。

    dotnet ef migrations add MIGRATION_NAME
    dotnet ef database update -v
    
  2. 開啟 VS Code

    code .
    
  3. 啟動專案

    dotnet watch run
    
  4. 呼叫 API

    你可以直接用瀏覽器打開 https://localhost:7295/api/Departments/1 即可看到結果。

    也可以透過 curl 進行測試:

    curl -s 'https://localhost:7295/api/Departments/1'
    

    你會得到以下結果:

    {
      "departmentId": 1,
      "name": "教育訓練部",
      "budget": 1000,
      "startDate": "2022-04-20T23:38:36.372125",
      "instructorId": null,
      "instructor": null,
      "courses": []
    }
    

    這裡你可以看到 courses 這個屬性回傳一個「空陣列」,這意味著 Entity Framework Core 並不會透過「導覽屬性」載入 Courses 的關聯資料。

預先載入 (Eager loading) (積極載入) (提前載入)

假設我們想載入一筆 Department 資料所關聯的多筆 Course 資料(一對多關係),我們有幾種可能的實現方式:

  1. 不透過導覽屬性,直接對 Course 查詢資料

    [HttpGet("{id}/Courses/v1")]
    public ActionResult<IEnumerable<Course>> GetDepartmentCourses(int id)
    {
        return this._db.Courses.Where(p => p.DepartmentId == id).ToList();
    }
    

    存取 https://localhost:7295/api/Departments/5/Courses/v1 應該會得到 3 筆 Course 型別的資料:

    curl -s 'https://localhost:7295/api/Departments/5/Courses/v1'
    
    [
      {
        "courseId": 1,
        "title": "Entity Framework 6 開發實戰",
        "credits": 1,
        "departmentId": 5,
        "department": null,
        "enrollments": [],
        "instructors": []
      },
      {
        "courseId": 2,
        "title": "Git新手入門",
        "credits": 1,
        "departmentId": 5,
        "department": null,
        "enrollments": [],
        "instructors": []
      },
      {
        "courseId": 3,
        "title": "Git進階版控流程",
        "credits": 2,
        "departmentId": 5,
        "department": null,
        "enrollments": [],
        "instructors": []
      }
    ]
    

    這種寫法的優點是「效率好」,不用對 DepartmentCourse 進行 JOIN 查詢。

    如果你不需要輸出 Department 資料,這種寫法其實沒有什麼缺點,唯一的缺點就是 Course 型別也有 3 個導覽屬性 (department, enrollments, instructors),這三個導覽屬性不會有資料,因為 Entity Framework Core 預設並不會「延遲載入」關聯資料。如果你不需要將這三個導覽屬性輸出,可以參考 How to ignore properties with System.Text.Json 文章說明,加上 [JsonIgnore] 屬性 (Attribute) 到「導覽屬性」上即可。

  2. 透過導覽屬性,直接對 Department 查詢資料就好,關聯資料直接透過「導覽屬性」取得

    我們可以透過 .Include(dept => dept.Courses) 明確的告訴 EF Core 說要載入關聯資料,這樣的寫法會在查詢資料時自動填滿 dept.Courses 導覽屬性,讓你直接可以取得關聯資料:

    [HttpGet("{id}/Courses/v2")]
    public ActionResult<IEnumerable<Course>> GetDepartmentCoursesByEagerLoading(int id)
    {
        var dept = this._db.Departments.Include(dept => dept.Courses).First(p => p.DepartmentId == id);
        return dept.Courses.ToList();
    }
    

    開啟 https://localhost:7295/api/Departments/5/Courses/v2 你應該會得到以下「錯誤」訊息:

    JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.Department.Courses.CourseId.
    

    這算是在 ORM 的領域中常見的問題,因為「循環參考」導致資料無法序列化成 JSON 的狀況。

    所謂的「循環參考」意味著你在將 Department 物件序列化成 JSON 的時候,其中有個「屬性」為「導覽屬性」,導覽屬性會幫你找出關聯的 Courses 集合資料,而每一筆 Course 資料也包含了一個 Department 導覽屬性,又會嘗試序列化這個屬性,如此一來就會形成一個「循環」,這會導致無窮迴圈的問題,因此會出現這個錯誤。

    這個問題,我們可以透過 [JsonIgnore] 解決「循環參考」問題,你只要將 Course 型別上的「導覽屬性」加上 [JsonIgnore] 就不會發生「循環參考」問題。不過,你還是要特別小心,預先載入一個集合導覽屬性很有可能對效能帶來巨大衝擊。你可以適時的利用分割查詢(Split queries) 來優化查詢效率,避免因為 JOIN 的關係從資料庫回傳大量的結果集

  3. 連續載入多層導覽屬性

    如果你有個「一對多對多」的關聯,可以參考以下語法載入多層關聯的資料:

    using (var context = new BloggingContext())
    {
        var blogs = context.Blogs
            .Include(blog => blog.Posts)
            .ThenInclude(post => post.Author)
            .ToList();
    }
    

    可以連續載入多層的關聯資料:

    using (var context = new BloggingContext())
    {
        var blogs = context.Blogs
            .Include(blog => blog.Posts)
            .ThenInclude(post => post.Author)
            .ThenInclude(author => author.Photo)
            .ToList();
    }
    

    也可以載入相對複雜的關聯資料:

    using (var context = new BloggingContext())
    {
        var blogs = context.Blogs
            .Include(blog => blog.Posts)
            .ThenInclude(post => post.Author)
            .ThenInclude(author => author.Photo)
            .Include(blog => blog.Owner)
            .ThenInclude(owner => owner.Photo)
            .ToList();
    }
    

    注意:連續載入多層導覽屬性會有很大的機率導致 EF Core 查詢效能低落,請務必小心使用!

要解決「循環參考」導致資料無法序列化成 JSON 的狀況,也有多種解決方案,不同的解決方案對效能的影響巨大,不可不知!

以下我分享三種解決方案:

  1. 調整 Controller 的 Json 序列化設定 (JsonSerializerOptions)

    這是效能最差最差的解法,拜託不要這樣用,寫給你看不是要你學,是提醒你不要這樣寫!

    builder.Services.AddControllers().AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
    });
    

    這種寫法的優點是「不會出現例外」,而且你在不特別加入 [JsonIgnore] 的情況下,你的 API 就可以順利輸出 JSON 結果。你知道的,有些人要求不多,只求程式能動就好,那這招你可以參考,快,又有效!XD

    這種寫法的缺點是「效能極差」,而且可能會浪費大量頻寬,產生許多無意義的關聯資料。

    你直接看執行結果就知道了,許多無意義的循環參考資料都被加入,非常沒有效率:

    [
      {
        "courseId": 1,
        "title": "Entity Framework 6 開發實戰",
        "credits": 1,
        "departmentId": 5,
        "department": {
          "departmentId": 5,
          "name": "專案開發部",
          "budget": 1000,
          "startDate": "2022-04-20T23:38:36.3721319",
          "instructorId": null,
          "instructor": null,
          "courses": [
            null,
            {
              "courseId": 2,
              "title": "Git新手入門",
              "credits": 1,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            },
            {
              "courseId": 3,
              "title": "Git進階版控流程",
              "credits": 2,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            }
          ]
        },
        "enrollments": [],
        "instructors": []
      },
      {
        "courseId": 2,
        "title": "Git新手入門",
        "credits": 1,
        "departmentId": 5,
        "department": {
          "departmentId": 5,
          "name": "專案開發部",
          "budget": 1000,
          "startDate": "2022-04-20T23:38:36.3721319",
          "instructorId": null,
          "instructor": null,
          "courses": [
            {
              "courseId": 1,
              "title": "Entity Framework 6 開發實戰",
              "credits": 1,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            },
            null,
            {
              "courseId": 3,
              "title": "Git進階版控流程",
              "credits": 2,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            }
          ]
        },
        "enrollments": [],
        "instructors": []
      },
      {
        "courseId": 3,
        "title": "Git進階版控流程",
        "credits": 2,
        "departmentId": 5,
        "department": {
          "departmentId": 5,
          "name": "專案開發部",
          "budget": 1000,
          "startDate": "2022-04-20T23:38:36.3721319",
          "instructorId": null,
          "instructor": null,
          "courses": [
            {
              "courseId": 1,
              "title": "Entity Framework 6 開發實戰",
              "credits": 1,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            },
            {
              "courseId": 2,
              "title": "Git新手入門",
              "credits": 1,
              "departmentId": 5,
              "department": null,
              "enrollments": [],
              "instructors": []
            },
            null
          ]
        },
        "enrollments": [],
        "instructors": []
      }
    ]
    
  2. 調整實體模型的導覽屬性,特別加上 [JsonIgnore] 屬性 (Attribute)

    public partial class Course
    {
        public Course()
        {
            Enrollments = new HashSet<Enrollment>();
            Instructors = new HashSet<Person>();
        }
    
        public int CourseId { get; set; }
        public string? Title { get; set; }
        public int Credits { get; set; }
        public int DepartmentId { get; set; }
    
        [JsonIgnore] // <-- 加上這行
        public virtual Department Department { get; set; } = null!;
        [JsonIgnore] // <-- 加上這行
        public virtual ICollection<Enrollment> Enrollments { get; set; }
        [JsonIgnore] // <-- 加上這行
        public virtual ICollection<Person> Instructors { get; set; }
    }
    

    在執行一次,就可以得到相當乾淨的 JSON 結果:

    [
      {
        "courseId": 1,
        "title": "Entity Framework 6 開發實戰",
        "credits": 1,
        "departmentId": 5
      },
      {
        "courseId": 2,
        "title": "Git新手入門",
        "credits": 1,
        "departmentId": 5
      },
      {
        "courseId": 3,
        "title": "Git進階版控流程",
        "credits": 2,
        "departmentId": 5
      }
    ]
    

    這種寫法的優點是「輸出的 JSON 結果很乾淨,也節省網路頻寬,下載速度快」,而且你的 API 也可以順利輸出 JSON 結果!

    這種寫法的缺點是「需要手動調整實體模型」,而且你若用 DB First 的方式自動產生實體模型,下次在重新產生實體模型時可能會蓋掉你自己手動加上的 [JsonIgnore] 屬性 (Attribute),這個層次的程式維護性是需要考慮的!

    要解決這個缺點,可以考慮用使用自訂 ViewModel 的方式,將 API 回應 JSON 部分避開「循環參考」等問題。

  3. 透過 Microsoft.AspNetCore.Mvc.NewtonsoftJson 套件搭配 MetadataType 在另一個型別套用 [JsonIgnore] 屬性 (Attribute)

    先加入 Microsoft.AspNetCore.Mvc.NewtonsoftJson 套件

    dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
    

    調整 Program.csbuilder.Services.AddControllers() 內容:

    builder.Services.AddControllers().AddNewtonsoftJson();
    

    加入一個 Course.Partial.cs 檔案,內容如下,但重點是你不能用 ASP.NET Core MVC 的 ModelMetadataType 屬性 (Attribute),必須改用 System.ComponentModel.DataAnnotations 下的 MetadataType 屬性 (Attribute),而且 [JsonIgnore] 屬性 (Attribute) 要用 Newtonsoft.Json 命名空間下的才行:

    using System.ComponentModel.DataAnnotations;
    
    namespace EFCore6Demo.Models;
    
    [MetadataType(typeof(CourseMetadata))]
    public partial class Course
    {
    }
    
    internal class CourseMetadata
    {
        public int CourseId { get; set; }
        public string? Title { get; set; }
        public int Credits { get; set; }
        public int DepartmentId { get; set; }
    
        [Newtonsoft.Json.JsonIgnore]
        public virtual Department Department { get; set; } = null!;
        [Newtonsoft.Json.JsonIgnore]
        public virtual ICollection<Enrollment> Enrollments { get; set; }
        [Newtonsoft.Json.JsonIgnore]
        public virtual ICollection<Person> Instructors { get; set; }
    }
    

    這種寫法的優點是輸出的 JSON 結果很乾淨你的 API 也可以順利輸出 JSON 結果,而且就算你用 DB First 的方式自動產生實體模型,下次在重新產生實體模型時也不會蓋掉你自己手動加上的 [JsonIgnore] 屬性 (Attribute),可以兼具效率程式維護性

    這種寫法的缺點是必須繼續使用 Newtonsoft.Json (JSON.NET) 來序列化回應的資料,無法享受 System.Text.Json 命名空間下的類別帶來的效能優勢!

延遲載入 (Lazy loading) (消極載入)

我可以說「延遲載入」(Lazy loading) 是 Entity Framework Core 或任何 ORM 框架的初學者最愛的功能,但是越方便的功能越可能帶來致命的效能傷害,經典的 N+1 問題就是因為「延遲載入」所造成的,不得不謹慎看待,沒事千萬不要啟用這個功能!

要在 Entity Framework Core 啟用「延遲載入」功能還挺麻煩的,步驟如下:

  1. 安裝 Microsoft.EntityFrameworkCore.Proxies 套件

    當透過導覽屬性讀取關聯資料時,會自動載入關聯資料 (需額外設定)

    dotnet add package Microsoft.EntityFrameworkCore.Proxies
    
  2. 調整載入 DbContext 的設定

    builder.Services.AddDbContext<ContosoUniversityContext>(options =>
      options.UseLazyLoadingProxies()
        .UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
    

    需特別注意 Related data and serialization 的問題!

  3. 接著你的 DepartmentController 就可以加入以下 Action

    [HttpGet("{id}/Courses/v3")]
    public ActionResult<IEnumerable<Course>> GetDepartmentCoursesByLazyLoading(int id)
    {
        var dept = this._db.Departments.Find(id);
        return dept.Courses.ToList();
    }
    

若不想要啟用 Proxy 實體模型的話,那就麻煩很多,並不適用 DB First 的用戶,有興趣可瞭解的朋友可以參考官網的 Lazy loading without proxies 文件。

明確載入 (Explicit loading)

當你想精準的控制「關聯資料」的載入時機,就可以透過明確載入的 API 來自行載入導覽屬性的關聯資料。

  • 明確載入集合導覽屬性 (載入一對多的導覽屬性內容)

    [HttpGet("{id}/Courses/v4")]
    public ActionResult<IEnumerable<Course>> GetDepartmentCoursesByExplicitLoading(int id)
    {
        var dept = this._db.Departments.Find(id);
        this._db.Entry(dept).Collection(p => p.Courses).Load();
        return dept.Courses.ToList();
    }
    
  • 明確載入參考導覽屬性 (載入多對一的導覽屬性內容)

    [HttpGet("~/api/Courses/{id}/Department")]
    public ActionResult<Department> GetDepartmentFromCourseByExplicitLoading(int id)
    {
        var course = this._db.Courses.Find(id);
        this._db.Entry(course).Reference(p => p.Department).Load();
        return course.Department;
    }
    

    若不想 JSON 輸出導覽屬性,請記得要加上正確的 [JsonIgnore] 屬性 (Attribute)

  • 設定明確載入但僅定義 LINQ 查詢

    從官網的 Querying related entities 可以看到一個很棒的用法,你在「明確載入集合導覽屬性」或「明確載入參考導覽屬性」的時候不要執行 .Load() 方法,而是改執行 .Query() 方法。這段語法將不會立即載入資料,而是產生 LINQ 語法而已,你可以搭配「匯總函式」來優化查詢效率!👍

    using (var context = new BloggingContext())
    {
        var blog = context.Blogs
            .Single(b => b.BlogId == 1);
    
        var postCount = context.Entry(blog)
            .Collection(b => b.Posts)
            .Query()
            .Count();
    }
    

相關連結