امکان انجام محاسبات سمت کلاینت در EF Core
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

در دنیای NET. همواره دو نوع LINQ وجود داشته داشته است: LINQ to Objects و ... مابقی.  در حالت اول با <IEnumerable<T‌ها کار می‌کنیم که تمام عملیات در حافظه انجام می‌شود و در مابقی حالات یک <IQueryable<T وجود دارد که عبارت حاصل از آن جهت کاربردهای مختلفی به زبان‌های متفاوتی مانند SQL ترجمه می‌شوند. در هر دو حالت کلی، Syntax نهایی یکی است و تنها اگر به منبع داده‌ی آن‌ها دقت کنیم، می‌توانیم نوع آن‌ها را تشخیص دهیم. برای نمونه کوئری ذیل بر اساس منبع Blogs است که می‌تواند LINQ to Objects باشد و یا حالت <Queryable<Blog که قرار است به زبانی مشخص ترجمه شود:
var blogs = from blog in Blogs
   where blog.Name.Contains("Development")
   select blog;
اکنون فرض کنید که این عبارت قرار است به SQL ترجمه شده و سپس بر روی یک بانک اطلاعاتی اجرا شود. در این حالت مفسر LINQ باید بداند که متد Contains را چگونه به معادل SQL آن ترجمه کند و این ترجمه می‌تواند بر اساس بانک‌های اطلاعاتی مختلف، متفاوت نیز باشد. اما در حالت LINQ to Objects یک چنین مشکلی وجود ندارد و این ترجمه مستقیما بر روی متد Contains کلاس string انجام می‌شود.
اما اکنون چطور؟
var blogs = from blog in Blogs
   where blog.Name.ComputeHash() == 0
   select blog;
فرض کنید یک متد الحاقی را به نام ComputeHash به کلاس string اضافه کرده‌ایم. یک چنین کوئری را اگر بر روی EF 6.x اجرا کنیم، برنامه با یک استثناء متوقف خواهد شد؛ چون امکان ترجمه‌ی متد ComputeHash را به معادل SQL آن ندارد؛ اما EF Core برای انجام یک چنین کوئری‌هایی بهبود یافته‌است که به آن، محاسبات سمت کلاینت گفته می‌شود.


یک مثال: بررسی تاثیر ارزیابی‌های سمت کلاینت در EF Core

فرض کنید ساختار جدول بلاگ‌ها به صورت زیر است:
public class Blog
{
   public int BlogId { get; set; }
   public string Url { get; set; }  
}
همچنین یک متد الحاقی را به نام ComputeHash به صورت ذیل تعریف کرده‌ایم:
    public static class StringExtensions
    {
        public static int ComputeHash(this string str)
        {
            var hash = 0;
            foreach (var ch in str)
            {
                hash += (int)ch;
            }
            return hash;
        }
    }
اکنون می‌خواهیم بلاگ‌هایی را پیدا کنیم که Hash مربوط به Url آن‌ها بیشتر از 10 است (صرفا جهت نمایش این قابلیت جدید):
using (var context = new BloggingContext())
{
   var blogs = context.Blogs
     .Where(blog => blog.Url.ComputeHash() >= 10)
     .ToList();
   Console.WriteLine(blogs.First().Url);
}
اگر این کوئری را اجرا کنیم، یک چنین خروجی SQL ایی تولید خواهد شد و همچنین برنامه کرش هم نمی‌کند:
SELECT [blog].[BlogId], [blog].[Url]
   FROM [Blogs] AS [blog]
به این معنا که در ارزیابی‌های سمت کلاینت:
الف) مفسر LINQ در EF Core، شروع به ارزیابی کوئری نوشته شده می‌کند و هرجائیکه متدی را یافت و از درک آن عاجز بود (معادل SQL ایی را برای آن نیافت)، آن‌را از کوئری حذف می‌کند.
ب) کوئری SQL نهایی بدون متد ComputeHash بر روی بانک اطلاعاتی اجرا شده و نتیجه به سمت کلاینت بازگشت داده می‌شود. به همین جهت است که در خروجی SQL فوق خبری از متد ComputeHash نیست.
ج) اکنون که EF Core اطلاعات لازم را از سمت سرور دریافت کرده‌است، متد ComputeHash را در سمت کلاینت بر روی این نتیجه‌ی دریافتی اعمال می‌کند. یعنی مرحله‌ی آخر همان LINQ to Objects متداول خواهد بود.
به این ترتیب است که EF Core قابلیت اجرای هر نوع متدی را که معادل SQL ایی برای آن وجود ندارد، خواهد یافت.


