حين كار با ORMهاي پيشرفته، ويژگيهاي جالب توجهي در اختيار برنامه نويسها قرار ميگيرد كه در زمان استفاده از كلاسهاي متداول SQLHelper از آنها خبري نيست؛ مانند:
الف) Deferred execution
ب) Lazy loading
ج) Eager loading
نحوه بررسي SQL نهايي توليدي توسط EF
براي توضيح موارد فوق، نياز به مشاهده خروجي SQL نهايي حاصل از ORM است و همچنين شمارش تعداد بار رفت و برگشت به بانك اطلاعاتي. بهترين ابزاري را كه براي اين منظور ميتوان پيشنهاد داد، برنامه EF Profiler است. براي دريافت آن ميتوانيد به اين آدرس مراجعه كنيد: (^) و (^)
پس از وارد كردن نام و آدرس ايميل، يك مجوز يك ماهه آزمايشي، به آدرس ايميل شما ارسال خواهد شد.
زمانيكه اين فايل را در ابتداي اجراي برنامه به آن معرفي ميكنيد، محل ذخيره سازي نهايي آن جهت بازبيني بعدي، مسير MyUserName\Local Settings\Application Data\EntityFramework Profiler خواهد بود.
استفاده از اين برنامه هم بسيار ساده است:
الف) در برنامه خود، ارجاعي را به اسمبلي HibernatingRhinos.Profiler.Appender.dll كه در پوشه برنامه EFProf موجود است، اضافه كنيد.
ب) در نقطه آغاز برنامه، متد زير را فراخواني نمائيد:
HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize();
نقطه آغاز برنامه ميتواند متد Application_Start برنامههاي وب، در متد Program.Main برنامههاي ويندوزي كنسول و WinForms و در سازنده كلاس App برنامههاي WPF باشد.
ج) برنامه EFProf را اجرا كنيد.
مزاياي استفاده از اين برنامه
1) وابسته به بانك اطلاعاتي مورد استفاده نيست. (برخلاف براي مثال برنامه معروف SQL Server Profiler كه فقط به همراه SQL Server ارائه ميشود)
2) خروجي SQL نمايش داده شده را فرمت كرده و به همراه Syntax highlighting نيز هست.
3) كار اين برنامه صرفا به لاگ كردن SQL توليدي خلاصه نميشود. يك سري از Best practices را نيز به شما گوشزد ميكند. بنابراين اگر نياز داريد سيستم خود را بر اساس ديدگاه يك متخصص بررسي كنيد (يك Code review ارزشمند)، اين ابزار ميتواند بسيار مفيد باشد.
4) ميتواند كوئريهاي سنگين و سبك را به خوبي تشخيص داده و گزارشات آماري جالبي را به شما ارائه دهد.
5) ميتواند دقيقا مشخص كند، كوئري را كه مشاهده ميكنيد از طريق كدام متد در كدام كلاس صادر شده است و دقيقا از چه سطري.
6) امكان گروه بندي خودكار كوئريهاي صادر شده را بر اساس DbContext مورد استفاده به همراه دارد.
و ...
استفاده از اين برنامه حين كار با EF «الزامي» است! (البته نسخههاي NH و ساير ORMهاي ديگر آن نيز موجود است و اين مباحث در مورد تمام ORMهاي پيشرفته صادق است)
مدام بايد بررسي كرد كه صفحه جاري چه تعداد كوئري را به بانك اطلاعاتي ارسال كرده و به چه نحوي. همچنين آيا ميتوان با اعمال اصلاحاتي، اين وضع را بهبود بخشيد. بنابراين عدم استفاده از اين برنامه حين كار با ORMs، همانند راه رفتن در خواب است! ممكن است تصور كنيد برنامه دارد به خوبي كار ميكند اما ... در پشت صحنه فقط صفحه جاري برنامه، 100 كوئري را به بانك اطلاعاتي ارسال كرده، در حاليكه شما تنها نياز به يك كوئري داشتهايد.
كلاسهاي مدل مثال جاري
كلاسهاي مدل مثال جاري از يك دپارتمان كه داراي تعدادي كارمند ميباشد، تشكيل شده است. ضمنا هر كارمند تنها در يك دپارتمان ميتواند مشغول به كار باشد و رابطه many-to-many نيست :
using System.Collections.Generic; namespace EF_Sample06.Models { public class Department { public int DepartmentId { get; set; } public string Name { get; set; } //Creates Employee navigation property for Lazy Loading (1:many) public virtual ICollection<Employee> Employees { get; set; } } }
namespace EF_Sample06.Models { public class Employee { public int EmployeeId { get; set; } public string FirstName { get; set; } public string LastName { get; set; } //Creates Department navigation property for Lazy Loading public virtual Department Department { get; set; } } }
نگاشت دستي اين كلاسها هم ضرورتي ندارد، زيرا قراردادهاي توكار EF Code first را رعايت كرده و EF در اينجا به سادگي ميتواند primary key و روابط one-to-many را بر اساس navigation properties تعريف شده، تشخيص دهد.
در اينجا كلاس Context برنامه به شرح زير است:
using System.Data.Entity; using EF_Sample06.Models; namespace EF_Sample06.DataLayer { public class Sample06Context : DbContext { public DbSet<Department> Departments { set; get; } public DbSet<Employee> Employees { set; get; } } }
و تنظيمات ابتدايي نحوه به روز رساني و آغاز بانك اطلاعاتي نيز مطابق كدهاي زير ميباشد:
using System.Collections.Generic; using System.Data.Entity.Migrations; using EF_Sample06.Models; namespace EF_Sample06.DataLayer { public class Configuration : DbMigrationsConfiguration<Sample06Context> { public Configuration() { AutomaticMigrationsEnabled = true; AutomaticMigrationDataLossAllowed = true; } protected override void Seed(Sample06Context context) { var employee1 = new Employee { FirstName = "f name1", LastName = "l name1" }; var employee2 = new Employee { FirstName = "f name2", LastName = "l name2" }; var employee3 = new Employee { FirstName = "f name3", LastName = "l name3" }; var employee4 = new Employee { FirstName = "f name4", LastName = "l name4" }; var dept1 = new Department { Name = "dept 1", Employees = new List<Employee> { employee1, employee2 } }; var dept2 = new Department { Name = "dept 2", Employees = new List<Employee> { employee3 } }; var dept3 = new Department { Name = "dept 3", Employees = new List<Employee> { employee4 } }; context.Departments.Add(dept1); context.Departments.Add(dept2); context.Departments.Add(dept3); base.Seed(context); } } }
نكته: تهيه خروجي XML از نگاشتهاي خودكار تهيه شده
اگر علاقمند باشيد كه پشت صحنه نگاشتهاي خودكار EF Code first را در يك فايل XML جهت بررسي بيشتر ذخيره كنيد، ميتوان از متد كمكي زير استفاده كرد:
void ExportMappings(DbContext context, string edmxFile) { var settings = new XmlWriterSettings { Indent = true }; using (XmlWriter writer = XmlWriter.Create(edmxFile, settings)) { System.Data.Entity.Infrastructure.EdmxWriter.WriteEdmx(context, writer); } }
بهتر است پسوند فايل XML توليدي را edmx قيد كنيد تا بتوان آنرا با دوبار كليك بر روي فايل، در ويژوال استوديو نيز مشاهده كرد:
using (var db = new Sample06Context()) { ExportMappings(db, "mappings.edmx"); }
الف) بررسي Deferred execution يا بارگذاري به تاخير افتاده
براي توضيح مفهوم Deferred loading/execution بهترين مثالي را كه ميتوان ارائه داد، صفحات جستجوي تركيبي در برنامهها است. براي مثال يك صفحه جستجو را طراحي كردهايد كه حاوي دو تكست باكس دريافت FirstName و LastName كاربر است. كنار هر كدام از اين تكست باكسها نيز يك چكباكس قرار دارد. به عبارتي كاربر ميتواند جستجويي تركيبي را در اينجا انجام دهد. نحوه پياده سازي صحيح اين نوع مثالها در EF Code first به چه نحوي است؟
using System; using System.Collections.Generic; using System.Data.Entity; using System.Linq; using EF_Sample06.DataLayer; using EF_Sample06.Models; namespace EF_Sample06 { class Program { static IList<Employee> FindEmployees(string fName, string lName, bool byName, bool byLName) { using (var db = new Sample06Context()) { IQueryable<Employee> query = db.Employees.AsQueryable(); if (byLName) { query = query.Where(x => x.LastName == lName); } if (byName) { query = query.Where(x => x.FirstName == fName); } return query.ToList(); } } static void Main(string[] args) { // note: remove this line if you received : create database is not supported by this provider. HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize(); Database.SetInitializer(new MigrateDatabaseToLatestVersion<Sample06Context, Configuration>()); var list = FindEmployees("f name1", "l name1", true, true); foreach (var item in list) { Console.WriteLine(item.FirstName); } } } }
نحوه صحيح اين نوع پياده سازي تركيبي را در متد FindEmployees مشاهده ميكنيد. نكته مهم آن، استفاده از نوع IQueryable و متد AsQueryable است و امكان تركيب كوئريها با هم.
به نظر شما با فراخواني متد FindEmployees به نحو زير كه هر دو شرط آن توسط كاربر انتخاب شده است، چه تعداد كوئري به بانك اطلاعاتي ارسال ميشود؟
var list = FindEmployees("f name1", "l name1", true, true);
شايد پاسخ دهيد كه سه بار : يكبار در متد db.Employees.AsQueryable و دوبار هم در حين ورود به بدنه شرطهاي ياد شده و اينجا است كه كساني كه قبلا با رويههاي ذخيره شده كار كرده باشند، شروع به فرياد و فغان ميكنند كه ما قبلا اين مسايل رو با يك SP در يك رفت و برگشت مديريت ميكرديم!
پاسخ صحيح: «فقط يكبار»! آنهم تنها در زمان فراخواني متد ToList و نه قبل از آن.
براي اثبات اين مدعا نياز است به خروجي SQL لاگ شده توسط EF Profiler مراجعه كرد:
SELECT [Extent1].[EmployeeId] AS [EmployeeId], [Extent1].[FirstName] AS [FirstName], [Extent1].[LastName] AS [LastName], [Extent1].[Department_DepartmentId] AS [Department_DepartmentId] FROM [dbo].[Employees] AS [Extent1] WHERE ([Extent1].[LastName] = 'l name1' /* @p__linq__0 */) AND ([Extent1].[FirstName] = 'f name1' /* @p__linq__1 */)
IQueryable قلب LINQ است و تنها بيانگر يك عبارت (expression) از ركوردهايي ميباشد كه مد نظر شما است و نه بيشتر. براي مثال زمانيكه يك IQueryable را همانند مثال فوق فيلتر ميكنيد، هنوز چيزي از بانك اطلاعاتي يا منبع دادهاي دريافت نشده است. هنوز هيچ اتفاقي رخ نداده است و هنوز رفت و برگشتي به منبع دادهاي صورت نگرفته است. به آن بايد به شكل يك expression builder نگاه كرد و نه ليستي از اشياء فيلتر شدهي ما. به اين مفهوم، deferred execution (اجراي به تاخير افتاده) نيز گفته ميشود.
كوئري LINQ شما تنها زماني بر روي بانك اطلاعاتي اجرا ميشود كه كاري بر روي آن صورت گيرد مانند فراخواني متد ToList، فراخواني متد First يا FirstOrDefault و امثال آن. تا پيش از اين فقط به شكل يك عبارت در برنامه وجود دارد و نه بيشتر.
اطلاعات بيشتر: «تفاوت بين IQueryable و IEnumerable در حين كار با ORMs»
ب) بررسي Lazy Loading يا واكشي در صورت نياز
در مطلب جاري اگر به كلاسهاي مدل برنامه دقت كنيد، تعدادي از خواص به صورت virtual تعريف شدهاند. چرا؟
تعريف يك خاصيت به صورت virtual، پايه و اساس lazy loading است و به كمك آن، تا به اطلاعات شيءايي نياز نباشد، وهله سازي نخواهد شد. به اين ترتيب ميتوان به كارآيي بيشتري در حين كار با ORMs رسيد. براي مثال در كلاسهاي فوق، اگر تنها نياز به دريافت نام يك دپارتمان هست، نبايد حين وهله سازي از شيء دپارتمان، شيء ليست كارمندان مرتبط با آن نيز وهله سازي شده و از بانك اطلاعاتي دريافت شوند. به اين وهله سازي با تاخير، lazy loading گفته ميشود.
Lazy loading پياده سازي سادهاي نداشته و مبتني است بر بكارگيري AOP frameworks يا كتابخانههايي كه امكان تشكيل اشياء Proxy پويا را در پشت صحنه فراهم ميكنند. علت virtual تعريف كردن خواص رابط نيز به همين مساله بر ميگردد، تا اين نوع كتابخانهها بتوانند در نحوه تعريف اينگونه خواص virtual در زمان اجرا، در پشت صحنه دخل و تصرف كنند. البته حين استفاده از EF يا انواع و اقسام ORMs ديگر با اين نوع پيچيدگيها روبرو نخواهيم شد و تشكيل اشياء Proxy در پشت صحنه انجام ميشوند.
يك مثال: قصد داريم اولين دپارتمان ثبت شده در حين آغاز برنامه را يافته و سپس ليست كارمندان آنرا نمايش دهيم:
using (var db = new Sample06Context()) { var dept1 = db.Departments.Find(1); if (dept1 != null) { Console.WriteLine(dept1.Name); foreach (var item in dept1.Employees) { Console.WriteLine(item.FirstName); } } }
رفتار يك ORM جهت تعيين اينكه آيا نياز است براي دريافت اطلاعات بين جداول Join صورت گيرد يا خير، واكشي حريصانه و غيرحريصانه را مشخص ميسازد.
در حالت واكشي حريصانه به ORM خواهيم گفت كه لطفا جهت دريافت اطلاعات فيلدهاي جداول مختلف، از همان ابتداي كار در پشت صحنه، Join هاي لازم را تدارك ببين. در حالت واكشي غيرحريصانه به ORM خواهيم گفت به هيچ عنوان حق نداري Join ايي را تشكيل دهي. هر زماني كه نياز به اطلاعات فيلدي از جدولي ديگر بود بايد به صورت مستقيم به آن مراجعه كرده و آن مقدار را دريافت كني.
به صورت خلاصه برنامه نويس در حين كار با ORM هاي پيشرفته نيازي نيست Join بنويسد. تنها بايد ORM را طوري تنظيم كند كه آيا اينكار را حتما خودش در پشت صحنه انجام دهد (واكشي حريصانه)، يا اينكه خير، به هيچ عنوان SQL هاي توليدي در پشت صحنه نبايد حاوي Join باشند (lazy loading).
در مثال فوق به صورت خودكار دو كوئري به بانك اطلاعاتي ارسال ميگردد:
SELECT [Limit1].[DepartmentId] AS [DepartmentId], [Limit1].[Name] AS [Name] FROM (SELECT TOP (2) [Extent1].[DepartmentId] AS [DepartmentId], [Extent1].[Name] AS [Name] FROM [dbo].[Departments] AS [Extent1] WHERE [Extent1].[DepartmentId] = 1 /* @p0 */) AS [Limit1] SELECT [Extent1].[EmployeeId] AS [EmployeeId], [Extent1].[FirstName] AS [FirstName], [Extent1].[LastName] AS [LastName], [Extent1].[Department_DepartmentId] AS [Department_DepartmentId] FROM [dbo].[Employees] AS [Extent1] WHERE ([Extent1].[Department_DepartmentId] IS NOT NULL) AND ([Extent1].[Department_DepartmentId] = 1 /* @EntityKeyValue1 */)
يكبار زمانيكه قرار است اطلاعات دپارتمان يك (db.Departments.Find) دريافت شود. تا اين لحظه خبري از جدول Employees نيست. چون lazy loading فعال است و فقط اطلاعاتي را كه نياز داشتهايم فراهم كرده است.
زمانيكه برنامه به حلقه ميرسد، نياز است اطلاعات dept1.Employees را دريافت كند. در اينجا است كه كوئري دوم، به بانك اطلاعاتي صادر خواهد شد (بارگذاري در صورت نياز).
ج) بررسي Eager Loading يا واكشي حريصانه
حالت lazy loading بسيار جذاب به نظر ميرسد؛ براي مثال ميتوان خواص حجيم يك جدول را به جدول مرتبط ديگري منتقل كرد. مثلا فيلدهاي متني طولاني يا اطلاعات باينري فايلهاي ذخيره شده، تصاوير و امثال آن. به اين ترتيب تا زمانيكه نيازي به اينگونه اطلاعات نباشد، lazy loading از بارگذاري آنها جلوگيري كرده و سبب افزايش كارآيي برنامه ميشود.
اما ... همين lazy loading در صورت استفاده نا آگاهانه ميتواند سرور بانك اطلاعاتي را در يك برنامه چندكاربره از پا درآورد! نيازي هم نيست تا شخصي به سايت شما حمله كند. مهاجم اصلي همان برنامه نويس كم اطلاع است!
اينبار مثال زير را درنظر بگيريد كه بجاي دريافت اطلاعات يك شخص، مثلا قصد داريم، اطلاعات كليه دپارتمانها را توسط يك Grid نمايش دهيم (فرقي نميكند برنامه وب يا ويندوز باشد؛ اصول يكي است):
using (var db = new Sample06Context()) { foreach (var dept in db.Departments) { Console.WriteLine(dept.Name); foreach (var item in dept.Employees) { Console.WriteLine(item.FirstName); } } }
There is already an open DataReader associated with this Command which must be closed first
براي رفع اين مشكل نياز است گزينه MultipleActiveResultSets=True را به كانكشن استرينگ اضافه كرد:
<connectionStrings> <clear/> <add name="Sample06Context" connectionString="Data Source=(local);Initial Catalog=testdb2012;Integrated Security = true;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" /> </connectionStrings>
سؤال: به نظر شما در دو حلقه تو در توي فوق چندبار رفت و برگشت به بانك اطلاعاتي صورت ميگيرد؟ با توجه به اينكه در متد Seed ذكر شده در ابتداي مطلب، تعداد ركوردها مشخص است.
پاسخ: 7 بار!
و اينجا است كه عنوان شد استفاده از EF Profiler در حين توسعه برنامههاي مبتني بر ORM «الزامي» است! اگر از اين نكته اطلاعي نداشتيد، بهتر است يكبار تمام صفحات گزارشگيري برنامههاي خود را كه حاوي يك Grid هستند، توسط EF Profiler بررسي كنيد. اگر در اين برنامه پيغام خطاي n+1 select را دريافت كرديد، يعني در حال استفاده ناصحيح از امكانات lazy loading ميباشيد.
آيا ميتوان اين وضعيت را بهبود بخشيد؟ زمانيكه كار ما گزارشگيري از اطلاعات با تعداد ركوردهاي بالا است، استفاده ناصحيح از ويژگي Lazy loading ميتواند به شدت كارآيي بانك اطلاعاتي را پايين بياورد. براي حل اين مساله در زمانهاي قديم (!) بين جداول join مينوشتند؛ الان چطور؟
در EF متدي به نام Include جهت Eager loading اطلاعات موجوديتهاي مرتبط به هم درنظر گرفته شده است كه در پشت صحنه همينكار را انجام ميدهد:
using (var db = new Sample06Context()) { foreach (var dept in db.Departments.Include(x => x.Employees)) { Console.WriteLine(dept.Name); foreach (var item in dept.Employees) { Console.WriteLine(item.FirstName); } } }
همانطور كه ملاحظه ميكنيد اينبار به كمك متد Include، نسبت به واكشي حريصانه Employees اقدام كردهايم. اكنون اگر برنامه را اجرا كنيم، فقط يك رفت و برگشت به بانك اطلاعاتي انجام خواهد شد و كار Join نويسي به صورت خودكار توسط EF مديريت ميگردد:
SELECT [Project1].[DepartmentId] AS [DepartmentId], [Project1].[Name] AS [Name], [Project1].[C1] AS [C1], [Project1].[EmployeeId] AS [EmployeeId], [Project1].[FirstName] AS [FirstName], [Project1].[LastName] AS [LastName], [Project1].[Department_DepartmentId] AS [Department_DepartmentId] FROM (SELECT [Extent1].[DepartmentId] AS [DepartmentId], [Extent1].[Name] AS [Name], [Extent2].[EmployeeId] AS [EmployeeId], [Extent2].[FirstName] AS [FirstName], [Extent2].[LastName] AS [LastName], [Extent2].[Department_DepartmentId] AS [Department_DepartmentId], CASE WHEN ([Extent2].[EmployeeId] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1] FROM [dbo].[Departments] AS [Extent1] LEFT OUTER JOIN [dbo].[Employees] AS [Extent2] ON [Extent1].[DepartmentId] = [Extent2].[Department_DepartmentId]) AS [Project1] ORDER BY [Project1].[DepartmentId] ASC, [Project1].[C1] ASC
متد Include در نگارشهاي اخير EF پيشرفت كرده است و همانند مثال فوق، امكان كار با lambda expressions را جهت تعريف خواص مورد نظر به صورت strongly typed ارائه ميدهد. در نگارشهاي قبلي اين متد، تنها امكان استفاده از رشتهها براي معرفي خواص وجود داشت.
همچنين توسط متد Include امكان eager loading چندين سطح با هم نيز وجود دارد؛ مثلا x.Employees.Kids و همانند آن.
چند نكته در مورد نحوه خاموش كردن Lazy loading
امكان خاموش كردن Lazy loading در تمام كلاسهاي برنامه با تنظيم خاصيت Configuration.LazyLoadingEnabled كلاس Context برنامه به نحو زير ميسر است:
public class Sample06Context : DbContext { public Sample06Context() { this.Configuration.LazyLoadingEnabled = false; }
يا اگر تنها در مورد يك كلاس نياز است اين خاموش سازي صورت گيرد، كلمه كليدي virtual را حذف كنيد. براي مثال با نوشتن public ICollection<Employee> Employees بجاي public virtual ICollection<Employee> Employees در اولين بار وهله سازي كلاس دپارتمان، ليست كارمندان آن به نال تنظيم ميشود. البته در اين حالت null object pattern را نيز فراموش نكنيد (وهله سازي پيش فرض Employees در سازنده كلاس):
public class Department { public int DepartmentId { get; set; } public string Name { get; set; } public ICollection<Employee> Employees { get; set; } public Department() { Employees = new HashSet<Employee>(); } }
به اين ترتيب به خطاي null reference object بر نخواهيم خورد. همچنين وهله سازي، با مقدار دهي ليست دريافتي از بانك اطلاعاتي متفاوت است. در اينجا نيز بايد از متد Include استفاده كرد.
بنابراين در صورت خاموش كردن lazy loading، حتما نياز است از متد Include استفاده شود. اگرlazy loading فعال است، جهت تبديل آن به eager loading از متد Include استفاده كنيد (اما اجباري نيست).