۱۳۸۹/۱۰/۱۷

مديريت Join در NHibernate 3.0


مباحث 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<CUSTOMER>()
.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 ذكر شود و نه قبل از آن.