چگونه متوجه شویم که ارزیابی سمت کلاینت رخ داده‌است؟

EF Core این قابلیت را دارد تا گزارش کاملی را از ارزیابی‌های سمت کلاینت صورت گرفته ارائه دهد. هرچند در مثال فوق متد الحاقی ComputeHash بسیار واضح است، اما برای نمونه متد string.Join نیز معادل SQL ایی ندارد:
var idUrls = context.Blogs
   .Select(b => new
   {
      IdUrlString = string.Join(", ", b.BlogId, b.Url),
   }).ToList();
این مثال بدون مشکل توسط EF Core و قابلیت جدید ارزیابی سمت کلاینت آن اجرا می‌شود، اما بهتر است از وقوع یک چنین رخ‌دادهایی مطلع شویم:
    public class BloggingContext : DbContext
    {
        public BloggingContext()
        { }

        public BloggingContext(DbContextOptions options)
            : base(options)
        { }

        public DbSet<Blog> Blogs { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Demo.ClientSideEvaluation;Trusted_Connection=True;");
                optionsBuilder.ConfigureWarnings(warnings =>
                {
                    warnings.Log(CoreEventId.IncludeIgnoredWarning);
                    warnings.Log(RelationalEventId.QueryClientEvaluationWarning);
                });
            }
        }
    }
برای این منظور تنها کافی است درخواست فعالسازی لاگ کردن QueryClientEvaluationWarning را در قسمت ConfigureWarnings آن ارائه دهیم. در این حالت اگر برنامه را مجددا اجرا کنیم، ابتدا یک چنین خروجی لاگ می‌شود:
 warn: Microsoft.EntityFrameworkCore.Query[200500]
The LINQ expression 'where ([blog].Url.ComputeHash() >= 10)' could not be translated and will be evaluated locally.
عنوان کرده‌است که قابلیت ترجمه‌ی ComputeHash را به SQL نداشته و آن‌را در نهایت به صورت محلی و در سمت کلاینت محاسبه می‌کند.

اگر می‌خواهید ارزیابی سمت کلاینت را ممنوع کنید، در تنظیمات فوق warnings.Log را به warnings.Throw تغییر دهید. این مورد سبب خواهد شد تا اگر برنامه به این نوع ارزیابی‌ها رسید، با یک استثناء متوقف شود (شبیه به حالت EF 6.x).


تاثیر ارزیابی‌های سمت کلاینت بر روی کارآیی برنامه

هرچند قابلیت ارزیابی‌های سمت کلاینت بسیار مفید است اما باید دقت داشت:
الف) در این حالت چون ابتدا متدهایی که قابلیت ارزیابی در سمت سرور را دارا نیستند، حذف خواهند شد، ممکن است تمام رکوردها به سمت کلاینت بازگشت داده شده و سپس فیلترینگ نهایی در سمت کلاینت صورت گیرد. مانند مثال محاسبه‌ی hash که در SQL تولیدی آن، خبری از قسمت where نیست و این شرط در انتهای کار، در سمت کلاینت و به صورت LINQ to Objects اعمال می‌شود.
ب) این قابلیت ممکن است برنامه نویس‌ها را از تفکر در مورد یافتن روش‌های محاسباتی سمت سرور دور کند. برای مثال هر چند مثال string.Join نوشته شده در سمت کلاینت محاسبه خواهد شد و این کوئری بدون مشکل اجرا می‌شود، اما اگر آن‌را به صورت ذیل جایگزین کنیم:
var idUrls2 = context.Blogs
   .Select(b => new
   {
     IdUrlString = b.BlogId + "," + b.Url
   }).ToList();
اینبار به یک خروجی SQL قابل محاسبه‌ی در سمت سرور، خواهیم رسید:
SELECT (CAST([b].[BlogId] AS nvarchar(max)) + N',') + [b].[Url] AS [IdUrlString]
FROM [Blogs] AS [b]
به همین جهت حداقل لاگ کردن ارزیابی‌های سمت کلاینت را روشن کنید تا از وقوع یک چنین مسایلی مطلع گردید.


کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید: ClientSideEvaluation.zip
  • #
    ‫۵ سال و ۷ ماه قبل، چهارشنبه ۲۴ بهمن ۱۳۹۷، ساعت ۱۷:۵۶
    یک نکته‌ی تکمیلی: کدامیک از متدهای کار با رشته‌ها در سمت کلاینت پردازش می‌شوند و کدام‌ها در سمت سرور؟
    در مثال‌های زیر، هر جائیکه قسمت where عبارت SQL حذف شده‌، یعنی Client-Side Evaluation رخ داده‌است:
    - با اضافه شدن نوع مقایسه، محاسبه‌ی سمت کلاینت رخ می‌دهد:
    var test1 = context.Blogs
    .Where(blog => String.Compare(blog.Url, "A", StringComparison.Ordinal) > 0)
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    - بدون مشخص سازی نوع مقایسه، محاسبه‌ی سمت سرور را خواهیم داشت:
    var test2 = context.Blogs
    .Where(blog => String.Compare(blog.Url, "B") > 0)
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    // WHERE [blog].[Url] > N'B'
    - در مورد متد Equals هم دقیقا به همین صورت است. با اضافه شدن نوع مقایسه، محاسبه‌ی سمت کلاینت رخ می‌دهد: 
    var test3 = context.Blogs
    .Where(blog => blog.Url.Equals("C", StringComparison.OrdinalIgnoreCase))
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    - بدون مشخص سازی نوع مقایسه، محاسبه‌ی سمت سرور را خواهیم داشت: 
    var test3_1 = context.Blogs
    .Where(blog => blog.Url.Equals("C_1"))
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    // WHERE [blog].[Url] = N'C_1'
    - StartsWith و EndsWith در سمت سرور محاسبه می‌شوند:
    var test4 = context.Blogs
    .Where(blog => blog.Url.StartsWith("D"))
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    // WHERE [blog].[Url] LIKE N'D' + N'%' AND (LEFT([blog].[Url], LEN(N'D')) = N'D')
    - اما اگر از عبارات SQL آن‌ها راضی نیستید، از متد EF.Functions.Like استفاده کنید:
    var test5 = context.Blogs
    .Where(blog => EF.Functions.Like(blog.Url, "S_i%"))
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    // WHERE [blog].[Url] LIKE N'S_i%'
    - متدهای ToUpper و ToLower سمت سرور محاسبه می‌شوند (هرچند اگر از SQL Server استفاده می‌کنید، Collation پیش‌فرض آن غیرحساس به حروف کوچک و بزرگ است و در اکثر مواقع نیازی به یک چنین متدهایی نیست):
    var test6 = context.Blogs
    .Where(blog => blog.Url.ToUpper() == "E")
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
    // WHERE UPPER([blog].[Url]) = N'E'
    - اما حالت‌های Invariant دار آن‌ها خیر و در سمت کلاینت محاسبه می‌شوند:
    var test7 = context.Blogs
    .Where(blog => blog.Url.ToUpperInvariant() == "F")
    .ToList();
    // SELECT [blog].[BlogId], [blog].[Url]
    // FROM [Blogs] AS [blog]
  • #
    ‫۵ سال و ۳ ماه قبل، دوشنبه ۲۰ خرداد ۱۳۹۸، ساعت ۱۵:۱۷
    ارتقاء به EF Core 3.0
    تا پیش از EF Core 3.0، استفاده از قابلیت Client-Side Evaluation در هر قسمتی از کوئری میسر است که سبب شده استفاده‌های نادرستی از آن صورت گیرد و کارآیی کوئر‌ی‌ها بی‌جهت کاهش یابد. از نگارش 3 به بعد، این نوع محاسبات فقط در قسمت Select نهایی مجاز است و نه هیچ قسمت دیگری از کوئری؛ در غیراینصورت یک استثناء را دریافت خواهید کرد. برای نمونه در مثالی که در اینجا ارائه شده، از متد ComputeHash در قسمت Where کوئری استفاده شده‌است که اکنون در EF Core 3.0 دیگر مجاز نیست. اگر نیاز است چنین کاری را انجام دهید، خودتان یک ToList را بر روی کوئری، فراخوانی کنید و سپس بر روی نتیجه‌ی LINQ to Objects حاصل، یک Where را بنویسید.