شرط گذاری روی Include ها در EF Core

این عکس ربطی نداره فقط چون خوشم اومد گذاشتم :)
این عکس ربطی نداره فقط چون خوشم اومد گذاشتم :)

خب بریم سر اصل مطلب

سناریویی را در نظر بگیرید که میخواهید لیست Blog‌ها به همراه Post هایشان که شامل کلمه خاصی است را کلاینت باز گردانید. در این احتمالا چنین کدی به نظرتان خواهد آمد.

var list = dbContext.Blogs
    .AsNoTracking()
    .Include(p => p.Posts.Where(p => p.Title.Contains(&quottest title&quot)))
    .ToList();
return Json(list);

این کد تا قبل از EFCore 5.0 پیش نمایش 3 به خطای زیر منجر میشود چرا که EFCore از شرط گذاری روی Include‌ها پشتیبانی نمی‌کند.

System.InvalidOperationException: 'Lambda expression used inside Include is not valid.'

پس مجبوریم همه رکورد‌های Include را از دیتابیس خوانده و سپس آنها را در حافظه فیلتر کنیم.

var list = dbContext.Blogs
    .AsNoTracking()
    .Include(e => e.Posts)
    .ToList();
list.ForEach(p => p.Posts = p.Posts.Where(p => p.Title.Contains(&quottest title&quot)).ToList());
  • این روش سربار بسیار زیادی دارد و بسته به تعداد رکورد‌ها و ستون‌های Post حجم زیادی از دیتای غیر لازم را از دیتابیس میخواند و تخصیص حافظه (memory allocation) اضافی و زیادی را به همراه دارد. مثلا اگر 100 Blog داشته باشیم که هرکدام 100 Post داشته باشد و فقط یکی از Post‌ها شرط مورد نظر را داشته باشد، بدین ترتیب 100 * 100 منهای 1، رکورد اضافی واکشی خواهد شد یعنی برابر ‭9,999‬! (می توان با لحاظ کردن تعداد و حجم ستون‌های اضافی نیز، وخامت اوضاع را درک کرد)
  • همچنین اگر به منظور غیر read-only (عدم استفاده از AsNoTracking)  داده‌ها را لود کرده باشید با شرطی که داخل ForEach اعمال می‌شود. رکورد هایی که فیلتر میشوند به صورت Deleted در ChangeTracker علامت گذاری میشوند که میتواند مشکل ساز نیز باشد.

برای حل این مشکل چندین روش وجود دارد:

1- توسط یک تایپ دلخواه (anonymous یا dto) واکشی را به صورت Projection انجام دهیم و Post‌ها را فیلتر کنیم.

var list = dbContext.Blogs
    .AsNoTracking()
    .Select(p => new
    {
        p.Id,
        p.Name,
        Posts = p.Posts.Where(p => p.Title.Contains(&quottest title&quot)).ToList()
}).ToList();

