SQL Antipattern #2
بخش دوم : Naive Trees
فرض کنید یک وب سایت حرفهای خبری یا علمی-پژوهشی داریم که قابلیت دریافت نظرات کاربران را در مورد هر مطلب مندرج در سایت یا نظرات داده شده در مورد آن مطالب را دارا میباشد. یعنی هر کاربر علاوه بر توانایی اظهار نظر در مورد یک خبر یا مطلب باید بتواند پاسخ نظرات کاربران دیگر را نیز بدهد. اولین راه حلی که برای طراحی این مطلب در پایگاه داده به ذهن ما میرسد، ایجاد یک زنجیره با استفاده از کد sql زیر میباشد:
CREATE TABLE Comments ( comment_idSERIAL PRIMARY KEY, parent_idBIGINT UNSIGNED, comment TEXT NOT NULL, FOREIGN KEY (parent_id) REFERENCES Comments(comment_id) );
البته همان طور که پیداست بازیابی زنجیرهای از پاسخها در یک پرسوجوی sql کار سختی است. این نخها معمولا عمق نامحدودی دارند و برای به دست آوردن تمام نخهای یک زنجیره باید پرسوجوهای زیادی را اجرا نمود.
ایدهی دیگر میتواند بازیابی تمام نظرها و ذخیرهی آنها در حافظهی برنامه به صورت درخت باشد. ولی این روش برای ذخیره هزاران نظری که ممکن است در سایت ثبت شود و علاوه بر آن مقالات جدیدی که اضافه میشوند، تقریبا غیرعملی است.
1.2 هدف: ذخیره و ایجاد پرسوجو در سلسلهمراتب
وجود سلسله مراتب بین دادهها امری عادی محسوب میگردد. در ساختار دادهای درختی هر ورودی یک گره محسوب میگردد. یک گره ممکن است تعدادی فرزند و یک پدر داشته باشد. گره اول پدر ندارد، ریشه و گره فرزند که فرزند ندارد، برگ و گرهای دیگر، گرههای غیربرگ نامیده میشوند.
مثالهایی که از ساختار درختی دادهها وجود دارد شامل موارد زیر است:
Organizational chart: در این ساختار برای مثال در ارتباط بین کارمندان و مدیر، هر کارمند یک مدیر دارد که نشاندهندهی پدر یک کارمند در ساختار درختی است. هر مدیر هم یک کارمند محسوب میگردد.
Threaded discussion: در این ساختار برای مثال در سیستم نظردهی و پاسخها، ممکن است زنجیرهای از نظرات در پاسخ به نظرات دیگر استفاده گردد. در درخت، فرزندان یک گرهی نظر، پاسخهای آن گره هستند.
در این فصل ما از مثال ساختار دوم برای نشان دادن Antipattern و راه حل آن بهره میگیریم.
2.2 Antipattern : همیشه مبتنی بر یکی از والدین
راه حل ابتدایی و ناکارآمد
اضافه نمودن ستون parent_id . این ستون، به ستون نظر در همان جدول ارجاع داده میشود و شما میتوانید برای اجرای این رابطه از قید کلید خارجی استفاده نمایید. پرسوجویی که برای ساخت مثالی که ما در این بحث از آن استفاده میکنیم در ادامه آمده است:
CREATE TABLE Comments ( comment_idSERIAL PRIMARY KEY, parent_idBIGINT UNSIGNED, bug_idBIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_dateDATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (parent_id)REFERENCES Comments(comment_id), FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY(author) REFERENCES Accounts(account_id) );
لیست مجاورت :
لیست مجاورت خود میتواند به عنوان یک antipattern در نظر گرفته شود. البته این مطلب از آنجایی نشأت میگیرد که این روش توسط بسیاری از توسعهدهندگان مورد استفاده قرار میگیرد ولی نتوانسته است به عنوان راه حل برای معمولترین وظیفهی خود، یعنی ایجاد پرسوجو بر روی کلیه فرزندان، باشد.
• با استفاده از پرسوجوی زیر میتوان فرزند بلافاصلهی یک "نظر" را برگرداند:
SELECT c1.*, c2.* FROM Comments c1 LEFT OUTER JOIN Comments c2 ON c2.parent_id = c1.comment_id;
ضعف پرسوجوی فوق این است که فقط میتواند دو سطح از درخت را برای شما برگرداند. در حالیکه خاصیت درخت این است که شما را قادر میسازد بدون هیچ گونه محدودیتی فرزندان و نوههای متعدد (سطوح بیشمار) برای درخت خود تعریف کنید. بنابراین به ازای هر سطح اضافه باید یک join به پرسجوی خود اضافه نمایید. برای مثال اگر پرسوجوی زیر میتواند درختی با چهار سطح برای شما برگرداند ولی نه بیش از آن:
SELECT c1.*, c2.*, c3.*, c4.* FROM Comments c1 -- 1st level LEFT OUTER JOIN Comments c2 ON c2.parent_id = c1.comment_id -- 2nd level LEFT OUTER JOIN Comments c3 ON c3.parent_id = c2.comment_id -- 3rd level LEFT OUTER JOIN Comments c4 ON c4.parent_id = c3.comment_id; -- 4th level
این پرسوجو به این دلیل که با اضافه شدن ستونهای دیگر، نوهها را سطوح عمیقتری برمیگرداند، پرسوجوی مناسبی نیست. در واقع استفاده از توابع تجمیعی ، مانند COUNT() مشکل میشود.
راه دیگر برای به دست آوردن ساختار یک زیردرخت از لیست مجاورت برای یک برنامه، این است که سطرهای مورد نظر خود را از مجموعه بازیابی نموده و سلسهمراتب مورد نظر را در حافظه بازیابی نماییم و از آن به عنوان درخت استفاده نماییم:
SELECT * FROM Comments WHERE bug_id = 1234;
INSERT INTO Comments (bug_id, parent_id, author, comment) VALUES (1234, 7, 'Kukla' , 'Thanks!' );
UPDATE Comments SET parent_id = 3 WHERE comment_id = 6;
SELECT parent_id FROM Comments WHERE comment_id = 6; -- returns 4 UPDATE Comments SET parent_id = 4 WHERE parent_id = 6; DELETE FROM Comments WHERE comment_id = 6;
- چه تعداد سطح برای پشتیبانی در درخت نیاز خواهیم داشت؟
- من همیشه از کار با کدی که ساختار دادهی درختی را مدیریت میکند، میترسم
- من باید اسکریپتی را به طور دورهای اجرا نمایم تا سطرهای یتیم موجود در درخت را حذف کند.
WITH CommentTree (comment_id, bug_id, parent_id, author, comment, depth) AS ( SELECT *, 0 AS depth FROM Comments WHERE parent_id IS NULL UNION ALL SELECT c.*, ct.depth+1 AS depth FROM CommentTreect JOIN Comments c ON (ct.comment_id = c.parent_id) ) SELECT * FROM CommentTree WHERE bug_id = 1234;
SELECT * FROM Comments START WITH comment_id = 9876 CONNECT BY PRIOR parent_id = comment_id;
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, path VARCHAR(1000), bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id)
SELECT * FROM Comments AS c WHERE '1/4/6/7/' LIKE c.path || '%' ;
SELECT * FROM Comments AS c WHERE c.path LIKE '1/4/' || '%' ;
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, nsleft INTEGER NOT NULL, nsright INTEGER NOT NULL, bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id) );
شمارهی سمت چپ یک گره از تمام شمارههای سمت چپ فرزندان آن گره کوچکتر و شمارهی سمت راست آن گره از تمام شمارههای سمت راست آن گره بزرگتر است. این شمارهها هیچ ارتباطی به comment_id مربوط به آن گره ندارند.
یک راه حل ساده برای تخصیص این شمارهها به گرهها این است که از سمت چپ یک گره آغاز میکنیم و اولین شماره را اختصاص میدهیم و به همین به گرهای سمت چپ فرزندان میآییم و شمارهها را به صورت افزایشی به سمت چپ آنها نیز اختصاص میدهیم. سپس در ادامه به سمت راست آخرین نود رفته و از آن جا به سمت بالا میآییم و به همین ترتیب به صورت بازگشتی تخصیص شمارهها را ادامه میدهیم.
با اختصتص شمارهها به هر گره، میتوان از آنها برای یافتن نیاکان و فرزندان آن گره بهره جست. برای مثال برای بازیابی گرهی 4 و فرزندان (نوههای) آن باید دنبال گرههایی باشیم که شمارههای آن گرهها بین nsleft و nsright گرهی شماره4 باشد:
SELECT c2.* FROM Comments AS c1 JOIN Comments as c2 ON c2.nsleft BETWEEN c1.nsleft AND c1.nsright WHERE c1.comment_id = 4;
SELECT c2.* FROM Comments AS c1 JOIN Comment AS c2 ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright WHERE c1.comment_id = 6;
SELECT parent.* FROM Comment AS c JOIN Comment AS parent ON c.nsleft BETWEEN parent.nsleft AND parent.nsright LEFT OUTER JOIN Comment AS in_between ON c.nsleft BETWEEN in_between.nsleft AND in_between.nsright AND in_between.nsleft BETWEEN parent.nsleft AND parent.nsright WHERE c.comment_id = 6 AND in_between.comment_id IS NULL;
-- make space for NS values 8 and 9 UPDATE Comment SET nsleft = CASE WHEN nsleft >= 8 THEN nsleft+2 ELSE nsleft END, nsright = nsright+2 WHERE nsright >= 7; -- create new child of comment #5, occupying NS values 8 and 9 INSERT INTO Comment (nsleft, nsright, author, comment) VALUES (8, 9, 'Fran' , 'Me too!' );
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY, bug_id BIGINT UNSIGNED NOT NULL, author BIGINT UNSIGNED NOT NULL, comment_date DATETIME NOT NULL, comment TEXT NOT NULL, FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id), FOREIGN KEY (author) REFERENCES Accounts(account_id) ); CREATE TABLE TreePaths ( ancestor BIGINT UNSIGNED NOT NULL, descendant BIGINT UNSIGNED NOT NULL, PRIMARY KEY(ancestor, descendant), FOREIGN KEY (ancestor) REFERENCES Comments(comment_id), FOREIGN KEY (descendant) REFERENCES Comments(comment_id) );
به جای استفاده از جدول Comments برای ذخیرهی اطلاعات مربوط به یک درخت از جدول TreePath استفاده میکنیم. به ازای هر یک جفت گره در این درخت یک سطر در جدول ذخیره میشود که ارتباط پدر فرزندی را نمایش میدهد و الزاما نباید این دو پدر فرزند بلافصل باشد. همچنین یک سطر هم به ازای ارتباط هر گره با خودش به جدول اضافه میگردد.
پرسوجوهای بازیابی نیاکان و فرزندان (گرهها) از طریق جدول TreePaths سادهتر از روش مجموعههای تودرتو است. مثلا برای بازیابی فرزندان (نوههای) گرهی شمارهی 4، سطرهایی که نیاکان آنها 4 است را به دست میآوریم:
SELECT c.* FROM Comments AS c JOIN TreePaths AS t ON c.comment_id = t.descendant WHERE t.ancestor = 4;
SELECT c.* FROM Comments AS c JOIN TreePaths AS t ON c.comment_id = t.ancestor WHERE t.descendant = 6;
INSERT INTO TreePaths (ancestor, descendant) SELECT t.ancestor, 8 FROM TreePaths AS t WHERE t.descendant = 5 UNION ALL SELECT 8, 8;
DELETE FROM TreePaths WHERE descendant = 7;
DELETE FROM TreePaths WHERE descendant IN (SELECT descendant FROM TreePaths WHERE ancestor = 4);
DELETE FROM TreePaths WHERE descendant IN (SELECT descendant FROM TreePaths WHERE ancestor = 6) AND ancestor IN (SELECT ancestor FROM TreePaths WHERE descendant = 6 AND ancestor != descendant);
INSERT INTO TreePaths (ancestor, descendant) SELECT supertree.ancestor, subtree.descendant FROM TreePaths AS supertree CROSS JOIN TreePaths AS subtree WHERE supertree.descendant = 3 AND subtree.ancestor = 6;
میتوان عملکرد Closure Table را برای ایجاد پرسوجو روی فرزندان و پدر بلافصل را آسانتر نیز نمود. اگر فیلد path_length را به جدول TreePaths اضافه نماییم این کار انجام میشود. path_length گرهای که به خودش ارجاع میشود، صفر است. path_length فرزند بلافصل هر گره 1، path_length نوهی آن 2 میباشد و به همین ترتیب path_lengthها را در هر سطر مقداردهی میکنیم. اکنون یا فتن فرزند گرهی شمارهی 4 آسانتر است:
SELECT * FROM TreePaths WHERE ancestor = 4 AND path_length = 1;
ابتدا بسته زیر را از طریق nuget نصب نمایید:
dotnet add package MongoDB.Driver
سپس مدلهای زیر را ایجاد نمایید:
public class BaseModel { public BaseModel() { CreationDate=DateTime.Now; } public string Id { get; set; } public DateTime CreationDate { get; set; } public bool IsRemoved { get; set; } public DateTime? ModificationDate { get; set; } }
این مدل شامل یک کلاس پایه برای id,CreationDate,ModificationDate,IsRemoved میباشد که بسیار شبیه مدلهایی است که عموما در EntityFramework تعریف میکنیم.
برای اینکه فیلد Id به صورت objectId ایجاد شود ولی به صورت رشتهای استفاده شود ابتدا ویژگی BsonId را در بالای آن تعریف کرده تا به عنوان شناسه یکتا سند شناخته شود و سپس با استفاده از ویژگی BsonRepresentation اعلام میکنیم که کار تبدیل به رشته و بلعکس آن به صورت خودکار در پشت صحنه صورت بگیرد:
public class BaseModel { [BsonId] [BsonRepresentation((BsonType.ObjectId))] public string Id { get; set; } }
البته این حالت برای زمانی مناسب است که ما
در استفاده از ویژگیها محدودیتی نداشته باشیم؛ ولی در بسیاری از نرم افزارها که از
معماریهای چند لایه مانند لایه پیازی استفاده میشود استفاده از این خصوصیتها یعنی اعمال کارکرد کتابخانه بالاتر بر روی لایههای زیرین که هسته نرم افزار
شناخته میشوند که صحیح نبوده و باید توسط لایههای بالاتر این تغییرات اعمال شوند که
میتواند از طریق کلاس این کار را انجام دهید. به ازای هر مدل که نیاز به تغییرات
دارد، یک حالت جدید تعریف شده و در ابتدای برنامه در فایل Program.cs یا قبل از دات نت 6 در Startup.cs صدا زده میشوند.
BsonClassMap.RegisterClassMap<BaseModel>(map => { map.SetIdMember(map.GetMemberMap(x=>x.Id)); map.GetMemberMap(x => x.Id) .SetSerializer(new StringSerializer(BsonType.ObjectId)); });
یک نکته بسیار مهم: کلاس و متد BsonClassMap . RegisterClassMap قادر به اعمال تغییرات بر روی خصوصیتهای کلاس والد نیستند و آن خصوصیات حتما باید در آن کلاسی که آن را کانفیگ میکنید، تعریف شده باشند؛ یعنی چنین چیزی که در کد زیر میبینید در زمان اجرا با یک خطا مواجه خواهد شد:
public class Employee : BaseModel { public string FirstName { get; set; } public string LastName { get; set; } } //================= BsonClassMap.RegisterClassMap<Employee >(map => { map.SetIdMember(map.GetMemberMap(x=>x.Id)); map.GetMemberMap(x => x.Id) .SetSerializer(new StringSerializer(BsonType.ObjectId)); });
روش استفاده از مونگو در asp.net core به صورت زیر بسیار متداول میباشد که در قسمتهای پیشین هم در این مورد نوشته بودیم:
MongoDbContext
public interface IMongoDbContext { IMongoCollection<TEntity> GetCollection<TEntity>(); } public class MongoDbContext : IMongoDbContext { private readonly IMongoClient _client; private readonly IMongoDatabase _database; public MongoDbContext(string databaseName,string connectionString) { var settings = MongoClientSettings.FromUrl(new MongoUrl(connectionString)); _client = new MongoClient(settings); _database = _client.GetDatabase(databaseName); } public IMongoCollection<TEntity> GetCollection<TEntity>() { return _database.GetCollection<TEntity>(typeof(TEntity).Name.ToLower() + "s"); } }
سپس از طریق کد زیر IMongoDbContext را به سیستم تزریق وابستگیها معرفی میکنیم. الگوی استفاده شدهی در اینجا بر خلاف نسخههای sql که عموما به صورت AddScoped تعریف میشدند، در اینجا به صورت AddSingleton تعریف کردیم و نحوه پیاده سازی آن را نیز در طرف سمت راست به صورت صریح اعلام کردیم:
public static class MongoDbContextService { public static void AddMongoDbContext(this IServiceCollection services,string databaseName,string connectionString) { services.AddSingleton<IMongoDbContext>(serviceProvider => new MongoDbContext(databaseName, connectionString)); } } //=============== Program.cs builder.Services.AddMongoDbContext("bookstore", "mongodb://localhost:27017");
پیاده سازی SoftDelete در مونگو
در مونگو چیزی تحت عنوان Global Query Filter نداریم که تمام کوئری هایی که به سمت دیتابیس ارسال میشوند، توسط کانتکس اطلاح شوند؛ بدین جهت برای پیاده سازی این خصوصیت میتوان اینترفیسی با نام <IRepository<T را به شکل زیر طراحی نماییم:
public interface IRepository<T> where T : BaseModel { IMongoCollection<T> GetCollection(); IMongoQueryable<T> GetFilteredCollection(); } public class Repository<T> : IRepository<T> where T:BaseModel { private IMongoDbContext _mongoDbContext; public Repository(IMongoDbContext mongoDbContext) { _mongoDbContext = mongoDbContext; } public IMongoCollection<T> GetCollection() { return _mongoDbContext.GetCollection<T>(); } public IMongoQueryable<T> GetFilteredCollection() { var query= _mongoDbContext.GetCollection<T>().AsQueryable(); //================= Global Query Filters ==================== //Filter 1 query=query.Where(x => x.RemovedAt.HasValue == false); //============================================================== return query; } }
این کلاس یا اینترفیس شامل دو متد هستند که کلاس جنریک آنها باید از BaseModel ارث بری کرده باشد و اولین متد، تنها یک کالکشن بدون هیچگونه فیلتری است که میتواند نقش متد IgnoreQueryFilters را بازی کند و دیگری GetFilteredCollection است که در این متد ابتدا کالکشنی دریافت شده و سپس آن را به حالت کوئری تغییر داده و فیلترهای مورد نظر، مانند حذف منطقی را پیاده سازی میکنیم:
public interface IRepository<T> where T : BaseModel { IMongoCollection<T> GetCollection(); IMongoQueryable<T> GetFilteredCollection(); } public class Repository<T> : IRepository<T> where T:BaseModel { private IMongoDbContext _mongoDbContext; public Repository(IMongoDbContext mongoDbContext) { _mongoDbContext = mongoDbContext; } public IMongoCollection<T> GetCollection() { return _mongoDbContext.GetCollection<T>(); } public IMongoQueryable<T> GetFilteredCollection() { var query= _mongoDbContext.GetCollection<T>().AsQueryable(); //================= Global Query Filters ==================== //Filter 1 query=query.Where(x => x.RemovedAt.HasValue == false); //============================================================== return query; } }
اصلاح تاریخ ویرایش در مدل
در EF به لطف dbset و همچنین ChangeTracking امکان شناسایی حالتها وجود دارد و میتوانید در متدی مانند saveChanges مقدار تاریخ ویرایش را تنظیم نمود. برای مدلهای منگو چنین چیزی وجود ندارد و به همین دلیل چند روش زیر پیشنهاد میگردد:
یک. استفاده از اینترفیس INotifyPropertyChanged یا جهت حذف کدهای تکراری نیز از الگوی AOP بهره بگیرید.
دو. استفاده از یک <Repository<T همانند بالا که شامل متدهای داخلی Update و Delete هستند که در آنجا میتوانید این مقادیر را به صورت مستقیم تغییر دهید.
- برخلاف بسیاری از طراحیهای موجود، بر فراز هر موجودیت اصلی (منظور AggregateRoot) باید یک DTO که از این پس با عنوان Model از آنها یاد خواهیم کرد، تعریف شود.
- هیچ تراکنشی برای موجودیتهای فرعی یا همان Detailها نخواهیم داشت. این موجودیتها در تراکنش موجودیت اصلی مرتبط به آن مدیریت خواهند شد.
- هر Commandای که قرار است مرتبط با یک موجودیت اصلی در سیستم انجام پذیرد، باید از منطق تجاری آن موجودیت عبور کند و نباید با دور زدن منطق تجاری، از طرق مختلف تغییراتی بر آن موجودیت اعمال شود. (موضوع مهمی که در ادامه مطلب جاری تشریح خواهد شد)
- ویوهای مختلفی از یک موجودیت میتوان انتظار داشت که ویو پیشفرض آن در CrudService تدارک دیده شده است. برای سایر موارد نیاز است در سرویس مرتبط، متدهای Read مختلفی را پیادهسازی کنید.
- با اعمال اصل CQS، متدهای ثبت و ویرایش در کلاس سرویس پایه CRUD، بعد از انجام عملیات مربوطه، Id و RowVersion مدل ورودی و هچنین Id و TrackingState موجودیتهای فرعی وابسته، مقداردهی خواهند شد و نیاز به انجام یک Query دیگر و بازگشت آن به عنوان خروجی متدها نبوده است. به همین دلیل خروجی این متدها صرفا Result ای میباشد که نشان از امکان Failure بودن انجام آنها میباشد که با اصل مذکور در تضاد نمیباشد.
- ورودی متدهای Read شما که در اکثر موارد نیاز به مهیا کردن خروجی صفحهبندی شده دارند، باید از نوع PagedQueryModel و یا اگر همچنین نیاز به جستجوی پویا براساس فیلدهایی موجود در ReadModel مرتبط دارید، باید از نوع FilteredPagedQueryModel باشد. متدهای الحاقی برای اعمال خودکار این صفحهبندی و جستجوی پویا در نظر گرفته شده است. همچنین خروجی آنها در اکثر موارد از نوع IPagedQueryResult خواهد بود. اگر نیاز است تا جستجوی خاصی داشته باشید که خصوصیتی متناظر با آن فیلد در مدل Read وجود ندارد، لازم است تا از این QueryModelهای مطرح شده، ارثبری کرده و خصوصیت اضافی مدنظر خود را تعریف کنید. بدیهی است که اعمال جستجوی این موارد خاص به عهده توسعه دهنده میباشد.
- عملیات ثبت، ویرایش و حذف، برای کار بر روی لیستی از وهلههای Model، طراحی شدهاند. این موضوع در بسیاری از دومینها قابلیت مورد توجهی میباشد.
- رخداد متناظر با عملیات CUD مرتبط با هر موجودیت اصلی، به عنوان یکسری نقاط قابل گسترش (Extensibility Point) در اختیار سایر بخشهای سیستم میباشد. این رخدادها درون تراکنش جاری Raise خواهند شد؛ از این جهت امکان اعمال یکسری Rule جدید از سمت سایر موءلفههای سیستم موجود میباشد.
- برخلاف بسیاری از طراحیهای موجود، قصد ایجاد لایه انتزاعی برفراز EF Core به منظور رسیدن به Persistence Ignorance را ندارم. بنابراین امروز بسته DNTFrameworkCore.EntityFramework آن آماده میباشد. اگر توسعه دهندهای قصد یکپارچه کردن این زیرساخت را با سایر ORMها یا Micro ORMها داشته باشد، میتواند Pull Request خود را ارسال کند.
- خبر خوب اینکه هیچ وابستگی به AutoMapper به منظور نگاشت مابین موجودیتها و مدلهای متناظر آنها، در این زیرساخت وجود ندارد. با پیاده سازی متدهای MapToModel و MapToEntity میتوانید از کتابخانه Mapper مورد نظر خودتان استفاده کنید؛ یا به صورت دستی این کار را انجام دهید. بعد از چند سال استفاده از AutoMapper، این روزها خیلی اعتقادی به استفاده از آن ندارم.
- هیچ وابستگی به FluentValidation به منظور اعتبارسنجی ورودی متدها یا پیادهسازی قواعد تجاری، در این زیرساخت وجود ندارد. شما امکان استفاده از Attributeهای اعتبارسنجی توکار، پیاده سازی IValidatableObject توسط مدل یا در موارد خاص به منظور پیاده سازی قواعد تجاری پیچیده، پیاده سازی IModelValidator را دارید. با این حال برای یکپارچگی با این کتابخانه محبوب، میتوانید بسته نیوگت DNTFrameworkCore.FluentValidation را نصب کرده و استفاده کنید.
- با اعمال الگوی Template Method در پیاده سازی سرویس CRUD پایه، از طریق تعدادی متد با پیشوندهای Before و After متناظر با عملیات CUD میتوانید در فرآیند انجام آنها نیز دخالت داشته باشید؛ به عنوان مثال: BeforeEditAsync یا AfterCreateAsync
- باتوجه به اینکه در فرآیند انجام متدهای CUD، یکسری Event هم Raise خواهند شد و همچنین در خیلی از موراد شاید نیاز به فراخوانی SaveChange مرتبط با UnitOfWork جاری باشد، لذا مطمئنترین راه حل برای این قضیه و حفظ ثبات سیستم، همان استفاده از تراکنش محیطی میباشد. از این جهت متدهای مذکور با TransactionAttribute نیز تزئین شدهاند که برای فعال سازی این مکانیزم نیاز است تا TransactionInterceptor مربوطه را به سیستم معرفی کنید.
- ValidationInterceptor موجود در زیرساخت، در صورتیکه خروجی متد از نوع Result باشد، خطاهای ممکن را در قالب یک شی Result بازگشت خواهد داد؛ در غیر این صورت یک استثنای ValidationException پرتاب میشود که این مورد هم توسط GlobalExceptionFilter مدیریت خواهند شد و در قالب یک BadRequest به کلاینت ارسال خواهد شد.
- در سناریوهای Master-Detail، قرارداد این است که Detailها به همراه Master متناظر واکشی خواهند شد و در زمان ثبت و یا ویرایش هم همه آنها به همراه Master متناظر خود به سرور ارسال خواهند شد.
PM> Install-Package DNTFrameworkCore -Version 1.0.0 PM> Install-Package DNTFrameworkCore.EntityFramework -Version 1.0.0
[LocalizationResource(Name = "SharedResource", Location = "DNTFrameworkCore.TestAPI")] public class BlogModel : MasterModel<int>, IValidatableObject { public string Title { get; set; } [MaxLength(50, ErrorMessage = "Maximum length is 50")] public string Url { get; set; } public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (Title == "BlogTitle") { yield return new ValidationResult("IValidatableObject Message", new[] {nameof(Title)}); } } }
public class BlogValidator : FluentModelValidator<BlogModel> { public BlogValidator(IMessageLocalizer localizer) { RuleFor(b => b.Title).NotEmpty() .WithMessage(localizer["Blog.Fields.Title.Required"]); } }
public class BlogValidator : ModelValidator<BlogkModel> { public override IEnumerable<ModelValidationResult> Validate(BlogModel model) { yield return new ModelValidationResult(nameof(BlogkModel.Title), "Validation from IModelValidator"); } }
public interface IBlogService : ICrudService<int, BlogModel> { }
public class BlogService : CrudService<Blog, int, BlogModel>, IBlogService { public BlogService(CrudServiceDependency dependency) : base(dependency) { } protected override IQueryable<BlogModel> BuildReadQuery(FilteredPagedQueryModel model) { return EntitySet.AsNoTracking().Select(b => new BlogModel {Id = b.Id, RowVersion = b.RowVersion, Url = b.Url, Title = b.Title}); } protected override Blog MapToEntity(BlogModel model) { return new Blog { Id = model.Id, RowVersion = model.RowVersion, Url = model.Url, Title = model.Title, NormalizedTitle = model.Title.ToUpperInvariant() //todo: normalize based on your requirement }; } protected override BlogModel MapToModel(Blog entity) { return new BlogModel { Id = entity.Id, RowVersion = entity.RowVersion, Url = entity.Url, Title = entity.Title }; } }
[LocalizationResource(Name = "SharedResource", Location = "DNTFrameworkCore.TestAPI")] public class TaskModel : MasterModel<int>, IValidatableObject { public string Title { get; set; } [MaxLength(50, ErrorMessage = "Validation from DataAnnotations")] public string Number { get; set; } public string Description { get; set; } public TaskState State { get; set; } = TaskState.Todo; public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (Title == "IValidatableObject") { yield return new ValidationResult("Validation from IValidatableObject"); } } }
public class TaskReadModel : MasterModel<int> { public string Title { get; set; } public string Number { get; set; } public TaskState State { get; set; } = TaskState.Todo; public DateTimeOffset CreationDateTime { get; set; } public string CreatorUserDisplayName { get; set; } }
public class TaskValidator : ModelValidator<TaskModel> { public override IEnumerable<ModelValidationResult> Validate(TaskModel model) { if (!Enum.IsDefined(typeof(TaskState), model.State)) { yield return new ModelValidationResult(nameof(TaskModel.State), "Validation from IModelValidator"); } } }
public interface ITaskService : ICrudService<int, TaskReadModel, TaskModel, TaskFilteredPagedQueryModel> { }
public class TaskFilteredPagedQueryModel : FilteredPagedQueryModel { public TaskState? State { get; set; } }
پیاده سازی واسط ITaskService با استفاده از AutoMapper
public class TaskService : CrudService<Task, int, TaskReadModel, TaskModel, TaskFilteredPagedQueryModel>, ITaskService { private readonly IMapper _mapper; public TaskService(CrudServiceDependency dependency, IMapper mapper) : base(dependency) { _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); } protected override IQueryable<TaskReadModel> BuildReadQuery(TaskFilteredPagedQueryModel model) { return EntitySet.AsNoTracking() .WhereIf(model.State.HasValue, t => t.State == model.State) .ProjectTo<TaskReadModel>(_mapper.ConfigurationProvider); } protected override Task MapToEntity(TaskModel model) { return _mapper.Map<Task>(model); } protected override TaskModel MapToModel(Task entity) { return _mapper.Map<TaskModel>(entity); } }
به عنوان مثال در کلاس بالا برای نگاشت مابین مدل و موجودیت، از واسط IMapper کتابخانه AutoMapper استفاده شدهاست و همچنین عملیات جستجوی سفارشی در همان متد BuildReadQuery برای تولید کوئری متد Read پیشفرض، قابل ملاحظه میباشد.
مثال سوم: پیادهسازی سرویس یک موجودیت اصلی به همراه تعدادی موجودیت فرعی وابسته (سناریوهای Master-Detail)
گام اول: طراحی Modelهای متناظر
public class UserModel : MasterModel { public string UserName { get; set; } public string DisplayName { get; set; } public string Password { get; set; } public bool IsActive { get; set; } public ICollection<UserRoleModel> Roles { get; set; } = new HashSet<UserRoleModel>(); public ICollection<PermissionModel> Permissions { get; set; } = new HashSet<PermissionModel>(); public ICollection<PermissionModel> IgnoredPermissions { get; set; } = new HashSet<PermissionModel>(); }
مدل بالا متناظر است با موجودیت کاربر سیستم، که به یکسری گروه کاربری متصل میباشد و همچنین دارای یکسری دسترسی مستقیم بوده و یا یکسری دسترسی از او گرفته شدهاست. مدلهای Detail نیز از قرارداد خاصی پیروی خواهند کرد که در ادامه مشاهده خواهیم کرد.
public class PermissionModel : DetailModel<int> { public string Name { get; set; } }
به عنوان مثال PermissionModel بالا از DetailModel جنریکای ارثبری کرده است که دارای Id و TrackingState نیز میباشد.
public class UserRoleModel : DetailModel<int> { public long RoleId { get; set; } }
شاید در نگاه اول برای گروههای کاربری یک کاربر کافی بود تا یک لیست ساده از long را از کلاینت دریافت کنیم. در این صورت نیاز است تا برای تمام موجودیتهای سیستم که چنین شرایط مشابهی را دارند، عملیات ثبت، ویرایش و حذف متناظر با تک تک Detailها را دستی مدیریت کنید. روش فعلی خصوصا برای سناریوهای منفصل به مانند پروژههای تحت وب، پیشنهاد میشود.
گام دوم: پیاده سازی اعتبارسنج مستقل
public class UserValidator : FluentModelValidator<UserModel> { private readonly IUnitOfWork _uow; public UserValidator(IUnitOfWork uow, IMessageLocalizer localizer) { _uow = uow ?? throw new ArgumentNullException(nameof(uow)); RuleFor(m => m.DisplayName).NotEmpty() .WithMessage(localizer["User.Fields.DisplayName.Required"]) .MinimumLength(3) .WithMessage(localizer["User.Fields.DisplayName.MinimumLength"]) .MaximumLength(User.MaxDisplayNameLength) .WithMessage(localizer["User.Fields.DisplayName.MaximumLength"]) .Matches(@"^[\u0600-\u06FF,\u0590-\u05FF,0-9\s]*$") .WithMessage(localizer["User.Fields.DisplayName.RegularExpression"]) .DependentRules(() => { RuleFor(m => m).Must(model => !CheckDuplicateDisplayName(model.DisplayName, model.Id)) .WithMessage(localizer["User.Fields.DisplayName.Unique"]) .OverridePropertyName(nameof(UserModel.DisplayName)); }); RuleFor(m => m.UserName).NotEmpty() .WithMessage(localizer["User.Fields.UserName.Required"]) .MinimumLength(3) .WithMessage(localizer["User.Fields.UserName.MinimumLength"]) .MaximumLength(User.MaxUserNameLength) .WithMessage(localizer["User.Fields.UserName.MaximumLength"]) .Matches("^[a-zA-Z0-9_]*$") .WithMessage(localizer["User.Fields.UserName.RegularExpression"]) .DependentRules(() => { RuleFor(m => m).Must(model => !CheckDuplicateUserName(model.UserName, model.Id)) .WithMessage(localizer["User.Fields.UserName.Unique"]) .OverridePropertyName(nameof(UserModel.UserName)); }); RuleFor(m => m.Password).NotEmpty() .WithMessage(localizer["User.Fields.Password.Required"]) .When(m => m.IsNew, ApplyConditionTo.CurrentValidator) .MinimumLength(6) .WithMessage(localizer["User.Fields.Password.MinimumLength"]) .MaximumLength(User.MaxPasswordLength) .WithMessage(localizer["User.Fields.Password.MaximumLength"]); RuleFor(m => m).Must(model => !CheckDuplicateRoles(model)) .WithMessage(localizer["User.Fields.Roles.Unique"]) .When(m => m.Roles != null && m.Roles.Any(r => !r.IsDeleted)); } private bool CheckDuplicateUserName(string userName, long id) { var normalizedUserName = userName.ToUpperInvariant(); return _uow.Set<User>().Any(u => u.NormalizedUserName == normalizedUserName && u.Id != id); } private bool CheckDuplicateDisplayName(string displayName, long id) { var normalizedDisplayName = displayName.NormalizePersianTitle(); return _uow.Set<User>().Any(u => u.NormalizedDisplayName == normalizedDisplayName && u.Id != id); } private bool CheckDuplicateRoles(UserModel model) { var roles = model.Roles.Where(a => !a.IsDeleted); return roles.GroupBy(r => r.RoleId).Any(r => r.Count() > 1); } }
به عنوان مثال در این اعتبارسنج بالا، قواعدی از جمله بررسی تکراری بودن نامکاربری و از این دست اعتبارسنجیها نیز انجام شده است. نکته حائز اهمیت آن متد CheckDuplicateRoles میباشد:
private bool CheckDuplicateRoles(UserModel model) { var roles = model.Roles.Where(a => !a.IsDeleted); return roles.GroupBy(r => r.RoleId).Any(r => r.Count() > 1); }
با توجه به «نکته مهم» ابتدای بحث، model.Roles، شامل تمام گروههای کاربری متصل شده به کاربر میباشند که در این لیست برخی از آنها با TrackingState.Deleted، برخی دیگر با TrackingState.Added و ... علامتگذاری شدهاند. لذا برای بررسی یکتایی و عدم تکرار در این سناریوها نیاز به اجری پرسوجویی بر روی دیتابیس نمیباشد. بدین منظور، با اعمال یک شرط، گروههای حذف شده را از بررسی خارج کردهایم؛ چرا که آنها بعد از عبور از منطق تجاری، حذف خواهند شد.
گام سوم: پیادهسازی سرویس متناظر
public interface IUserService : ICrudService<long, UserReadModel, UserModel> { }
public class UserService : CrudService<User, long, UserReadModel, UserModel>, IUserService { private readonly IUserManager _manager; public UserService(CrudServiceDependency dependency, IUserManager manager) : base(dependency) { _manager = manager ?? throw new ArgumentNullException(nameof(manager)); } protected override IQueryable<User> BuildFindQuery() { return base.BuildFindQuery() .Include(u => u.Roles) .Include(u => u.Permissions); } protected override IQueryable<UserReadModel> BuildReadQuery(FilteredPagedQueryModel model) { return EntitySet.AsNoTracking().Select(u => new UserReadModel { Id = u.Id, RowVersion = u.RowVersion, IsActive = u.IsActive, UserName = u.UserName, DisplayName = u.DisplayName, LastLoggedInDateTime = u.LastLoggedInDateTime }); } protected override User MapToEntity(UserModel model) { return new User { Id = model.Id, RowVersion = model.RowVersion, IsActive = model.IsActive, DisplayName = model.DisplayName, UserName = model.UserName, NormalizedUserName = model.UserName.ToUpperInvariant(), NormalizedDisplayName = model.DisplayName.NormalizePersianTitle(), Roles = model.Roles.Select(r => new UserRole {Id = r.Id, RoleId = r.RoleId, TrackingState = r.TrackingState}).ToList(), Permissions = model.Permissions.Select(p => new UserPermission { Id = p.Id, TrackingState = p.TrackingState, IsGranted = true, Name = p.Name }).Union(model.IgnoredPermissions.Select(p => new UserPermission { Id = p.Id, TrackingState = p.TrackingState, IsGranted = false, Name = p.Name })).ToList() }; } protected override UserModel MapToModel(User entity) { return new UserModel { Id = entity.Id, RowVersion = entity.RowVersion, IsActive = entity.IsActive, DisplayName = entity.DisplayName, UserName = entity.UserName, Roles = entity.Roles.Select(r => new UserRoleModel {Id = r.Id, RoleId = r.RoleId, TrackingState = r.TrackingState}).ToList(), Permissions = entity.Permissions.Where(p => p.IsGranted).Select(p => new PermissionModel { Id = p.Id, TrackingState = p.TrackingState, Name = p.Name }).ToList(), IgnoredPermissions = entity.Permissions.Where(p => !p.IsGranted).Select(p => new PermissionModel { Id = p.Id, TrackingState = p.TrackingState, Name = p.Name }).ToList() }; } protected override Task BeforeSaveAsync(IReadOnlyList<User> entities, List<UserModel> models) { ApplyPasswordHash(entities, models); ApplySerialNumber(entities, models); return base.BeforeSaveAsync(entities, models); } private void ApplySerialNumber(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.IsActive || !model.Password.IsEmpty() || model.Roles.Any(a => a.IsNew || a.IsDeleted) || model.IgnoredPermissions.Any(p => p.IsDeleted || p.IsNew) || model.Permissions.Any(p => p.IsDeleted || p.IsNew)) { entity.SerialNumber = _manager.NewSerialNumber(); } else { //prevent include SerialNumber in update query UnitOfWork.Entry(entity).Property(a => a.SerialNumber).IsModified = false; } } } private void ApplyPasswordHash(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.Password.IsEmpty()) { entity.PasswordHash = _manager.HashPassword(model.Password); } else { //prevent include PasswordHash in update query UnitOfWork.Entry(entity).Property(a => a.PasswordHash).IsModified = false; } } } }
در سناریوهای Master-Detail نیاز است متد دیگری تحت عنوان BuildFindQuery را نیز بازنویسی کنید. این متد برای بقیه حالات نیاز به بازنویسی نداشت؛ چرا که یک تک موجودیت واکشی میشد و خبری از موجودیتهای Detail نبود. در اینجا لازم است تا روش تولید کوئری FindAsyn رو بازنویسی کنیم تا جزئیات دیگری را نیز واکشی کنیم. به عنوان مثال در اینجا Roles و Permissions کاربر نیز Include شدهاند.
نکته: بازنویسی BuildFindQuery را شاید بتوان با روشهای دیگری هم مانند تزئین موجودیتهای وابسته با یک DetailOfAttribute و مشخص کردن نوع موجودیت اصلی، نیز جایگزین کرد.
متدهای MapToModel و MapToEntity هم به مانند قبل پیادهسازی شدهاند. موضوع دیگری که در برخی از سناریوها پیش خواهد آمد، مربوط است به خصوصیتی که در زمان ثبت ضروری میباشد، ولی در زمان ویرایش اگر مقدار داشت باید با اطلاعات موجود در دیتابیس جایگزین شود؛ مانند Password و SerialNumber در موجودیت کاربر. برای این حالت میتوان از متد BeforeSaveAsync بهره برد؛ به عنوان مثال برای SerialNumber:
private void ApplySerialNumber(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.IsActive || !model.Password.IsEmpty() || model.Roles.Any(a => a.IsNew || a.IsDeleted) || model.IgnoredPermissions.Any(p => p.IsDeleted || p.IsNew) || model.Permissions.Any(p => p.IsDeleted || p.IsNew)) { entity.SerialNumber = _manager.NewSerialNumber(); } else { //prevent include SerialNumber in update query UnitOfWork.Entry(entity).Property(a => a.SerialNumber).IsModified = false; } } }
در اینجا ابتدا بررسی شدهاست که اگر کاربر، جدید میباشد، غیرفعال شده است، کلمه عبور او تغییر داده شده است و یا تغییراتی در دسترسیها و گروههای کاربری او وجود دارد، یک SerialNumber جدید ایجاد کند. در غیر این صورت با توجه به اینکه برای عملیات ویرایش، به صورت منفصل عمل میکنیم، نیاز است تا به شکل بالا، از قید این فیلد در کوئری ویرایش، جلوگیری کنیم.
نکته: متد BeforeSaveAsync دقیقا بعد از ردیابی شدن وهلههای موجودیت توسط Context برنامه و دقیقا قبل از UnitOfWork.SaveChange فراخوانی خواهد شد.
LINQPad برای تست کردن سریع عبارات LINQ فوق العاده است. با استفاده از آن بدون نیاز به VS.NET خیلی سریع و در عرض چند ثانیه میتونید عبارات LINQ خودتون رو نوشته و تست کنید. این LINQ میتونه LINQ to Objects باشه یا LINQ to SQL یا LINQ to Entities و غیره.
خلاصه چیزی شبیه به management studio مخصوص SQL Server را تصور کنید که اینبار بجای SQL نویسی، LINQ مینویسید، حاصل را نمایش میدهد؛ علاوه بر آن خروجی SQL تولیدی و حتی IL نهایی را هم نمایش میدهد که برای دیباگ بسیار مفید است.
به همراه آن یک سری مثال هم وجود دارد که جهت فراگیری LINQ یا حتی استفاده از آنها به عنوان مرجع بینظیر است.
کوئری تایپها در EF Core
EF Code First #8
یک قسمت رو به مروری سریع به کوئری نوشتن در EF اختصاص خواهم داد.
این افزونه جزو موفقترین کتابخانههای دات نتی مایکروسافت در سالهای اخیر به شما میرود؛ تا حدی که معادلهای بسیاری از آن برای زبانهای دیگر مانند Java، JavaScript، Python، CPP و غیره نیز تهیه شدهاند.
استفاده از Rx به همراه یک کوئری LINQ
یک برنامهی کنسول جدید را ایجاد کنید. سپس برای نصب کتابخانهی Rx، دستور ذیل را در کنسول پاورشل نیوگت اجرا نمائید:
PM> Install-Package Rx-Main
<?xml version="1.0" encoding="utf-8"?> <packages> <package id="Rx-Core" version="2.2.4" targetFramework="net45" /> <package id="Rx-Interfaces" version="2.2.4" targetFramework="net45" /> <package id="Rx-Linq" version="2.2.4" targetFramework="net45" /> <package id="Rx-Main" version="2.2.4" targetFramework="net45" /> <package id="Rx-PlatformServices" version="2.2.4" targetFramework="net45" /> </packages>
using System; using System.Linq; namespace Rx01 { class Program { static void Main(string[] args) { var query = Enumerable.Range(1, 5).Select(number => number); foreach (var number in query) { Console.WriteLine(number); } finished(); } private static void finished() { Console.WriteLine("Done!"); } } }
اکنون اگر بخواهیم همین عملیات را توسط Rx انجام دهیم، به شکل زیر خواهد بود:
using System; using System.Linq; using System.Reactive.Linq; namespace Rx01 { class Program { static void Main(string[] args) { var query = Enumerable.Range(1, 5).Select(number => number); var observableQuery = query.ToObservable(); observableQuery.Subscribe(onNext: number => Console.WriteLine(number), onCompleted: () => finished()); } private static void finished() { Console.WriteLine("Done!"); } } }
1 2 3 4 5 Done!
observableQuery.Subscribe(Console.WriteLine, finished);
در این مثال ساده صرفا یک Syntax دیگر را نسبت به حلقهی foreach متداول مشاهده کردیم که اندکی فشردهتر است. در هر دو حالت نیز عملیات انجام شده در تردجاری صورت گرفتهاند. اما قابلیتها و ارزشهای واقعی Rx زمانی آشکار خواهند شد که پردازش موازی و پردازش در تردهای دیگر را در آن فعال کنیم.
الگوی Observer
Rx پیاده سازی کنندهی الگوی طراحی شیءگرایی به نام Observer است. برای توضیح آن یک لامپ و سوئیچ برق را درنظر بگیرید. زمانیکه لامپ مشاهده میکند سوئیچ برق در حالت روشن قرار گرفتهاست، روشن خواهد شد و برعکس. در اینجا به سوئیچ، subject و به لامپ، observer گفته میشود. هر زمان که حالت سوئیچ تغییر میکند، از طریق یک callback، وضعیت خود را به observer اعلام خواهد کرد. علت استفاده از callbackها، ارائه راهحلهای عمومی است تا بتواند با انواع و اقسام اشیاء کار کند. به این ترتیب هر بار که شیء observer از نوع متفاوتی تعریف میشود (مثلا بجای لامپ یک خودرو قرار گیرد)، نیازی نخواهد بود تا subject را تغییر داد.
در Rx دو اینترفیس معادل observer و subject تعریف شدهاند. در اینجا اینترفیس IObserver معادل observer است و اینترفیس IObservable معادل subject میباشد:
class Subject : IObservable<int> { public IDisposable Subscribe(IObserver<int> observer) { } }
class Observer : IObserver<int> { public void OnCompleted() { } public void OnError(Exception error) { } public void OnNext(int value) { } }
مجموعههای Observable کلید کار با Rx هستند. در مثال قبل ملاحظه کردیم که با استفاده از متد الحاقی ToObservable بر روی یک کوئری LINQ و یا هر نوع IEnumerable ایی، میتوان یک مجموعهی Observable را ایجاد کرد. خروجی کوئری حاصل از آن به صورت خودکار اینترفیس IObservable را پیاده سازی میکند که دارای یک متد به نام Subscribe است.
در متد Subscribe کاری که به صورت خودکار صورت خواهد گرفت، ایجاد یک حلقهی foreach بر روی مجموعهی مورد آنالیز و سپس فراخوانی متد OnNext کلاس پیاده سازی کنندهی IObserver به ازای هر آیتم موجود در مجموعه است (فراخوانی observer.OnNext). در پایان کار هم فقط return this در اینجا صورت خواهد گرفت. در حین پردازش حلقه، اگر خطایی رخ دهد، متد observer.OnError انجام میشود.
در مثال قبل،کوئری LINQ نوشته شده، خروجی از نوع IObservable ندارد. به کمک متد الحاقی ToObservable:
public static System.IObservable<TSource> ToObservable<TSource>( this System.Collections.Generic.IEnumerable<TSource> source, System.Reactive.Concurrency.IScheduler scheduler)
البته استفاده از متد Subscribe به نحوی که در مثال قبل ذکر شد، خلاصه شدهی الگوی Observer است. اگر بخواهیم دقیقا مانند الگو عمل کنیم، چنین شکلی را خواهد داشت:
var query = Enumerable.Range(1, 5).Select(number => number); var observableQuery = query.ToObservable(); var observer = Observer.Create<int>(onNext: number => Console.WriteLine(number)); observableQuery.Subscribe(observer);
پردازش نتایج یک کوئری LINQ در تردی دیگر توسط Rx
برای اجرای نتایج متد Subscribe در یک ترد جدید، میتوان پارامتر scheduler متد ToObservable را مقدار دهی کرد:
using System; using System.Linq; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Threading; namespace Rx01 { class Program { static void Main(string[] args) { Console.WriteLine("Thread-Id: {0}", Thread.CurrentThread.ManagedThreadId); var query = Enumerable.Range(1, 5).Select(number => number); var observableQuery = query.ToObservable(scheduler: NewThreadScheduler.Default); observableQuery.Subscribe(onNext: number => { Console.WriteLine("number: {0}, on Thread-id: {1}", number, Thread.CurrentThread.ManagedThreadId); }, onCompleted: () => finished()); } private static void finished() { Console.WriteLine("Done!"); } } }
Thread-Id: 1 number: 1, on Thread-id: 3 number: 2, on Thread-id: 3 number: 3, on Thread-id: 3 number: 4, on Thread-id: 3 number: 5, on Thread-id: 3 Done!
NewThreadScheduler.Default در فضای نام System.Reactive.Concurrency واقع شدهاست.
یک نکته
در نگارشهای آغازین Rx، مقدار scheduler را میشد معادل Scheduler.NewThread نیز قرار داد که در نگارشهای جدید منسوخ شده درنظر گرفته شده و به زودی حذف خواهد شد. معادلهای جدید آن اکنون NewThreadScheduler.Default، ThreadPoolScheduler.Default و امثال آن هستند.
مدیریت خاتمهی اعمال انجام شدهی در تردهای دیگر توسط Rx
یکی از مواردی که حین اجرای نتیجهی callbackهای پردازش شدهی در تردهای دیگر نیاز است بدانیم، زمان خاتمهی کار آنها است. برای نمونه در مثال قبل، نمایش Done پس از پایان تمام callbacks انجام شدهاست. فرض کنید، callback پایان عملیات را حذف کرده و متد finished را پس از فراخوانی متد observableQuery.Subscribe قرار دهیم:
observableQuery.Subscribe(onNext: number => { Console.WriteLine("number: {0}, on Thread-id: {1}", number, Thread.CurrentThread.ManagedThreadId); }/*, onCompleted: () => finished()*/); finished();
Thread-Id: 1 number: 1, on Thread-id: 3 Done! number: 2, on Thread-id: 3 number: 3, on Thread-id: 3 number: 4, on Thread-id: 3 number: 5, on Thread-id: 3
مدیریت استثناهای رخ داده در حین پردازش مجموعههای واکنشگرا
متد Subscribe دارای چندین overload است. تا اینجا نمونهای که دارای پارامترهای onNext و onCompleted بودند را بررسی کردیم. اگر بخواهیم مدیریت استثناءها را نیز در اینجا اضافه کنیم، فقط کافی است از overload دیگر آن که دارای پارامتر onError است، استفاده نمائیم:
observableQuery.Subscribe( onNext: number => Console.WriteLine(number), onError: exception => Console.WriteLine(exception.Message), onCompleted: () => finished());
مدیریت ترد اجرای نتایج حاصل از Rx در یک برنامهی دسکتاپ WPF یا WinForms
تا اینجا مشاهده کردیم که اجرای callbackهای observer در یک ترد دیگر، به سادگی تنظیم پارامتر scheduler متد ToObservable است. اما در برنامههای دسکتاپ برای به روز رسانی عناصر رابط کاربری، حتما باید در تردی قرار داشته باشیم که آن رابط کاربری در آن ایجاد شدهاست یا به عبارتی در ترد اصلی برنامه؛ در غیر اینصورت برنامه کرش خواهد کرد. مدیریت این مساله نیز در Rx بسیار سادهاست. ابتدا نیاز است بستهی Rx-WPF را نصب کرد:
PM> Install-Package Rx-WPF
observableQuery.ObserveOn(DispatcherScheduler.Current).Subscribe(...)
observableQuery.ObserveOnDispatcher().Subscribe(...)
و یا اگر از WinForms استفاده میکنید، ابتدا بستهی Rx خاص آنرا نصب کنید:
PM> Install-Package Rx-WinForms
observableQuery.ObserveOn(SynchronizationContext.Current).Subscribe(...)
یک نکته
در Rx فرض میشود که کوئری شما زمانبر است و callbackهای مشاهدهگر سریع عمل میکنند. بنابراین هدف از callbackهای آن، پردازشهای سنگین نیست. جهت آزمایش این مساله، اینبار query ابتدایی برنامه را به شکل ذیل تغییر دهید که در آن بازگشت زمانبر یک سری داده شبیه سازی شدهاند.
var query = Enumerable.Range(1, 5).Select(number => { Thread.Sleep(250); return number; });
var user = _documentSession .Include<User>(x => x.Apps[59].AddressId) .Load("Users/131-A"); var address = _documentSession.Load<Address>(user.Apps[59].AddressId)
var user = _documentSession .Include<User>(x => x.Apps.Values.Select(app => app.AddressId)) .Load("Users/131-A"); var addresses = List<Address>(); foreach(app in user.Apps) { addresses.Add(_documentSession.Load<Address>(app.AddressId)); //queryسمت کلاینت انجام اجرا میشود }
- حتما باید Id سند(ها) را داشته باشیم.
- کل سند را بازیابی میکند.
List<User> users = await _documentSession .Query<Users>() .Where(u => u.PhoneNumber.StartsWith("915")) .ToListAsync();
var users = await _documentSession.Query<AppUser>() .Where(u => u.Id.Equals("915")) .Select(u => new { u.Apps[appCode].FirstName, u.Apps [appCode].LastName, }) .ToListAsync();
from Users as user where startsWith(user.PhoneNumber, "915") select { FirstName : user.Apps ["59"].FirstName, LastName : user.Apps ["59"].LastName }
from u in _documentSession.Query<User>() where u.PhoneNumber.StartsWith("915") let app = u.Apps["59"] select new { app.FirstName, app.LastName, };
declare function output(u) { var app = u.Apps["59"]; return { FirstName : app.FirstName, LastName : app.LastName}; } from Users as user where startsWith(user.PhoneNumber, "915") select output(user)
app.FirstName, app.LastName, *key = u.ActiveInApps.Select(a => a.Key)
query = query.Search(u => u.key, "59");
public class User_MyIndex : AbstractIndexCreationTask<User> { Map = users => from u in users from app in u.Apps select new { Id = u.Id, PhoneNumber = u.PhoneNumber, UserName = app.Value.UserName, FirstName = app.Value.FirstName, LastName = app.Value.LastName, IsActive = app.Value.IsActive, key = app.Key }; }
new User_MyIndex().Execute(store);
IndexCreation.CreateIndexes(typeof(User_MyIndex).Assembly, store);
from u in _documentSession.Query<User, User_MyIndex>() ...
select new { ... key = aia.Key, Address = LoadDocument<Address>(aia.Value.AddressId), // City = LoadDocument<Address>(aia.Value.AddressId).City, };
Message = app.Messages.Select(m => LoadDocument<Message>(m).Content)
var users = _documentSession.Advanced.AsyncDocumentQuery<User, User_MyIndex>() .WhereStartsWith(nameof(AppUser.PhoneNumber), "915") .WhereEquals("key", appCode, exact: true) .SelectFields<AppUserModel>(new[] { $"Apps[{appCode}].FirstName", $"Apps[{appCode}].LastName" }) .ToListAsync();
public class Post { public int Id { get; set; } public string Content { get; set; } public string Title { get; set; } public List<string> Tags { get; set; } public string WriterName { get; set; } public string WriterId { get; set; } }
public class Post_ByContent : AbstractIndexCreationTask<Post> { public Post_ByContent() { Map = posts=> from post in posts select new { post.Content }; Analyzers.Add(p => p.Content, "StandardAnalyzer"); } }
List<Post> posts = _documentSession .Query<Post, Post_ByContent>() .MoreLikeThis(builder => builder .UsingDocument(p => p.Id == "posts/59-A") .WithOptions(new MoreLikeThisOptions { Fields = new[] { nameof(Post.Content) }, StopWordsDocumentId = "appConfig/StopWords" })) .ToList();
using System.Collections.Generic; namespace AutoMapperComparison.Models { public class User { public int Id { get; set; } public string Name { get; set; } public ICollection<Address> Addresses { get; set; } } }
using System.ComponentModel.DataAnnotations.Schema; namespace AutoMapperComparison.Models { public class Address { public int Id { get; set; } public double? Code { get; set; } public string Title { get; set; } public int UserId { get; set; } [ForeignKey(nameof(UserId))] public virtual User User { get; set; } } }
using EntityFramework.BulkInsert.Extensions; using System.Collections.Generic; using System.Data.Entity; using System.Data.SqlClient; namespace AutoMapperComparison.Models { public class AppDbContextInitializer : DropCreateDatabaseAlways<AppDbContext> { protected override void Seed(AppDbContext context) { User user = context.Users.Add(new User { Name = "Test" }); context.SaveChanges(); List<Address> addresses = new List<Address>(); for (int i = 0; i < 500000; i++) { addresses.Add(new Address { Id = i, Code = 1, Title = "Test", UserId = user.Id }); } context.BulkInsert(addresses); base.Seed(context); } } public class AppDbContext : DbContext { static AppDbContext() { Database.SetInitializer(new AppDbContextInitializer()); //Database.SetInitializer<AppDbContext>(null); } public AppDbContext() : base(new SqlConnection(@"Data Source=.;Initial Catalog=AppDbContext;Integrated Security=True"), contextOwnsConnection: true) { Configuration.AutoDetectChangesEnabled = false; Configuration.EnsureTransactionsForFunctionsAndCommands = false; Configuration.LazyLoadingEnabled = false; Configuration.ProxyCreationEnabled = false; Configuration.ValidateOnSaveEnabled = false; Configuration.UseDatabaseNullSemantics = false; } public DbSet<User> Users { get; set; } public DbSet<Address> Addresses { get; set; } } }
namespace AutoMapperComparison.Models { public class AddressDto { public int Id { get; set; } public double? Code { get; set; } public string Title { get; set; } public int UserId { get; set; } public string UserName { get; set; } } }
using AutoMapper; using AutoMapper.QueryableExtensions; using AutoMapperComparison.Models; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; namespace AutoMapperComparison { public class Program { public static void Main() { Mapper.Initialize(cfg => { cfg.CreateMap<Address, AddressDto>(); }); Console.WriteLine($"Create Db {DateTimeOffset.UtcNow}"); using (AppDbContext db = new AppDbContext()) { db.Database.Initialize(force: true); db.Database.ExecuteSqlCommand("DBCC DROPCLEANBUFFERS"); //Removes all clean buffers from the buffer pool, and columnstore objects from the columnstore object pool Console.WriteLine(db.Addresses.ProjectTo<AddressDto>()); Console.WriteLine(db.Addresses.Select(add => new AddressDto { Id = add.Id, Code = add.Code, Title = add.Title, UserId = add.UserId, UserName = add.User.Name })); } Console.WriteLine($"Normal Select {DateTimeOffset.UtcNow}"); using (AppDbContext db = new AppDbContext()) { db.Database.ExecuteSqlCommand("DBCC DROPCLEANBUFFERS"); Stopwatch watch = Stopwatch.StartNew(); List<AddressDto> addresses = db.Addresses.AsNoTracking().Select(add => new AddressDto { Id = add.Id, Code = add.Code, Title = add.Title, UserId = add.UserId, UserName = add.User.Name }).ToList(); List<AddressDto> addresses2 = db.Addresses.AsNoTracking().Select(add => new AddressDto { Id = add.Id, Code = add.Code, Title = add.Title, UserId = add.UserId, UserName = add.User.Name }).ToList(); watch.Stop(); Console.WriteLine($"{watch.ElapsedMilliseconds} {addresses.Count} {addresses2.Count}"); } Console.WriteLine($"AutoMapper Exec {DateTimeOffset.UtcNow}"); using (AppDbContext db = new AppDbContext()) { db.Database.ExecuteSqlCommand("DBCC DROPCLEANBUFFERS"); Stopwatch watch = Stopwatch.StartNew(); List<AddressDto> addresses = db.Addresses.AsNoTracking().ProjectTo<AddressDto>().ToList(); List<AddressDto> addresses2 = db.Addresses.AsNoTracking().ProjectTo<AddressDto>().ToList(); watch.Stop(); Console.WriteLine($"{watch.ElapsedMilliseconds} {addresses.Count} {addresses2.Count}"); } Console.ReadKey(); } } }
حال نتایج بدست آمده، در قسمت پایینتر آن نمایان میشود:
البته نتیجهی این آزمایش بسته به سخت افزار سیستم شما ممکن است کمی متفاوت باشد.
در سه آزمایش دیگر به صورت متوالی نتیجهی زیر بدست آمد:
Normal | AutoMapper |
2451 | 2378 |
2120 | 2111 |
2202 | 2124 |
اگر این مقدار جزئی از تفاوت بین دو نوع مختلف آزمایش را مورد نظر نگیریم، میتوان گفت که هر دو روش نتیجهی کاملا یکسانی خواهند داشت. فقط با استفاده از AutoMapper کدهای کمتری نوشته شدهاست!
اما دلیل چیست؟ از آنجایی که ProjectTo از Dto به Model انجام شده و Lambda Expressionی که به سمت Entity Framework فرستاده شدهاست با روش Normal کاملا برابر است و بقیهی عملیات توسط EF انجام میشود، با قاطعیت میتوان گفت که هر دو روش ذکر شده از نظر Performance کاملا یکسان خواهند بود.
نکته: البته به این موضوع باید توجه شود که اگر همین آزمایش را بطور مثال با استفاده از یک Listی از رکوردهای درون Memory ساخته شده توسط خودمان انجام دهیم، آن موقع نتیجهی یکسانی نخواهیم داشت، به دلیل اینکه EFی دیگر وجود نخواهد داشت که مسئولیت بازگشت دادهها را بر عهده بگیرد. از آنجائیکه اکثر کارهایی که توقع داریم AutoMapper برای ما انجام دهد، توسط ORM بازگشت داده میشود، پس میتوان گفت نکتهی فوق تقریبا در دنیای واقعی رخ نخواهد داد و باعث مشکل نخواهد شد.