string personName = "user-1"; var result = context.People.Where(p => p.Name == personName).Select(c => c.BornInCity).ToList();
مباحث eager fetching/loading (واکشی حریصانه) و lazy loading/fetching (واکشی در صورت نیاز، با تاخیر، تنبل) جزو نکات کلیدی کار با ORM های پیشرفته بوده و در صورت عدم اطلاع از آنها و یا استفادهی ناصحیح از هر کدام، باید منتظر از کار افتادن زود هنگام سیستم در زیر بار چند کاربر همزمان بود. به همین جهت تصور اینکه "با استفاده از ORMs دیگر از فراگیری SQL راحت شدیم!" یا اینکه "به من چه که پشت صحنه چه اتفاقی میافته!" بسی مهلک و نادرست است!
در ادامه به تفصیل به این موضوع پرداخته خواهد شد.
ابزار مورد نیاز
در این مطلب از برنامهی NHProf استفاده خواهد شد.
اگر مطالب NHibernate این سایت را دنبال کرده باشید، در مورد لاگ کردن SQL تولیدی به اندازهی کافی توضیح داده شده یا حتی یک ماژول جمع و جور هم برای مصارف دم دستی نوشته شده است. این موارد شاید این ایده را به همراه داشته باشند که چقدر خوب میشد یک برنامهی جامعتر برای این نوع بررسیها تهیه میشد. حداقل SQL نهایی فرمت میشد (یعنی برنامه باید مجهز به یک SQL Parser تمام عیار باشد که کار چند ماهی هست ...؛ با توجه به اینکه مثلا NHibernate از افزونههای SQL ویژه بانکهای اطلاعاتی مختلف هم پشتیبانی میکند، مثلا T-SQL مایکروسافت با یک سری ریزه کاریهای منحصر به MySQL متفاوت است)، یا پس از فرمت شدن، syntax highlighting به آن اضافه میشد، در ادامه مشخص میکرد کدام کوئریها سنگینتر هستند، کدامیک نشانهی عدم استفادهی صحیح از ORM مورد استفاده است، چه مشکلی دارد و از این موارد.
خوشبختانه این ایدهها یا آرزوها با برنامهی NHProf محقق شده است. این برنامه برای استفادهی یک ماه اول آن رایگان است (آدرس ایمیل خود را وارد کنید تا یک فایل مجوز رایگان یک ماهه برای شما ارسال گردد) و پس از یک ماه، باید حداقل 300 دلار هزینه کنید.
واکشی حریصانه و غیرحریصانه چیست؟
رفتار یک ORM جهت تعیین اینکه آیا نیاز است برای دریافت اطلاعات بین جداول Join صورت گیرد یا خیر، واکشی حریصانه و غیرحریصانه را مشخص میسازد.
در حالت واکشی حریصانه به ORM خواهیم گفت که لطفا جهت دریافت اطلاعات فیلدهای جداول مختلف، از همان ابتدای کار در پشت صحنه، Join های لازم را تدارک ببین. در حالت واکشی غیرحریصانه به ORM خواهیم گفت به هیچ عنوان حق نداری Join ایی را تشکیل دهی. هر زمانی که نیاز به اطلاعات فیلدی از جدولی دیگر بود باید به صورت مستقیم به آن مراجعه کرده و آن مقدار را دریافت کنی.
به صورت خلاصه برنامه نویس در حین کار با ORM های پیشرفته نیازی نیست Join بنویسد. تنها باید ORM را طوری تنظیم کند که آیا اینکار را حتما خودش در پشت صحنه انجام دهد (واکشی حریصانه)، یا اینکه خیر، به هیچ عنوان SQL های تولیدی در پشت صحنه نباید حاوی Join باشند (lazy loading).
چگونه واکشی حریصانه و غیرحریصانه را در NHibernate 3.0 تنظیم کنیم؟
در NHibernate اگر تنظیم خاصی را تدارک ندیده و خواص جداول خود را به صورت virtual معرفی کرده باشید، تنظیم پیش فرض دریافت اطلاعات همان lazy loading است. به مثالی در این زمینه توجه بفرمائید:
مدل برنامه:
مدل برنامه همان مثال کلاسیک مشتری و سفارشات او میباشد. هر مشتری چندین سفارش میتواند داشته باشد. هر سفارش به یک مشتری وابسته است. هر سفارش نیز از چندین قلم جنس تشکیل شده است. در این خرید، هر جنس نیز به یک سفارش وابسته است.
using System.Collections.Generic;
namespace CustomerOrdersSample.Domain
{
public class Customer
{
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual IList<Order> Orders { get; set; }
}
}
using System;
using System.Collections.Generic;
namespace CustomerOrdersSample.Domain
{
public class Order
{
public virtual int Id { get; set; }
public virtual DateTime OrderDate { set; get; }
public virtual Customer Customer { get; set; }
public virtual IList<OrderItem> OrderItems { set; get; }
}
}
namespace CustomerOrdersSample.Domain
{
public class OrderItem
{
public virtual int Id { get; set; }
public virtual Product Product { get; set; }
public virtual int Quntity { get; set; }
public virtual Order Order { set; get; }
}
}
namespace CustomerOrdersSample.Domain
{
public class Product
{
public virtual int Id { set; get; }
public virtual string Name { get; set; }
public virtual decimal UnitPrice { get; set; }
}
}
که جداول متناظر با آن به صورت زیر خواهند بود:
create table Customers (
CustomerId INT IDENTITY NOT NULL,
Name NVARCHAR(255) null,
primary key (CustomerId)
)
create table Orders (
OrderId INT IDENTITY NOT NULL,
OrderDate DATETIME null,
CustomerId INT null,
primary key (OrderId)
)
create table OrderItems (
OrderItemId INT IDENTITY NOT NULL,
Quntity INT null,
ProductId INT null,
OrderId INT null,
primary key (OrderItemId)
)
create table Products (
ProductId INT IDENTITY NOT NULL,
Name NVARCHAR(255) null,
UnitPrice NUMERIC(19,5) null,
primary key (ProductId)
)
alter table Orders
add constraint fk_Customer_Order
foreign key (CustomerId)
references Customers
alter table OrderItems
add constraint fk_Product_OrderItem
foreign key (ProductId)
references Products
alter table OrderItems
add constraint fk_Order_OrderItem
foreign key (OrderId)
references Orders
همچنین یک سری اطلاعات آزمایشی زیر را هم در نظر بگیرید: (بانک اطلاعاتی انتخاب شده SQL CE است)
SET IDENTITY_INSERT [Customers] ON;
GO
INSERT INTO [Customers] ([CustomerId],[Name]) VALUES (1,N'Customer1');
GO
SET IDENTITY_INSERT [Customers] OFF;
GO
SET IDENTITY_INSERT [Products] ON;
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (1,N'Product1',1000.00000);
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (2,N'Product2',2000.00000);
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (3,N'Product3',3000.00000);
GO
SET IDENTITY_INSERT [Products] OFF;
GO
SET IDENTITY_INSERT [Orders] ON;
GO
INSERT INTO [Orders] ([OrderId],[OrderDate],[CustomerId]) VALUES (1,{ts '2011-01-07 11:25:20.000'},1);
GO
SET IDENTITY_INSERT [Orders] OFF;
GO
SET IDENTITY_INSERT [OrderItems] ON;
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (1,10,1,1);
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (2,5,2,1);
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (3,20,3,1);
GO
SET IDENTITY_INSERT [OrderItems] OFF;
GO
دریافت اطلاعات :
میخواهیم نام کلیه محصولات خریداری شده توسط مشتریها را به همراه نام مشتری و زمان خرید مربوطه، نمایش دهیم (دریافت اطلاعات از 4 جدول بدون join نویسی):
var list = session.QueryOver<Customer>().List();
foreach (var customer in list)
{
foreach (var order in customer.Orders)
{
foreach (var orderItem in order.OrderItems)
{
Console.WriteLine("{0}:{1}:{2}", customer.Name, order.OrderDate, orderItem.Product.Name);
}
}
}
خروجی به صورت زیر خواهد بود:
Customer1:2011/01/07 11:25:20 :Product1
Customer1:2011/01/07 11:25:20 :Product2
Customer1:2011/01/07 11:25:20 :Product3
همانطور که مشاهده میکنید در اینجا اطلاعات از 4 جدول مختلف دریافت میشوند اما ما Join ایی را ننوشتهایم. ORM هرجایی که به اطلاعات فیلدهای جداول دیگر نیاز داشته، به صورت مستقیم به آن جدول مراجعه کرده و یک کوئری، حاصل این عملیات خواهد بود (مطابق تصویر جمعا 6 کوئری در پشت صحنه برای نمایش سه سطر خروجی فوق اجرا شده است).
این حالت فقط و فقط با تعداد رکورد کم بهینه است (و به همین دلیل هم تدارک دیده شده است). بنابراین اگر برای مثال قصد نمایش اطلاعات حاصل از 4 جدول فوق را در یک گرید داشته باشیم، بسته به تعداد رکوردها و تعداد کاربران همزمان برنامه (خصوصا در برنامههای تحت وب)، بانک اطلاعاتی باید بتواند هزاران هزار کوئری رسیده حاصل از lazy loading را پردازش کند و این یعنی مصرف بیش از حد منابع (IO بالا، مصرف حافظه بالا) به همراه بالا رفتن CPU usage و از کار افتادن زود هنگام سیستم.
کسانی که پیش از این با SQL نویسی خو گرفتهاند احتمالا الان منابع موجود را در مورد نحوهی نوشتن Join در NHibernate زیر و رو خواهند کرد؛ زیرا پیش از این آموختهاند که برای دریافت اطلاعات از دو یا چند جدول مرتبط باید Join نوشت. اما همانطور که پیشتر نیز عنوان شد، اگر با جزئیات کار با NHibernate آشنا شویم، نیازی به Join نویسی نخواهیم داشت. اینکار را خود ORM در پشت صحنه باید و میتواند مدیریت کند. اما چگونه؟
در NHibernate 3.0 با معرفی QueryOver که جایگزینی از نوع strongly typed همان ICriteria API قدیمی است، یا با معرفی Query که همان LINQ to NHibernate میباشد، متدی به نام Fetch نیز تدارک دیده شده است که استراتژیهای lazy loading و eager loading را به سادگی توسط آن میتوان مشخص نمود.
مثال: دریافت اطلاعات با استفاده از QueryOver
var list = session
.QueryOver<Customer>()
.Fetch(c => c.Orders).Eager
.Fetch(c => c.Orders.First().OrderItems).Eager
.Fetch(c => c.Orders.First().OrderItems.First().Product).Eager
.List();
foreach (var customer in list)
{
foreach (var order in customer.Orders)
{
foreach (var orderItem in order.OrderItems)
{
Console.WriteLine("{0}:{1}:{2}", customer.Name, order.OrderDate, orderItem.Product.Name);
}
}
}
پشت صحنه:
اینبار فقط یک کوئری حاصل عملیات بوده و join ها به صورت خودکار با توجه به متدهای Fetch ذکر شده که حالت eager loading آنها صریحا مشخص شده است، تشکیل شدهاند (6 بار رفت و برگشت به بانک اطلاعاتی به یکبار تقلیل یافت).
نکته 1: نتایج تکراری
اگر حاصل join آخر را نمایش دهیم، نتایجی تکراری خواهیم داشت که مربوط است به مقدار دهی customer با سه وهله از شیء مربوطه تا بتواند واکشی حریصانهی مجموعه اشیاء فرزند آنرا نیز پوشش دهد. برای رفع این مشکل یک سطر TransformUsing باید اضافه شود:
...
.TransformUsing(NHibernate.Transform.Transformers.DistinctRootEntity)
.List();
دریافت اطلاعات با استفاده از LINQ to NHibernate3.0
برای اینکه بتوان متدهای Fetch ذکر شده را به LINQ to NHibernate 3.0 اعمال نمود، ذکر فضای نام NHibernate.Linq ضروری است. پس از آن خواهیم داشت:
var list = session
.Query()
.FetchMany(c => c.Orders)
.ThenFetchMany(o => o.OrderItems)
.ThenFetch(p => p.Product)
.ToList();
اینبار از FetchMany، سپس ThenFetchMany (برای واکشی حریصانه مجموعههای فرزند) و در آخر از ThenFetch استفاده خواهد شد.
همانطور که ملاحظه میکنید حاصل این کوئری، با کوئری قبلی ذکر شده یکسان است. هر دو، اطلاعات مورد نیاز از دو جدول مختلف را نمایش میدهند. اما یکی در پشت صحنه شامل چندین و چند کوئری برای دریافت اطلاعات است، اما دیگری تنها از یک کوئری Join دار تشکیل شده است.
نکته 2: خطاهای ممکن
ممکن است حین تعریف متدهای Fetch در زمان اجرا به خطاهای Antlr.Runtime.MismatchedTreeNodeException و یا Specified method is not supported و یا موارد مشابهی برخورد نمائید. تنها کاری که باید انجام داد جابجا کردن مکان بکارگیری extension methods است. برای مثال متد Fetch باید پس از Where در حالت استفاده از LINQ ذکر شود و نه قبل از آن.
مروری بر کدهای کلاس SqlHelper
ضمن اینکه مثلا NHibernate قابلیتی دارد به نام second level cache که اساسا برای پروژههای وب طراحی شده. قابلیت کش در سطح کوئری یا اطلاعات پرکاربرد و عمومی سایت را به صورت خودکار دارد. در موردش قبلا مطلب نوشتم : (^). سطح اول کش آن هم پیاده سازی حرفهای همین باز نگه داشتن کانکشنی است که در کد SqlHelper بالا نویسنده موفق به پیاده سازی آن نشده، به علاوه کاهش رفت و آمدها به سرور: (^)
به علاوه NHibernate یک قابلیت دیگر هم دارد به نام ToFuture که میتونه چندین کوئری رو در طی یک رفت و برگشت برای شما انجام بده (^).
و ... خیلی از best practices دیگر هم در آن لحاظ شده. خلاصه اینکه تواناییهای بسیار ارزندهای رو با عدم استفاده از ORMs از دست خواهید داد. منجمله همان بحث کوئریهای پارامتری که عموما از نوشتن آن طفره میروند اما اینجا به صورت خودکار برای شما انجام میشود.
بررسی کارآیی کوئریها در SQL Server - قسمت ششم - بررسی عملگرهای دسترسی به دادهها در یک Query Plan
عملگرهای Scans و Seeks
در حالت کلی میتوان دو نوع جدول بدون و با ایندکس را درنظر گرفت. در حالت جداول بدون ایندکس، برای جستجوی اطلاعات نیاز به Table Scan وجود دارد و برعکس آن شامل یک Clustered index scan خواهد بود. گاهی از اوقات Clustered index scanها بهترین روش دریافت اطلاعات هستند و گاهی از اوقات خیر و نیاز به بررسی بیشتری دارند. بنابراین قانون کلی، حذف آنها به محض مشاهده، نیست.
نوع دیگر عملگرهای دسترسی به دادهها، Seeks هستند که شامل Clustered index seeks و Non-clustered index seeks میشوند. در بسیاری از موارد عنوان میشود که Seeks کارآیی بهتری را به همراه دارند. هرچند این مورد نیاز به بررسی بیشتری دارد که در ادامه با مثالهایی آنها را مرور خواهیم کرد.
بررسی عملگر Table scan در یک Query Plan
در ادامه تعدادی از عملگرهای مرتبط با data access را از لحاظ نحوهی انتخاب و تغییر آنها توسط بهینه ساز کوئریهای SQL Server بررسی میکنیم. برای این منظور ابتدا در management studio از منوی Query، گزینهی Include actual execution plan را انتخاب میکنیم. سپس کوئریهای زیر را اجرا میکنیم:
SET STATISTICS IO ON; GO SET STATISTICS TIME ON; GO SELECT * INTO [Sales].[Copy_Orders] FROM [Sales].[Orders]; GO SELECT [CustomerID], [OrderID], [OrderDate] FROM [Sales].[Copy_Orders] WHERE [CustomerID] > 550; GO
همانطور که مشاهده میکنید، برای برآورده کردن قسمت where این کوئری، یک Table Scan صورت گرفتهاست؛ چون این جدول کپی، به همراه هیچ ایندکسی نیست. به همین جهت برای یافتن رکوردهای مدنظر، راه دیگری بجز اسکن کل جدول بانک اطلاعاتی وجود ندارد که بسیار ناکارآمد است.
همچنین اگر به برگهی messages دقت کنیم، با توجه به روشن بودن STATISTICS IO، میزان logical reads نیز قابل مشاهدهاست:
(33035 rows affected) Table 'Copy_Orders'. Scan count 1, logical reads 689, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
SQL Server Execution Times: CPU time = 79 ms, elapsed time = 762 ms.
بررسی عملگر Index Seek در یک Query Plan
اکنون سؤال اینجا است که آیا میتوان این وضعیت را بهبود بخشید؟
بله. برای این منظور یک NONCLUSTERED INDEX را بر روی جدول کپی، ایجاد میکنیم؛ به نحوی که CustomerID لحاظ شدهی در قسمت where کوئری را پوشش دهد:
CREATE NONCLUSTERED INDEX [IX_Copy_Orders_CustomerID] ON [Sales].[Copy_Orders] ( [CustomerID] ) INCLUDE ( [OrderID], [OrderDate] ); GO
در ادامه مجددا همان کوئری را اجرا میکنیم:
SELECT [CustomerID], [OrderID], [OrderDate] FROM [Sales].[Copy_Orders] WHERE [CustomerID] > 550; GO
اینبار عملگر Table Scan قبلی به یک عملگر Index Seek بر روی NONCLUSTERED INDEX تعریف شده، تغییر کردهاست و اگر به آمار I/O آن دقت کنیم، logical reads 106 قابل مشاهدهاست که بهبود قابل ملاحظهای است نسبت به عدد 689 قبلی.
بررسی عملگر Clustered index scan در یک Query Plan
در ادامه همین کوئری را بر روی جدول [Sales].[Orders] اصلی اجرا میکنیم:
SELECT [CustomerID], [OrderID], [OrderDate] FROM [Sales].[Orders] WHERE [CustomerID] > 550; GO
اجرای کوئری فوق، چنین کوئری پلنی را تولید میکند:
جدول [Sales].[Orders]، یک CLUSTERED INDEX را بر روی [OrderID] دارد و یک NONCLUSTERED INDEX را بر روی [CustomerID].
در کوئری پلن تولید شده، یک Clustered index scan مشاهده میشود. علت اینجا است که هرچند در جدول [Sales].[Orders] یک NONCLUSTERED INDEX بر روی [CustomerID] تعریف شدهاست:
CREATE NONCLUSTERED INDEX [FK_Sales_Orders_CustomerID] ON [Sales].[Orders] ( [CustomerID] ASC )
بنابراین وجود عملگر Clustered index scan در یک کوئری پلن، یعنی نیاز به خواندن و اسکن کل جدول وجود دارد. برای اثبات آن، همین کوئری قبلی را که بر روی [Sales].[Orders] انجام دادیم، اینبار بدون قسمت where آن اجرا کنید. یعنی کوئری بر روی کل جدول انجام شود:
SELECT [CustomerID], [OrderID], [OrderDate] FROM [Sales].[Orders]
سؤال: آیا Clustered index scan همواره کل یک جدول را اسکن میکند؟
پاسخ: خیر. اگر یک کوئری برای مثال دارای top/min/max باشد، کل جدول اسکن نخواهد شد:
SELECT TOP 10 [CustomerID], [OrderID], [OrderDate] FROM [Sales].[Orders] WHERE [CustomerID] > 550;
هرچند در اینجا هم یک Clustered index scan صورت گرفته، اما اگر به برگهی messages آن مراجعه کنیم، آمار I/O آن بیانگر تنها logical reads 5 است که معادل اسکن کل جدول نیست:
(10 rows affected) Table 'Orders'. Scan count 1, logical reads 5, physical reads 0, read-ahead reads 510, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
مقایسهی عملگرهای Index Scan و Index Seek
ابتدا کوئری زیر را اجرا میکنیم:
SELECT [CustomerID], [OrderID] FROM [Sales].[Orders] WHERE [OrderID] > 30000;
در این جدول ایندکسی بر روی CustomerID وجود دارد و همچنین کلید اصلی جدول، OrderID است.
پس از اجرای این کوئری، به کوئری پلن زیر خواهیم رسید:
که بیانگر یک Index Scan است و نکتهی جالب آن، استفادهی از ایندکس FK_Sales_Orders_CustomerID میباشد (نام این شیء، ذیل آیکن عملگر، مشخص است). یعنی SQL Server در اینجا از یک non-clustered index تعریف شدهی بر روی CustomerID استفاده کردهاست.
اکنون اگر OrderID را تغییر دهیم چه اتفاقی رخ میدهد؟
SELECT [CustomerID], [OrderID] FROM [Sales].[Orders] WHERE [OrderID] > 60000;
در این مثال با دو ورودی مختلف، دو کوئری پلن مختلف تولید شدهاست؛ که مرتبط است با میزان اطلاعاتی که قرار است بازگشت داده شود.
اگر این دو کوئری را با هم اجرا کنیم (در طی یک batch)، به پلن مقایسهای زیر خواهیم رسید که در آن هزینهی Index Scan بیشتر است از clustered index seek:
به همراه آمار CPU و I/O ای به صورت زیر که اولی مرتبط است با index scan و دومی با clustered index seek:
(43595 rows affected) Table 'Orders'. Scan count 1, logical reads 191, physical reads 1, read-ahead reads 182, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. SQL Server Execution Times: CPU time = 31 ms, elapsed time = 754 ms. (13595 rows affected) Table 'Orders'. Scan count 1, logical reads 131, physical reads 0, read-ahead reads 127, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. SQL Server Execution Times: CPU time = 16 ms, elapsed time = 276 ms.
public class Customer { public int Id { set; get; } public string FirstName { get; set; } public string LastName { get; set; } public string Bio { get; set; } public virtual ICollection<Order> Orders { get; set; } [Computed] [NotMapped] public string FullName { get { return FirstName + ' ' + LastName; } } } public class Order { public int Id { set; get; } public string OrderNo { get; set; } public DateTime PurchaseDate { get; set; } public bool ShipToHomeAddress { get; set; } public virtual ICollection<OrderItem> OrderItems { get; set; } [ForeignKey("CustomerId")] public virtual Customer Customer { get; set; } public int CustomerId { get; set; } [Computed] [NotMapped] public decimal Total { get { return OrderItems.Sum(x => x.TotalPrice); } } } public class OrderItem { public int Id { get; set; } public decimal Price { get; set; } public string Name { get; set; } public int Quantity { get; set; } [ForeignKey("OrderId")] public virtual Order Order { get; set; } public int OrderId { get; set; } [Computed] [NotMapped] public decimal TotalPrice { get { return Price * Quantity; } } }
در ادامه میخواهیم اطلاعات حاصل از این کلاسها را با شرایط خاصی به ViewModelهای مشخصی جهت نمایش در برنامه نگاشت کنیم.
نمایش اطلاعات مشتریها
میخواهیم اطلاعات مشتریها را مطابق فرمت کلاس ذیل بازگشت دهیم:
public class CustomerViewModel { public string Bio { get; set; } public string CustomerName { get; set; } }
- اگر Bio نال بود، بجای آن N/A نمایش داده شود.
- CustomerName از خاصیت محاسباتی FullName کلاس مشتری تامین گردد.
برای حل این مساله، نیاز است نگاشت زیر را تهیه کنیم:
this.CreateMap<Customer, CustomerViewModel>() .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(entity => entity.FullName)) .ForMember(dest => dest.Bio, opt => opt.MapFrom(entity => entity.Bio ?? "N/A"));
همچنین در اینجا مطابق نگاشت فوق، خاصیت CustomerName از خاصیت FullName کلاس مشتری دریافت میشود.
کوئری نهایی استفاده کنندهی از این اطلاعات به شکل زیر خواهد بود:
using (var context = new MyContext()) { var viewCustomers = context.Customers .Project() .To<CustomerViewModel>() .Decompile() .ToList(); // don't use // var viewCustomers = Mapper.Map<IEnumerable<Customer>, IEnumerable<CustomerViewModel>>(dbCustomers); foreach (var customer in viewCustomers) { Console.WriteLine("{0} - {1}", customer.CustomerName, customer.Bio); } }
نمایش اطلاعات سفارشهای مشتریها
در ادامه قصد داریم اطلاعات سفارشها را با فرمت ViewModel ذیل نمایش دهیم:
public class OrderViewModel { public string CustomerName { get; set; } public decimal Total { get; set; } public string OrderNumber { get; set; } public IEnumerable<OrderItemsViewModel> OrderItems { get; set; } } public class OrderItemsViewModel { public string Name { get; set; } public int Quantity { get; set; } public decimal Price { get; set; } }
- CustomerName از خاصیت محاسباتی FullName کلاس مشتری تامین گردد.
- خاصیت OrderNumber آن از خاصیت OrderNo تهیه گردد.
به همین جهت کار را با تهیهی نگاشت ذیل ادامه میدهیم:
this.CreateMap<Order, OrderViewModel>() .ForMember(dest => dest.OrderNumber, opt => opt.MapFrom(src => src.OrderNo)) .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
using (var context = new MyContext()) { var viewOrders = context.Orders .Project() .To<OrderViewModel>() .Decompile() .ToList(); // don't use // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderViewModel>>(dbOrders); foreach (var order in viewOrders) { Console.WriteLine("{0} - {1} - {2}", order.OrderNumber, order.CustomerName, order.Total); } }
using (var context = new MyContext()) { var viewOrders = context.Orders .Project() .To<OrderViewModel>() .Decompile() .ToList(); // don't use // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderViewModel>>(dbOrders); foreach (var order in viewOrders) { Console.WriteLine("{0} - {1} - {2}", order.OrderNumber, order.CustomerName, order.Total); foreach (var item in order.OrderItems) { Console.WriteLine("({0}) {1} - {2}", item.Quantity, item.Name, item.Price); } } }
this.CreateMap<Order, OrderViewModel>() .ForMember(dest => dest.OrderNumber, opt => opt.MapFrom(src => src.OrderNo)) .ForMember(dest => dest.OrderItems, opt => opt.Ignore()) .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
نمایش اطلاعات یک سفارش، با فرمتی خاص
تا اینجا نگاشتهای انجام شده بر روی لیستی از اشیاء صورت گرفتند. در ادامه میخواهیم اولین سفارش ثبت شده را با فرمت ذیل نمایش دهیم:
public class OrderDateViewModel { public int PurchaseHour { get; set; } public int PurchaseMinute { get; set; } public string CustomerName { get; set; } }
this.CreateMap<Order, OrderDateViewModel>() .ForMember(dest => dest.PurchaseHour, opt => opt.MapFrom(src => src.PurchaseDate.Hour)) .ForMember(dest => dest.PurchaseMinute, opt => opt.MapFrom(src => src.PurchaseDate.Minute)) .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
پس از این تنظیمات، کوئری نهایی به شکل ذیل خواهد بود:
using (var context = new MyContext()) { var viewOrder = context.Orders .Project() .To<OrderDateViewModel>() .Decompile() .FirstOrDefault(); // don't use // var viewOrder = Mapper.Map<Order, OrderDateViewModel>(dbOrder); if (viewOrder != null) { Console.WriteLine("{0}, {1}:{2}", viewOrder.CustomerName, viewOrder.PurchaseHour, viewOrder.PurchaseMinute); } }
فرمت کردن سفارشی اطلاعات در حین نگاشتها
در مورد فرمت کنندههای سفارشی و تبدیلگرها پیشتر بحث کردهایم. اما اغلب آنها را در حالت خاص LINQ to Entities نمیتوان بکار برد، زیرا قابلیت تبدیل به SQL را ندارند. برای مثال فرض کنید میخواهیم خاصیت ShipToHomeAddress کلاس Order را به خاصیت ShipHome کلاس ذیل نگاشت کنیم:
public class OrderShipViewModel { public string ShipHome { get; set; } public string CustomerName { get; set; } }
this.CreateMap<Order, OrderShipViewModel>() .ForMember(dest => dest.ShipHome, opt => opt.MapFrom(src=>src.ShipToHomeAddress? "Yes": "No")) .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.FullName));
using (var context = new MyContext()) { var viewOrders = context.Orders .Project() .To<OrderShipViewModel>() .Decompile() .ToList(); // don't use // var viewOrders = Mapper.Map<IEnumerable<Order>, IEnumerable<OrderShipViewModel>>(dbOrders); foreach (var order in viewOrders) { Console.WriteLine("{0} - {1}", order.CustomerName, order.ShipHome); } }
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید.
ایجاد مدلها و موجودیتهای برنامهی Minimal Blog
در مثال این سری، هر نویسنده میتواند بلاگ خاص خودش را داشته باشد و در هر بلاگ، تعدادی مقاله منتشر کند. هر مقاله هم میتواند تعدادی تگ یا گروه مرتبط را داشته باشد.
ساختار ابتدایی موجودیت نویسندگان مطالب بلاگ
این موجودیتها در پوشهی جدیدی به نام Model پروژهی MinimalBlog.Domain اضافه خواهند شد:
namespace MinimalBlog.Domain.Model; public class Author { public int Id { get; set; } public string FirstName { get; set; } = default!; public string LastName { get; set; } = default!; public string FullName => FirstName + " " + LastName; public DateTime DateOfBirth { get; set; } public string? Bio { get; set; } }
ساختار ابتدایی موجودیت مقالات بلاگ
namespace MinimalBlog.Domain.Model; public class Article { public Article() { Categories = new List<Category>(); } public int Id { get; set; } public string Title { get; set; } = default!; public string? Subtitle { get; set; } public string Body { get; set; } = default!; public int AuthorId { get; set; } public Author Author { get; set; } = default!; public DateTime DateCreated { get; set; } public DateTime LastUpdated { get; set; } public int NumberOfLikes { get; set; } public int NumberOfShares { get; set; } public ICollection<Category> Categories { get; set; } }
ساختار ابتدایی بلاگهای اختصاصی قابل تعریف
namespace MinimalBlog.Domain.Model; public class Blog { public Blog() { Contributors = new List<Author>(); } public int Id { get; set; } public string Name { get; set; } = default!; public string Description { get; set; } = default!; public DateTime CreatedDate { get; set; } public int AuthorId { get; set; } public Author Owner { get; set; } = default!; public ICollection<Author> Contributors { get; } }
ساختار ابتدایی گروههای مقالات بلاگ
namespace MinimalBlog.Domain.Model; public class Category { public Category() { Articles = new List<Article>(); } public int Id { get; set; } public string Name { get; set; } = default!; public string Description { get; set; } = default!; public ICollection<Article> Articles { get; set; } }
معرفی موجودیتهای تعریف شده به لایهی Dal
لایهی Dal جایی است که DbContext برنامه تعریف میشود و موجودیتها فوق توسط DbSetها در معرض دید EF-Core قرار میگیرند. به همین جهت ابتدا فایل MinimalBlog.Dal.csproj را به صورت زیر تغییر میدهیم:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <ProjectReference Include="..\MinimalBlog.Domain\MinimalBlog.Domain.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.2"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> </Project>
به علاوه تعاریفی مانند ImplicitUsings و Nullable را هم مشاهده میکنید که با توجه به استفادهی از فایل Directory.Build.props غیرضروری و تکراری هستند؛ چون این فایل مخصوص به همراه این تعاریف سراسری نیز هست.
سپس به پروژهی Dal، کلاس جدید MinimalBlogDbContext را به صورت زیر اضافه میکنیم تا مدلهای برنامه را در معرض دید EF-Core قرار دهد:
using Microsoft.EntityFrameworkCore; using MinimalBlog.Domain.Model; namespace MinimalBlog.Dal; public class MinimalBlogDbContext : DbContext { public MinimalBlogDbContext(DbContextOptions options) : base(options) { } public DbSet<Article> Articles { get; set; } = default!; public DbSet<Category> Categories { get; set; } = default!; public DbSet<Author> Authors { get; set; } = default!; public DbSet<Blog> Blogs { get; set; } = default!; }
ایجاد و اجرای Migrations
پس از تعریف MinimalBlogDbContext، اکنون نوبت به ایجاد کلاسهای Migrations و اجرای آنها جهت ایجاد بانک اطلاعاتی متناظر با مدلهای برنامه است. برای این منظور در ابتدا به پروژهی Api مراجعه کرده و فایل appsettings.json را به صورت زیر تکمیل میکنیم:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "Default": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=MinimalBlog;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False" } }
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <ItemGroup> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.2"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> <ItemGroup> <ProjectReference Include="..\MinimalBlog.Domain\MinimalBlog.Domain.csproj" /> <ProjectReference Include="..\MinimalBlog.Dal\MinimalBlog.Dal.csproj" /> </ItemGroup> </Project>
using Microsoft.EntityFrameworkCore; using MinimalBlog.Dal; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var connectionString = builder.Configuration.GetConnectionString("Default"); builder.Services.AddDbContext<MinimalBlogDbContext>(opt => opt.UseSqlServer(connectionString));
پس از این تنظیمات به پوشهی MinimalBlog.Dal وارد شده و فایل _01-add_migrations.cmd را اجرا میکنیم (این فایل به همراه پیوست قسمت اول، ارائه شدهاست). محتوای این فایل به صورت زیر است:
For /f "tokens=2-4 delims=/ " %%a in ('date /t') do (set mydate=%%c_%%a_%%b) For /f "tokens=1-2 delims=/:" %%a in ("%TIME: =0%") do (set mytime=%%a%%b) dotnet tool update --global dotnet-ef --version 6.0.2 dotnet build dotnet ef migrations --startup-project ../MinimalBlog.Api/ add V%mydate%_%mytime% --context MinimalBlogDbContext pause
البته چون کدهای تولید شدهی به صورت خودکار الزاما با Roslyn analyzers برنامه سازگاری ندارند، آنها را توسط فایل مخصوص editorconfig. قرار گرفتهی در پوشهی MinimalBlog.Dal\Migrations، از لیست آنالیزهای برنامه خارج میکنیم. محتوای این فایل ویژه به صورت زیر است:
[*.cs] generated_code = true
پس از ایجاد کلاسهای Migration، اکنون نوبت به اجرای آنها بر روی بانک اطلاعاتی است که در رشتهی اتصالی مشخص کردیم. برای اینکار فایل MinimalBlog.Dal\_02-update_db.cmd را اجرا میکنیم که چنین محتویاتی دارد:
dotnet tool update --global dotnet-ef --version 6.0.2 dotnet build dotnet ef --startup-project ../MinimalBlog.Api/ database update --context MinimalBlogDbContext pause
- شما در کدها و کوئریهای مثلا EF در اصل با یک سری IQueryable کار میکنید. همینجا باید متد الحاقی ToDataSourceResult را اعمال کنید تا نتیجهی نهایی در حداقل بار تعداد رفت و برگشت و با کوئری مناسبی بر اساس پارامترهای دریافتی به صورت خودکار تولید شود. در انتهای کار بجای مثلا ToList بنویسید ToDataSourceResult.
یکی دیگر از معایب کوئریهای select * در SQL server این است که تغییرات حاصل در فیلدهای جداول یک بانک اطلاعاتی را در view های ساخته شده از این نوع کوئریها منعکس نمیکند.
برای مثال جدول tblTreeItems را با سه فیلد id ، parent و title در نظر بگیرید. فرض کنید بر این اساس view زیر را ساختهایم:
CREATE VIEW GetData
as
SELECT * FROM tblTreeItems
باید در نظر داشت که هنگام ایجاد یک view ، تصویری از تمامی فیلدهای مورد استفاده در آن زمان، جهت بالابردن کارآیی کوئری و عدم محاسبه مجدد فیلدها در جداول سیستمی ذخیره میگردد ( * با نام فیلدهای همان زمان ایجاد (نه زمان فعلی)، جایگزین خواهد شد). این تصویر ایستا است و با تغییر فیلدهای یک جدول به روز نخواهد شد.
برای به روز کردن view ها و stored procedures پس از تغییرات ساختاری در جداول، باید مجددا آنها را کامپایل کرد. برای این منظور راههای زیادی وجود دارد، برای مثال drop کردن یک view و ایجاد مجدد آن. یا باز کردن آن view در management studio (حالت alter query) و سپس فشردن دکمه F5 جهت اجرای مجدد کوئری که اینبار بر اساس اطلاعات جدید به روز خواهد شد. یا استفاده از رویههای سیستمی sp_refreshview و sp_recompile که برای کامپایل مجدد view ها و رویههای ذخیره شده بکار میروند.
برای مدیریت سادهتر این موارد ، اسکریپت زیر تمامی view ها و رویههای ذخیره شده یک دیتابیس را به صورت خودکار یافته و آنها را مجددا کامپایل میکند: (جهت مشاهده آن نیاز به ثبت نام دارد و رایگان است)
Refreshing Views and Recompiling Stored Procs
EF Code First #11
استفاده از الگوی Repository اضافی در EF Code first؛ آری یا خیر؟!
اگر در ویژوال استودیو، اشارهگر ماوس را بر روی تعریف DbContext قرار دهیم، راهنمای زیر ظاهر میشود:
A DbContext instance represents a combination of the Unit Of Work and Repository patterns such that
it can be used to query from a database and group together changes that will then be written back to
the store as a unit. DbContext is conceptually similar to ObjectContext.
در اینجا تیم EF صراحتا عنوان میکند که DbContext در EF Code first همان الگوی Unit Of Work را پیاده سازی کرده و در داخل کلاس مشتق شده از آن، DbSetها همان Repositories هستند (فقط نامها تغییر کردهاند؛ اصول یکی است).
به عبارت دیگر با نام بردن صریح از این الگوها، مقصود زیر را دنبال میکنند:
لطفا بر روی این لایه Abstraction ایی که ما تهیه دیدهایم، یک لایه Abstraction دیگر را ایجاد نکنید!
«لایه Abstraction دیگر» یعنی پیاده سازی الگوهای Unit Of Work و Repository جدید، برفراز الگوهای Unit Of Work و Repository توکار موجود!
کار اضافهای که در بسیاری از سایتها مشاهده میشود و ... متاسفانه اکثر آنها هم اشتباه هستند! در ذیل روشهای تشخیص پیاده سازیهای نادرست الگوی Repository را بر خواهیم شمرد:
1) قرار دادن متد Save تغییرات نهایی انجام شده، در داخل کلاس Repository
متد Save باید داخل کلاس Unit of work تعریف شود نه داخل کلاس Repository. دقیقا همان کاری که در EF Code first به درستی انجام شده. متد SaveChanges توسط DbContext ارائه میشود. علت هم این است که در زمان Save ممکن است با چندین Entity و چندین جدول مشغول به کار باشیم. حاصل یک تراکنش، باید نهایتا ذخیره شود نه اینکه هر کدام از اینها، تراکنش خاص خودشان را داشته باشند.
2) نداشتن درکی از الگوی Unit of work
به Unit of work به شکل یک تراکنش نگاه کنید. در داخل آن با انواع و اقسام موجودیتها از کلاسها و جداول مختلف کار شده و حاصل عملیات، به بانک اطلاعاتی اعمال میگردد. پیاده سازیهای اشتباه الگوی Repository، تمام امکانات را در داخل همان کلاس Repository قرار میدهند؛ که اشتباه است. این نوع کلاسها فقط برای کار با یک Entity بهینه شدهاند؛ در حالیکه در دنیای واقعی، اطلاعات ممکن است از دو Entity مختلف دریافت و نتیجه محاسبات مفروضی به Entity سوم اعمال شود. تمام این عملیات یک تراکنش را تشکیل میدهد، نه اینکه هر کدام، تراکنش مجزای خود را داشته باشند.
3) وهله سازی از DbContext به صورت مستقیم داخل کلاس Repository
4) Dispose اشیاء DbContext داخل کلاس Repository
هر بار وهله سازی DbContext مساوی است با باز شدن یک اتصال به بانک اطلاعاتی و همچنین از آنجائیکه راهنمای ذکر شده فوق را در مورد DbContext مطالعه نکردهاند، زمانیکه در یک متد با سه وهله از سه Repository موجودیتهای مختلف کار میکنید، سه تراکنش و سه اتصال مختلف به بانک اطلاعاتی گشوده شده است. این مورد ذاتا اشتباه است و سربار بالایی را نیز به همراه دارد.
ضمن اینکه بستن DbContext در یک Repository، امکان اعمال کوئریهای بعدی LINQ را غیرممکن میکند. به ظاهر یک شیء IQueryable در اختیار داریم که میتوان بر روی آن انواع و اقسام کوئریهای LINQ را تعریف کرد اما ... در اینجا با LINQ to Objects که بر روی اطلاعات موجود در حافظه کار میکند سر و کار نداریم. اتصال به بانک اطلاعاتی با بستن DbContext قطع شده، بنابراین کوئری LINQ بعدی شما کار نخواهد کرد.
همچنین در EF نمیتوان یک Entity را از یک Context به Context دیگری ارسال کرد. در پیاده سازی صحیح الگوی Repository (دقیقا همان چیزی که در EF Code first به صورت توکار وجود دارد)، Context باید بین Repositories که در اینجا فقط نامش DbSet تعریف شده، به اشتراک گذاشته شود. علت هم این است که EF از Context برای ردیابی تغییرات انجام شده بر روی موجودیتها استفاده میکند (همان سطح اول کش که در قسمتهای قبل به آن اشاره شد). اگر به ازای هر Repository یکبار وهله سازی DbContext انجام شود، هر کدام کش جداگانه خاص خود را خواهند داشت.
5) عدم امکان استفاده از تنها یک DbConetext به ازای یک Http Request
هنگامیکه وهله سازی DbContext به داخل یک Repository منتقل میشود و الگوی واحد کار رعایت نمیگردد، امکان به اشتراک گذاری آن بین Repositoryهای تعریف شده وجود نخواهد داشت. این مساله در برنامههای وب سبب کاهش کارآیی میگردد (باز و بسته شدن بیش از حد اتصال به بانک اطلاعاتی در حالیکه میشد تمام این عملیات را با یک DbContext انجام داد).
نمونهای از این پیاده سازی اشتباه را در اینجا میتوانید پیدا کنید. متاسفانه شبیه به همین پیاده سازی، در پروژه MVC Scaffolding نیز بکارگرفته شده است.
چرا تعریف لایه دیگری بر روی لایه Abstraction موجود در EF Code first اشتباه است؟
یکی از دلایلی که حین تعریف الگوی Repository دوم بر روی لایه موجود عنوان میشود، این است:
«به این ترتیب به سادگی میتوان ORM مورد استفاده را تغییر داد» چون پیاده سازی استفاده از ORM، در پشت این لایه مخفی شده و ما هر زمان که بخواهیم به ORM دیگری کوچ کنیم، فقط کافی است این لایه را تغییر دهیم و نه کل برنامه را.
ولی سؤال این است که هرچند این مساله از هزار فرسنگ بالاتر درست است، اما واقعا تابحال دیدهاید که پروژهای را با یک ORM شروع کنند و بعد سوئیچ کنند به ORM دیگری؟!
ضمنا برای اینکه واقعا لایه اضافی پیاده سازی شده انتقال پذیر باشد، شما باید کاملا دست و پای ORM موجود را بریده و تواناییهای در دسترس آن را به سطح نازلی کاهش دهید تا پیاده سازی شما قابل انتقال باشد. برای مثال یک سری از قابلیتهای پیشرفته و بسیار جالب در NH هست که در EF نیست و برعکس. آیا واقعا میتوان به همین سادگی ORM مورد استفاده را تغییر داد؟ فقط در یک حالت این امر میسر است: از قابلیتهای پیشرفته ابزار موجود استفاده نکنیم و از آن در سطحی بسیار ساده و ابتدایی کمک بگیریم تا از قابلیتهای مشترک بین ORMهای موجود استفاده شود.
ضمن اینکه مباحث نگاشت کلاسها به جداول را چکار خواهید کرد؟ EF راه و روش خاص خودش را دارد، NH چندین و چند روش خاص خودش را دارد! اینها به این سادگی قابل انتقال نیستند که شخصی عنوان کند: «هر زمان که علاقمند بودیم، ORM مورد استفاده را میشود عوض کرد!»
دلیل دومی که برای تهیه لایه اضافهتری بر روی DbContext عنوان میکنند این است:
«با استفاده از الگوی Repository نوشتن آزمونهای واحد سادهتر میشود». زمانیکه برنامه بر اساس Interfaceها کار میکند میتوان آنها را بجای اشاره به بانک اطلاعاتی، به نمونهای موجود در حافظه، در زمان آزمون تغییر داد.
این مورد در حالت کلی درست است اما .... نه در مورد بانکهای اطلاعاتی!
زمانیکه در یک آزمون واحد، پیاده سازی جدیدی از الگوی Interface مخزن ما تهیه میشود و اینبار بجای بانک اطلاعاتی با یک سری شیء قرارگرفته در حافظه سروکار داریم، آیا موارد زیر را هم میتوان به سادگی آزمایش کرد؟
ارتباطات بین جداولرا، cascade delete، فیلدهای identity، فیلدهای unique، کلیدهای ترکیبی، نوعهای خاص تعریف شده در بانک اطلاعاتی و مسایلی از این دست.
پاسخ: خیر! تغییر انجام شده، سبب کار برنامه با اطلاعات موجود در حافظه خواهد شد، یعنی LINQ to Objects.
شما در حالت استفاده از LINQ to Objects آزادی عمل فوق العادهای دارید. میتوانید از انواع و اقسام متدها حین تهیه کوئریهای LINQ استفاده کنید که هیچکدام معادلی در بانک اطلاعاتی نداشته و ... به ظاهر آزمون واحد شما پاس میشود؛ اما در عمل بر روی یک بانک اطلاعاتی واقعی کار نخواهد کرد.
البته شاید شخصی عنوان که بله میشود تمام اینها نیازمندیها را در حالت کار با اشیاء درون حافظه هم پیاده سازی کرد ولی ... در نهایت پیاده سازی آن بسیار پیچیده و در حد پیاده سازی یک بانک اطلاعاتی واقعی خواهد شد که واقعا ضرورتی ندارد.
و پاسخ صحیح در اینجا و این مساله خاص این است:
لطفا در حین کار با بانکهای اطلاعاتی مباحث mocking را فراموش کنید. بجای SQL Server، رشته اتصالی و تنظیمات برنامه را به SQL Server CE تغییر داده و آزمایشات خود را انجام دهید. پس از پایان کار هم بانک اطلاعاتی را delete کنید. به این نوع آزمونها اصطلاحا integration tests گفته میشود. لازم است برنامه با یک بانک اطلاعاتی واقعی تست شود و نه یک سری شیء ساده قرار گرفته در حافظه که هیچ قیدی همانند شرایط کار با یک بانک اطلاعاتی واقعی، بر روی آنها اعمال نمیشود.
ضمنا باید درنظر داشت بانکهای اطلاعاتی که تنها در حافظه کار کنند نیز وجود دارند. برای مثال SQLite حالت کار کردن صرفا در حافظه را پشتیبانی میکند. زمانیکه آزمون واحد شروع میشود، یک بانک اطلاعاتی واقعی را در حافظه تشکیل داده و پس از پایان کار هم ... اثری از این بانک اطلاعاتی باقی نخواهد ماند و برای این نوع کارها بسیار سریع است.
نتیجه گیری:
حین استفاده از EF code first، الگوی واحد کار، همان DbContext است و الگوی مخزن، همان DbSetها. ضرورتی به ایجاد یک لایه محافظ اضافی بر روی اینها وجود ندارد.
در اینجا بهتر است یک لایه اضافی را به نام مثلا Service ایجاد کرد و تمام اعمال کار با EF را به آن منتقل نمود. سپس در قسمتهای مختلف برنامه میتوان از متدهای این لایه استفاده کرد. به عبارتی در فایلهای Code behind برنامه شما نباید کدهای EF مشاهده شوند. یا در کنترلرهای MVC نیز به همین ترتیب. اینها مصرف کننده نهایی لایه سرویس ایجاد شده خواهند بود.
همچنین بجای نوشتن آزمونهای واحد، به Integration tests سوئیچ کنید تا بتوان برنامه را در شرایط کار با یک بانک اطلاعاتی واقعی تست کرد.
برای مطالعه بیشتر:
در قسمتهای قبلی (^ و ^) راهکارهایی جهت بالا بردن کارآیی، ارائه شد. در ادامه، به آخرین قسمت این سری اشاره خواهم کرد.
فراخوانی متد شناسایی تغییرات
یادآوری: قبل از هر چیز با توجه به این مقاله دانستن این نکته الزامی است که فراخوانی برخی متدها مانند DbSet.Add سبب فراخوانی DataContext.ChangeTracker.DetectChanges خواهند شد.
فرض کنید قصد افزودن 2000 موجودیت دانش آموز را دارید:
for (int i = 0; i < 2000; i++) { Pupil pupil = GetNewPupil(); db.Pupils.Add(pupil); } db.SaveChanges();
اگر به تصویر بالا دقت کنید بیش از 34 ثانیه (خط 193 قسمت سوم شکل) جهت افزودن 2000 موجودیت به کانتکست سپری شده است. در حالی که درج این 2000 موجودیت کمی بیش از 1 ثانیه (خط 195 قسمت سوم شکل) که 379 میلی ثانیه (قسمت دوم شکل) آن مربوط به اجرای کوئری اختصاص یافته طول کشیده است.
بیشترین زمان صرف شدهی برای درج 2000 موجودیت، در کد برنامه سپری شده است که با بررسی بیشتر در پروفایلر، متوجه زمان بر بودن فراخوانی متد ()DetectChanges که در فضای نام Data.Entity.Core وجود دارد خواهید شد. این متد 2000 بار به تعداد موجودیت هایی که قصد داریم به بانک اطلاعاتی اضافه نماییم، فراخوانی شده است.
همانطور که در شکل بالا مشخص است همان 34 ثانیه جهت ردیابی تغییرات صرف شده است. EF ردیابی تغییرات را بصورت پیش فرض هر زمانی که قصد افزودن یا ویرایش موجودیتی را داشته باشید، انجام خواهد داد و هر چه موجودیتهای بیشتری را بخواهید ویرایش یا اضافه نمایید، این زمان نیز بیشتر خواهد شد. در حقیقت زمان لازم برای الگوریتم ردیابی تغییرات بصورت نمایی با رشد موجودیتها افزوده میشود. به عبارت دیگر اگر این تعداد موجودیتها را به 4000 عدد برسانید، مدت زمان لازم بیش از 2 برابر افزوده خواهد شد.
راه حل اول:
استفاده از متد ()AddRange ارائه شده در EF6 که جهت درج دستهای (Bulk Insert) ارائه شده است:
var list = new List<Pupil>(); for (int i = 0; i < 2000; i++) { Pupil pupil = GetNewPupil(); list.Add(pupil); } db.Pupils.AddRange(list); db.SaveChanges();
راه حل دوم:
در سناریوهای پیچیده، مانند درج دستهای چندین موجودیت، شاید مجبور به خاموش نمودن این قابلیت شوید:
db.Configuration.AutoDetectChangesEnabled = false;
ردیابی تغییرات
هنگامی که موجودیتی را از بانک اطلاعاتی دریافت نمایید، میتوانید آن را ویرایش نمایید و مجددا به بانک اطلاعاتی اعمال نمایید. چون EF اطلاعی از قصد شما برای موجودیت نمیداند، مجبور است تغییرات شما را زیر نظر بگیرد که این زیر نظر گرفتن، هزینه و سربار دارد و این سربار و هزینه برای دادههای زیاد، بیشتر خواهد شد. بنابراین اگر قصد دارید اطلاعاتی فقط خواندنی را از بانک اطلاعاتی دریافت نمایید، بهتر است صراحتا به EF بگویید این موجودیت را تحت ردیابی قرار ندهد.string city = "New York"; List<School> schools = db.Schools .AsNoTracking() .Where(s => s.City == city) .Take(100) .ToList();
استفاده از متد AsNoTracking در کد بالا سبب خواهد شد 100 مدرسه که در شهر نیویورک وجود دارد توسط EF، بدون تحت نظر گرفتن آنها از بانک اطلاعاتی دریافت شوند و سرباری نیز تحمیل نشود.
ویوهای از قبل کامپایل شده
معمولا، هنگامی که از EF برای اولین بار استفاده مینمایید، ویوهایی ایجاد میگردد که برای ایجاد کوئریها مورد استفاده قرار میگیرند. این ویوها در طول حیات برنامه فقط یکبار ایجاد میشوند. ولی همین یکبار هم زمانبر هستند. خوشبختانه راههایی وجود دارد که ایجاد این ویوها را در زمان runtime انجام نداد و آن راه، استفاده از ویوهای از پیش کامپایل شده است. یکی از راههای ایجاد این ویوها استفاده از Entity Framework Power Tools است. بعد از نصب اکستنشن، بر روی فایل کانتکست راست کلیک کرده و سپس گزینهی Generate Views را از منوی Entity Framework انتخاب کنید.
توجه داشته باشید که هر تغییری را بعد از ایجاد این ویوها بر روی کانتکست اعمال نمایید، باید آنها را مجددا تولید کنید. برای آشنایی بیشتر با این ویوها به این لینک مراجعه کنید. هم چنین پکیج نیوگتی بنام EFInteractiveViews نیز برای این منظور تهیه و توزیع شده است.
حذف کوئریهای ابتدایی غیر ضروری
در هنگام شروع به کار با EF، چندین کوئری آغازین بر روی دیتابیس اجرا میشوند. یکی از کوئریهای آغازین جهت تشخیص نسخهی دیتابیس است که همانطور در تصویر زیر مشاهده میکنید، در حدود چند میلی ثانیه میباشد.
با توجه به توضیحات، در صورتیکه اطلاعی از نسخهی دیتابیس دارید، میتوانید این کوئری ابتدایی را تحریف نمایید. برای اینکار میتوان توسط متد ()ResolveManifestToken کلاسی که اینترفیس IManifestTokenResolver را پیاده سازی کرده است، نسخهی دیتابیس را برگردانیم و از یک رفت و برگشت به دیتابیس جلوگیری نماییم.
public class CustomManifestTokenResolver : IManifestTokenResolver { public string ResolveManifestToken(DbConnection connection) { return "2014"; } }
public class CustomDbConfiguration : DbConfiguration { public CustomDbConfiguration() { SetManifestTokenResolver(new CustomManifestTokenResolver()); } }
تخریب کانتکست
تخریب و از بین بردن کانتکست هنگامی که به آن نیاز نداریم بسیار ضروری است. یکی از روشهای اصولی برای Disposing کانتکست، محصور کردن آن بوسیله دستور Using است (البته فرض بر این است که قرار نیست از الگوهای اشاره شده استفاده نماییم). در صورت عدم تخریب صحیح کانتکست باید منتظر آسیب جدی به کارایی Garbage Collector جهت آزاد سازی منابع مورد استفاده کانتکست و هم چنین باز نمودن اتصالات جدید به دیتابیس باشید.
پاسخگویی به چندین درخواست بر روی یک کانکشن
EF از قابلیتی بنام Multiple Result Sets میتواند بهره ببرد که این قابلیت باعث میشود بر روی یک کانکشن ایجاد شده، یک یا چند درخواست از دیتابیس ارسال و یا دریافت شود که سبب کاهش تعداد رفت و برگشت به دیتابیس میشود. کاربرد دیگر این قابلیت، زمانی است که تاخیر زیادی (latency) بین اپلیکیشن و دیتابیس وجود دارد.
برای فعالسازی کافی است مقدار زیر را در کانکشن استرینگ اضافه نمایید:
MultipleActiveResultSets=True;
استفاده از متدهای ناهمگام
در C#5 و EF6 پشتیبانی خوبی از متدهای ناهمگام فراهم شده است و اکثر متدهایی مانند ToListAsync, CountAsync, FirstAsync, SaveChangesAsync و غیره که باعث رفت و برگشت به دیتابیس میشوند امکان پشتیبانی ناهمگام را نیز دارند. البته این قابلیت برای برنامههای یک درخواست در یک زمان شاید مفید نباشد؛ ولی برای برنامههای وبی برعکس. در برنامه وب جهت پشتیبانی از بارگذاری همزمان (concurrent) قابلیت ناهمگام (Async) سبب خواهد شد منابع تا زمان اجرای کوئری به ThreadPool بازگردانده شود و برای سرویس دهی مهیا باشند که باعث افزایش scalability خواهد شد.
بررسی و آزمایش با دادههای واقعی
در اکثر مواقع کارآیی با حجیم شدن دادهها کاهش پیدا میکند (البته در صورت عدم رعایت اصول استاندارد). بنابراین بررسی کارآیی در محیط هایی با حجم دادههای بالا ضروری است. هیچ چیز بدتر از آن نیست که همه چیز در محیط توسعه خوب و بی نقص باشد ولی در محیط عملیاتی به شکست بیانجامد. به همین جهت سعی کنید از ابزارهای تولید داده (^ و ^ و ^) برای ایجاد دادههای آزمایشی استفاده نمایید. سپس کارآیی کوئری خود را مورد بررسی و آزمایش قرار دهید.