این دستور، کوئری SQL زیر را تولید میکند.

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Description], [t].[Title]
FROM [Blogs] AS [
LEFT JOIN (
    SELECT [p].[Id], [p].[BlogId], [p].[Description], [p].[Title]
    FROM [Posts] AS [p]
    WHERE CHARINDEX(N'test title', [p].[Title]) > 0
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Id]

معایب این روش:

  • در صورت نیاز به ویرایش (عدم استفاده از AsNoTracking) بدلیل استفاده از anonymous به جای Blog، هیچ شی Blog ایی در ChangeTracker ثبت نخواهد شد ولی اشیا Post در ChangeTracker ثبت خواهند شد. در نتیجه تنها 1 شی در ChangeTracker اضافه خواهد شد.
  • کد نویسی را کثیف میکند مخصوصا اگر نیاز به شرط گذاری روی چندین Navigation Collection تو در تو داشته باشید.

برای جلوگیری از این کثیف شدن میتوان از قابلیت Projection کتابخانه AutoMapper استفاده کرد. کوئری تولید شده و عملکرد آن عینا مشابه همین روش است ولی کد تمیز‌تری را موجب می‌شود. ( از نظر سرعت مقدار کمی کند‌تر است  - انتهای مقاله بنچمارک آن را میتوانید مشاهده کنید)


2- از قابلیت IncludeFilter کتابخانه  Z.EntityFramework.Plus.EFCore استفاده کنیم

این کتابخانه امکانات بسیار مفیدی را ارائه میدهد و شخصا برای پروژه‌های واقعی و بزرگ آن را پیشنهاد میدهم. اگر از امکانات آن به جا استفاده شود تاثیر بسیار زیادی روی پرفرمنس پروژه خواهد گذاشت (توصیه میکنم حتما داکیومنت آن را مطالعه کنید)

این کتابخانه کاملا رایگان است و از EFCore و EF6 (در یک پکیج جداگانه) پشتیبانی میکند. شرکت مالک آن (ZZZ) یک کتابخانه دیگر به نام  Z.EntityFramework.Extensions.EFCore نیز دارد که امکانات بیشتری ارائه میدهد ولی رایگان نیست.

در این روش خواهیم داشت

var list = dbContext.Blogs
    .AsNoTracking()
    .IncludeFilter(e => e.Posts.Where(p => p.Title.Contains(&quottest tile&quot)))
    .ToList();

این دستور کوئری SQL زیر را تولید میکند

-- EF+ Query Future: 1 of 2
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
;
-- EF+ Query Future: 2 of 2
SELECT [t].[Id], [t].[BlogId], [t].[Description], [t].[Title]
FROM [Blogs] AS [b]
INNER JOIN (
    SELECT [p].[Id], [p].[BlogId], [p].[Description], [p].[Title]
    FROM [Posts] AS [p]
    WHERE CHARINDEX(N'test title', [p].[Title]) > 0
) AS [t] ON [b].[Id] = [t].[BlogId]
;
  • همانطور که میبینید این دستور، 2 کوئری را اجرا میکند. سرعت آن از روش قبلی کمی کند‌تر است و memory allocation بیشتری انجام میدهد.
  • در صورت عدم استفاده از AsNoTracking، اشیا Blog را نیز ثبت میکند درنتیجه تعداد 101 ابجکت (100 Blog و 1 Post) به ChangeTracker اضافه خواهند شد.
  • کد نویسی تمیزتر و راحت‌تری سمت سی شارپ دارد.
  • این روش در EF6 نیز قابل استفاده است.


3- کمبود این قابلیت در EFCore بسیار حس میشد (در NHibernate از قدیم این امکان وجود داشت) تا اینکه نهایتا در EFCore 5.0 پیش نمایش 3 (آخرین نسخه در حال حاضر) این قابلیت به EFCore اضافه شد.

برای استفاده از آن نیاز به هیچ کد اضافه ای نیست و به صورت معمول میتوان از متد Include همراه با شرط استفاده کرد.

var list = dbContext.Blogs
    .AsNoTracking()
    .Include(p => p.Posts.Where(p => p.Title.Contains(&quottest title&quot)))
    .ToList();

این دستور کوئری SQL زیر را تولید میکند.

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Description], [t].[Title]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[BlogId], [p].[Description], [p].[Title]
    FROM [Posts] AS [p]
    WHERE CHARINDEX(N'test title', [p].[Title]) > 0
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Id]
  • این روش بسیار بهینه است و از روش قبلی (دوم) کمی سریع‌تر بوده و memory allocation کمتری (نزدیک به روش اول) دارد.
  • در صورت عدم استفاده از AsNoTracking، مانند قبلی عمل میکند درنتیجه تعداد 101 ابجکت به ChangeTracker اضافه خواهند شد.
  • کد نویسی تمیزتر و راحت‌تری سمت سی شارپ دارد.


بنچمارک مقایسه این روش‌ها را میتوانید از ریپازیتوری گیتهاب زیر دریافت کنید.

https://github.com/mjebrahimi/EFCore-Include-Filtering-Benchmark

تصویر زیر نتایج آن را نشان میدهد. این شاخص‌ها بر اساس تعداد رکورد ها، ستون‌ها و حجم دیتای واکشی شده از دیتابیس میتواند متفاوت باشد ولی نتیجه آن از لحاظ مقایسه ای مشابه همین خواهد بود.

? کانال دات نت زوم

https://t.me/DotNetZoom