در ابتدای بحث، برای آشنایی بیشتر با HTML Helperها به مطالعه این مقاله بپردازین.
در این مقاله قرار است برای یک HTML Helper خاص، قالب نمایشی اختصاصی خودمان را طراحی کنیم و به نحوی HTML Helper موجود را سفارشی سازی کنیم. به عنوان مثال میخواهیم خروجی یک EditorFor() برای یک نوع خاص، به حالت دلخواهی باشد که ما خودمان آن را تولیدش کردیم؛ یا اصلا نه. حتی میشود برای خروجی یک EditorFor() که خصوصیتی از جنس string را میخواهیم به آن انتساب دهیم، به جای تولید input، یک مقدار متنی را برگردانیم. به این حالت:
<div> @Html.LabelFor(model => model.Name, htmlAttributes: new { @class = "control-label col-md-2" }) <div> @Html.EditorFor(model => model.Name, new { htmlAttributes = new { @class = "form-control" } }) @Html.ValidationMessageFor(model => model.Name, "", new { @class = "text-danger" }) </div> </div> <div> @Html.LabelFor(model => model.Genre, htmlAttributes: new { @class = "control-label col-md-2" }) <div> @Html.EditorFor(model => model.Genre, new { htmlAttributes = new { @class = "form-control" } }) @Html.ValidationMessageFor(model => model.Genre, "", new { @class = "text-danger" }) </div> </div>
در ادامه یک پروژهی عملی را شروع کرده و در آن کاری را که میخواهیم، انجام میدهیم. پروژهی ما به این شکل میباشد که قرار است در آن به ثبت کتاب بپردازیم و برای هر کتاب هم یک سبک داریم و قسمت سبک کتابهای ما یک Enum است که از قبل میخواهیم مقدارهایش را تعریف کنیم.
مدل برنامه
public class Books { public int Id { get; set; } [Required] [StringLength(255)] public string Name { get; set; } public Genre Genre { get; set; } }
public enum Genre { [Display(Name = "Non Fiction")] NonFiction, Romance, Action, [Display(Name = "Science Fiction")] ScienceFiction }
در داخل کلاس Books یک خصوصیت از جنس Genre برای سبک کتابها داریم و در داخل نوع شمارشی Genre، سبکهای ما تعریف شدهاند. همچنین هر کدام از سبکها هم به ویژگی Display مزین شدهاند تا بتونیم بعدا از مقدار آنها استفاده کنیم.
کنترلر برنامه
public class BookController : Controller { // GET: Book public ActionResult Index() { return View(DataAccess.DataContext.Book.ToList()); } public ActionResult Create() { return View(); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(Books model) { if (!ModelState.IsValid) return View(model); try { DataAccess.DataContext.Book.Add(model); DataAccess.DataContext.SaveChanges(); return RedirectToAction("Index"); } catch (Exception ex) { ModelState.AddModelError("", ex.Message); return View(model); } } public ActionResult Edit(int id) { try { var book = DataAccess.DataContext.Book.Find(id); return View(book); } catch (Exception ex) { return View("Error"); } } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Edit(Books model) { if (!ModelState.IsValid) return View(model); try { DataAccess.DataContext.Book.AddOrUpdate(model); DataAccess.DataContext.SaveChanges(); return RedirectToAction("Index"); } catch (Exception ex) { ModelState.AddModelError("", ex.Message); return View(model); } } public ActionResult Details(int id) { try { var book = DataAccess.DataContext.Book.Find(id); return View(book); } catch (Exception ex) { return View("Error"); } } }
در قسمت کنترلر هم کار خاصی جز عملیات اصلی نوشته نشدهاست. لیست کتابها را از پایگاه داده بیرون آوردیم و از طریق اکشن Index به نمایش گذاشتیم. با اکشنهای Create، Edit و Details هم کارهای روتین مربوط به خودشان را انجام دادیم. نکتهی قابل تذکر، DataAccess میباشد که کلاسی است که با آن ارتباط برقرار شده با EF و سپس اطلاعات واکشی و تزریق میشوند.
View مربوط به اکشن Create برنامه
@using Book.Entities @model Book.Entities.Books @{ ViewBag.Title = "Create"; } <h2>New Book</h2> @using (Html.BeginForm()) { @Html.AntiForgeryToken() <div> <h4>Books</h4> <hr /> @Html.ValidationSummary(true, "", new { @class = "text-danger" }) <div> @Html.LabelFor(model => model.Name, htmlAttributes: new { @class = "control-label col-md-2" }) <div> @Html.EditorFor(model => model.Name, new { htmlAttributes = new { @class = "form-control" } }) @Html.ValidationMessageFor(model => model.Name, "", new { @class = "text-danger" }) </div> </div> <div> @Html.LabelFor(model => model.Genre, htmlAttributes: new { @class = "control-label col-md-2" }) <div> @Html.EditorFor(model => model.Genre, new { htmlAttributes = new { @class = "form-control" } }) @Html.ValidationMessageFor(model => model.Genre, "", new { @class = "text-danger" }) </div> </div> <div> <div> <input type="submit" value="Create" /> <input type="reset" value="Reset" /> @Html.ActionLink("Back to List", "Index", null, new {@class="btn btn-default"}) </div> </div> </div> } @section Scripts { @Scripts.Render("~/bundles/jqueryval") }
View برنامه هم همان ویویی است که خود Visual Studio برای ما ساختهاست. به جز یک سری دستکاریهایی داخل سیاساس، هدف از گذاشتن View مربوط به Create این بود که قرار است بر روی این قسمت کار کنیم. اگر پروژه رو اجرا کنید و به قسمت Create بروید، مشاهده خواهید کرد که برای Genre یک input ساخته شدهاست که کاربر باید در آن مقدار وارد کند. ولی اگر یادتان باشد، ما سبکهای نگارشی خودمان را در نوع شمارشی Genre ایجاد کرده بودیم. پس عملا باید یک لیست به کاربر نشان داده شود که تا از آن لیست، نوع را انتخاب کند. میتوانیم بیایم همینجا در داخل View مربوطه، بهجای استفاده از HTML Helper پیشفرض، از DropDownList یا EnumFor استفاده کنیم و به طریقی این لیست را ایجاد کنیم. ولی چون قرار است در این مثال به شرح موضوع مقاله خودمان بپردازیم، این کار را انجام نمیدهیم.
در حقیقیت میخوایم متد EditorFor را طوری سفارشی سازی کنیم که برای نوع شمارشی Genre، به صورت خودکار یک لیست ایجاد کرده و برگرداند. از نسخهی سوم ASP.NET MVC به بعد این امکان برای توسعه دهندهها فراهم شدهاست. شما میتوانید در پوشهی Shared داخل پوشه Views برنامه، پوشهای را به اسم EditorTemplates ایجاد کنید؛ همینطور DisplayTemplates و برای نوع خاصی که میخواهید سفارشیسازی را برای آن انجام دهید، یک PartialView بسازید.
Views/Shared/DisplayTemplates/<type>.cshtml
کاری که الان میخواهیم انجام دهیم این است که یک SelectListItem ایجاد کرده تا مقدارهای نوع Genreمان داخلش باشد و بتوانیم به راحتی برای ساختن DropDownList از آن استفاده کنیم. برای این کار Helper مخصوص خودمان را ایجاد میکنیم. پوشهای به اسم Helpers در کنار پوشههای Controllers، Models ایجاد میکنیم و در داخل آن کلاسی به اسم EnumHelpers میسازیم.
public static class EnumHelpers { public static IEnumerable<SelectListItem> GetItems( this Type enumType, int? selectedValue) { if (!typeof(Enum).IsAssignableFrom(enumType)) { throw new ArgumentException("Type must be an enum"); } var names = Enum.GetNames(enumType); var values = Enum.GetValues(enumType).Cast<int>(); var items = names.Zip(values, (name, value) => new SelectListItem { Text = GetName(enumType, name), Value = value.ToString(), Selected = value == selectedValue } ); return items; } static string GetName(Type enumType, string name) { var result = name; var attribute = enumType .GetField(name) .GetCustomAttributes(inherit: false) .OfType<DisplayAttribute>() .FirstOrDefault(); if (attribute != null) { result = attribute.GetName(); } return result; } }
در توضیح کد بالا عنوان کرد که متدها بهصورت متدهای الحاقی به نوع Type نوشته شدند. کار خاصی در بدنهی متدها انجام نشدهاست. در بدنهی متد اول لیست آیتمها را تولید کردیم. در هنگام ساخت SelectListItem برای گرفتن Text، متد GetName را صدا زدیم. برای اینکه بتوانیم مقدار ویژگی Display که در هنگام تعریف نوع شمارشی استفاده کردیم را بدست بیاریم، باید چک کنیم ببینیم که آیا این آیتم به این ویژگی مزین شدهاست یا نه. اگر شده بود مقدار را میگیریم و به خصوصیت Text متد اول انتساب میدهیم.
@using Book.Entities @using Book.Web.Helpers @{ var items = typeof(Genre).GetItems((int?)Model); } @Html.DropDownList("", items, new {@class="form-control"})
کدهایی که در بالا مشاهده میکنید کدهایی میباشند که قرار است داخل PartialViewی Genre قرار دهیم که در پوشهی EditorTemplates ساختیم. ابتدا آمدیم آیتمها را گرفتیم و بعد به DropDownList دادیم تا لیست نوع را برای ما بسازد. حالا اگه برنامه را اجرا کنید میبینید که EditorFor برای شما یه لیست از نوع شمارشی ساخته و حالا قابل استفاده هست.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید
در این بین اگر به قالب پیشفرض پروژههای MVC تولید شدهی توسط ASP.NET Core 2.1 نیز دقت کنید، پشتیبانی توکار از پیشنیازهای GDPR در آن لحاظ شدهاست؛ چه از لحاظ گوشزد کردن شرایط حریم خصوصی و پذیرش آن و چه از لحاظ «پاک کردن» و «پس گرفتن» اطلاعات شخصی.
قالب و کوکی پذیرش شرایط حریم خصوصی سایت (Cookie Consent)
اگر قالب پیشفرض یک پروژهی ASP.NET Core 2.1 را اجرا کنید، تصویر فوق را که در آن نوار پذیرش شرایط حریم خصوصی سایت در بالای صفحه درج شدهاست، مشاهده خواهید کرد.
قالب جدید نوار پذیرش شرایط حریم خصوصی در مسیر Views\Shared\_CookieConsentPartial.cshtml واقع شدهاست و در فایل layout برنامه توسط tag helper جدید Partial، رندر و نمایش داده میشود:
<partial name="_CookieConsentPartial" />
@using Microsoft.AspNetCore.Http.Features @{ var consentFeature = Context.Features.Get<ITrackingConsentFeature>(); var showBanner = !consentFeature?.CanTrack ?? false; var cookieString = consentFeature?.CreateConsentCookie(); }
الف) تنظیم نیاز به دریافت پذیرش
public void ConfigureServices(IServiceCollection services) { services.Configure<CookiePolicyOptions>(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; });
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { // ... app.UseCookiePolicy();
علاوه بر Cookie Consent فوق که در یک قالب ابتدایی MVC نیز درج شدهاست، در قالب پروژههای ASP.NET Core Identity، دو گزینهی جدید دریافت اطلاعات شخصی و همچنین حذف اکانت (دادن حق فراموشی به کاربران) نیز پیشبینی شدهاست: PersonalData.cshtml
البته این صفحه جزو بستهی جدید Microsoft.AspNetCore.Identity.UI است که به همراه ASP.NET Core 2.1 ارائه میشود:
dotnet add package Microsoft.AspNetCore.Identity.UI --version 2.1.0-rc1-final
مثال 1: چه تعداد امکانات، توسط این مجموعه ارائه میشود؟
var count = context.Facilities.Count();
مثال 2: چه تعداد امکانات گران قیمتی توسط این مجموعه ارائه میشود؟
میخواهیم تعداد امکاناتی را بیابیم که guestcost آنها بزرگتر یا مساوی 10 است.
var count = context.Facilities.Count(x => x.GuestCost >= 10);
مثال 3: هر کاربر چه تعدادی کاربر دیگری را توصیه کردهاست؟
خروجی این گزارش بر اساس recommendedby و count باشد و مرتب شدهی بر اساس ID افراد.
var members = context.Members .Where(member => member.RecommendedBy != null) .GroupBy(member => member.RecommendedBy) .Select(group => new { RecommendedBy = group.Key, Count = group.Count() }) .OrderBy(result => result.RecommendedBy) .ToList();
مثال 4: تعداد slots رزرو شدهی به ازای هر کدام از امکانات موجود را نمایش دهید.
جهت یادآوری از قسمت اول: «هر رزرو کردن مکان و امکاناتی در این مجموعه، «نیم ساعته» است. بنابراین Slots در اینجا به معنای تعداد نیم ساعتهای رزرو کردن یک مکان خاص است؛ که به آن «half hour slots» نیز گفته میشود و زمان شروع این رزرو نیز ثبت میشود.»
خروجی این گزارش بر اساس facid و Total Slots باشد و مرتب شدهی بر اساس ID هر امکان موجود.
var facilities = context.Bookings .GroupBy(booking => booking.FacId) .Select(group => new { FacId = group.Key, TotalSlots = group.Sum(booking => booking.Slots) }) .OrderBy(result => result.FacId) .ToList();
مثال 5: تعداد slots رزرو شدهی در ماه September 2012 را به ازای هر کدام از امکانات موجود، نمایش دهید.
خروجی این گزارش بر اساس facid و Total Slots باشد و مرتب شدهی بر اساس ID تعداد slots.
var date1 = new DateTime(2012, 09, 01); var date2 = new DateTime(2012, 10, 01); var facilities = context.Bookings .Where(booking => booking.StartTime >= date1 && booking.StartTime < date2) .GroupBy(booking => booking.FacId) .Select(group => new { FacId = group.Key, TotalSlots = group.Sum(booking => booking.Slots) }) .OrderBy(result => result.TotalSlots) .ToList();
مثال 6: محاسبه کنید در سال 2012 و به ازای هر ماه مجزای آن، چه تعداد slots رزرو شدهاند.
خروجی این گزارش بر اساس facid, month, Total Slots باشد و مرتب شدهی بر اساس ID و شمارهی ماه.
var date1 = new DateTime(2012, 01, 01); var date2 = new DateTime(2013, 01, 01); var facilities = context.Bookings .Where(booking => booking.StartTime >= date1 && booking.StartTime < date2) .GroupBy(booking => new { booking.FacId, booking.StartTime.Month }) .Select(group => new { group.Key.FacId, group.Key.Month, TotalSlots = group.Sum(booking => booking.Slots) }) .OrderBy(result => result.FacId) .ThenBy(result => result.Month) .ToList();
الف) میتوان گروه بندی را بر روی بیش از یک ستون اعمال کرد. در این حالت در Select بعدی، group.Key به کل شیء گروه بندی شده، اشاره میکند.
ب) روش انتخاب ماه میلادی از یک خاصیت DateTime و گروه بندی بر اساس آن
که به صورت زیر ترجمه میشود:
مثال 7: چه تعداد کاربر مجموعه، حداقل یکبار امکاناتی را رزرو کردهاند؟
var count = context.Bookings.Select(booking => booking.MemId).Distinct().Count();
- (*)COUNT یعنی بازگشت تعداد ردیفهای نهایی گزارش.
- COUNT(address) یعنی بازگشت تعداد آدرسهای غیرنال، در کل ردیفهای نهایی گزارش.
- COUNT(DISTINCT address) یعنی بازگشت تعداد آدرسهای غیرمشابه در کل ردیفهای نهایی گزارش.
COUNT DISTINCT را EF-Core به صورت ترکیبی از یک sub-query ترجمه میکند:
مثال 8: امکاناتی را لیست کنید که بیش از 1000 slots رزرو شده دارند.
خروجی این گزارش بر اساس facid و Total Slots باشد و مرتب شدهی بر اساس ID هر امکان موجود.
var facilities = context.Bookings .GroupBy(booking => booking.FacId) .Select(group => new { FacId = group.Key, TotalSlots = group.Sum(booking => booking.Slots) }) .Where(result => result.TotalSlots > 1000) .OrderBy(result => result.FacId) .ToList();
مثال 9: میزان فروش کل هر امکان موجود را محاسبه کنید.
خروجی این گزارش بر اساس name, revenue باشد و مرتب شدهی بر اساس میزان فروش. بخاطر داشته باشید که میزان فروش کاربران ثبت نام شده با کاربران مهمان یکی نیست.
var facilities = context.Bookings.Select(booking => new { booking.Facility.Name, Revenue = booking.MemId == 0 ? booking.Slots * booking.Facility.GuestCost : booking.Slots * booking.Facility.MemberCost }) .GroupBy(b => b.Name) .Select(group => new { Name = group.Key, TotalRevenue = group.Sum(b => b.Revenue) }) .OrderBy(result => result.TotalRevenue) .ToList();
مثال 10: کدامیک از امکانات موجود، میزان فروشی کمتر از 1000 داشتهاند؟
خروجی این گزارش بر اساس name, revenue باشد و مرتب شدهی بر اساس میزان فروش. بخاطر داشته باشید که میزان فروش کاربران ثبت نام شده با کاربران مهمان یکی نیست.
var facilities = context.Bookings.Select(booking => new { booking.Facility.Name, Revenue = booking.MemId == 0 ? booking.Slots * booking.Facility.GuestCost : booking.Slots * booking.Facility.MemberCost }) .GroupBy(b => b.Name) .Select(group => new { Name = group.Key, TotalRevenue = group.Sum(b => b.Revenue) }) .Where(result => result.TotalRevenue < 1000) .OrderBy(result => result.TotalRevenue) .ToList();
مثال 11: کدامیک از امکانات موجود، بیشترین slots رزرو شده را دارد؟
var item = context.Bookings .GroupBy(booking => booking.FacId) .Select(group => new { FacId = group.Key, TotalSlots = group.Sum(booking => booking.Slots) }) .OrderByDescending(result => result.TotalSlots) .FirstOrDefault();
کدهای کامل این قسمت را در اینجا میتوانید مشاهده کنید.
پیاده سازی Full-Text Search با SQLite و EF Core - قسمت اول - ایجاد و به روز رسانی جدول مجازی FTS
SELECT Title FROM Book WHERE Desc LIKE '%cat%';
معادل دستور LIKE در کوئری فوق، متد Contains در EF Core است:
var cats = context.Chapters.Where(item => item.Text.Contains("cat")).ToList();
یک نکته: کوئری فوق توسط EF Core و به همراه پروایدر SQLite آن، به صورت زیر ترجمه میشود (که آن نیز یک full table scan است):
SELECT "c"."Text" FROM "Chapters" AS "c" WHERE ('cat' = '') OR (instr("c"."Text", 'cat') > 0)
var cats = context.Chapters.Where(item => item.Text.StartsWith("cat")).ToList(); // SELECT "c"."Text", FROM "Chapters" AS "c" WHERE "c"."Text" IS NOT NULL AND ("c"."Text" LIKE 'cat%')
var cats = context.Chapters.Where(item => item.Text.EndsWith("cat")).ToList(); // SELECT "c"."Title" FROM "Chapters" AS "c" WHERE "c"."Text" IS NOT NULL AND ("c"."Text" LIKE '%cat')
معرفی موجودیتهای مثال این سری
هدف اصلی ما، ایندکس کردن full-text ستونهای متنی عنوان و متن جدول بانک اطلاعاتی متناظر با Chapter است:
using System.Collections.Generic; namespace EFCoreSQLiteFTS.Entities { public class User { public int Id { get; set; } public string Name { get; set; } public ICollection<Chapter> Chapters { get; set; } } public class Chapter { public int Id { get; set; } public string Title { get; set; } public string Text { get; set; } public User User { get; set; } public int UserId { get; set; } } }
زمانیکه عملیات Migration را در EF Core فعال و اجرا میکنیم، دو جدول متناظر با Chapter و User ایجاد میشوند. اما برای کار با full-text search، نیاز به ایجاد جداول دیگری است، تا کار نگهداری ایندکسهای تشکیل شدهی از ستونهای متنی مدنظر ما را انجام دهند. به این نوع جداول در SQLite، جدول مجازی و یا virtual table گفته میشود. یک virtual table در اصل تفاوتی با یک جدول معمولی ندارد. تفاوت در اینجا است که منطق دسترسی به این جدول مجازی از موتور FTS5 مربوط به SQLite باید عبور کند. تاکنون نگارشهای مختلفی از موتور full-text search آن منتشر شدهاند؛ مانند FTS3 ، FTS4 و غیره که آخرین نگارش آن، FTS5 میباشد و به همراه توزیعی که مایکروسافت ارائه میدهد، وجود دارد و نیازی به تنظیمات خاصی ندارد.
در اینجا روش ایجاد یک جدول مجازی جدید Chapters_FTS را مشاهده میکنید:
CREATE VIRTUAL TABLE "Chapters_FTS" USING fts5("Text", "Title", content="Chapters", content_rowid="Id")
ذکر پارامتر "content="Chapters اختیاری بوده و به این معنا است که نیازی نیست تا اصل دادههای مرتبط با ستونهای ذکر شده نیز ذخیره شوند و آنها را میتوان از جدول Chapters، بازیابی کرد. در این حالت برای برقراری ارتباط بین این جدول مجازی و جدول chapters، پارامتر "content_rowid="Id مقدار دهی شدهاست. content_rowid به primary key جدول content اشاره میکند. ذکر هر دوی این پارامترها اختیاری بوده و در صورت تنظیم، حجم نهایی بانک اطلاعاتی را کاهش میدهند. چون در این حالت دیگری نیازی به ذخیره سازی جداگانهی اصل اطلاعات متناظر با ایندکسهای FTS نیست.
اکنون که با دستور ایجاد جدول مجازی FTS آشنا شدیم، روش ایجاد آن در برنامههای مبتنی بر EF Core نیز دقیقا به همین صورت است:
private static void createFtsTables(ApplicationDbContext context) { // For SQLite FTS // Note: This can be added to the `protected override void Up(MigrationBuilder migrationBuilder)` method too. context.Database.ExecuteSqlRaw(@"CREATE VIRTUAL TABLE IF NOT EXISTS ""Chapters_FTS"" USING fts5(""Text"", ""Title"", content=""Chapters"", content_rowid=""Id"");"); }
به روز رسانی اطلاعات جدول مجازی FTS، توسط تریگرها
پس از اجرای دستورCREATE VIRTUAL TABLE فوق، SQLite پنج جدول را به صورت خودکار ایجاد میکند که در تصویر زیر قابل مشاهده هستند:
البته ما مستقیما با این جداول کار نخواهیم کرد و این جداول برای نگهداری اطلاعات ایندکسهای full-text موتور FTS5، توسط خود SQLite نگهداری و مدیریت میشوند.
اما ... نکتهی مهم اینجا است که جدول مجازی Chapters_FTS، هرچند به جدول اصلی Chapters توسط پارامتر content آن متصل شدهاست، اما تغییرات آنرا ردیابی نمیکند. یعنی هر نوع insert/update/delete ای که در جدول اصلی Chapters رخ میدهد، سبب ایندکس شدن اطلاعات جدید آن در جدول مجازی Chapters_FTS نمیشود و برای اینکار باید اطلاعات را مستقیما در جدول Chapters_FTS درج کرد.
روش پیشنهاد شدهی در مستندات رسمی آن، استفاده از تریگرهای پس از درج اطلاعات، پس از حذف اطلاعات و پس از به روز رسانی اطلاعات به صورت زیر است:
-- Create a table. And an external content fts5 table to index it. CREATE TABLE tbl(a INTEGER PRIMARY KEY, b, c); CREATE VIRTUAL TABLE fts_idx USING fts5(b, c, content='tbl', content_rowid='a'); -- Triggers to keep the FTS index up to date. CREATE TRIGGER tbl_ai AFTER INSERT ON tbl BEGIN INSERT INTO fts_idx(rowid, b, c) VALUES (new.a, new.b, new.c); END; CREATE TRIGGER tbl_ad AFTER DELETE ON tbl BEGIN INSERT INTO fts_idx(fts_idx, rowid, b, c) VALUES('delete', old.a, old.b, old.c); END; CREATE TRIGGER tbl_au AFTER UPDATE ON tbl BEGIN INSERT INTO fts_idx(fts_idx, rowid, b, c) VALUES('delete', old.a, old.b, old.c); INSERT INTO fts_idx(rowid, b, c) VALUES (new.a, new.b, new.c); END;
در ادامه سه تریگر بر روی جدول اصلی که ما به صورت متداولی با آن در برنامههای خود کار میکنیم، تعریف شدهاند. این تریگرها کار insert اطلاعات را در جدول مجازی ایجاد شده، به صورت خودکار انجام میدهند.
همانطور که مشاهده میکنید، یک rowid نیز در اینجا قابل تعریف است؛ rowid، ستون مخفی یک جدول مجازی FTS است و هرچند در حین ایجاد، آنرا ذکر نمیکنیم، اما جزئی از ساختار آن بوده و قابل کوئری گرفتن است.
نکتهی مهم: به فرمت دستورات به روز رسانی جدول مجازی FTS دقت کنید. حتی در حالت تریگرهای update و یا delete نیز در اینجا دستور insert، مشاهده میشوند. این فرمت دقیقا باید به همین نحو رعایت شود؛ در غیراینصورت اگر از دستورات delete و یا update معمولی بر روی این جدول مجازی استفاده کنید، دفعهی بعدی که برنامه را اجرا میکنید، خطای «این بانک اطلاعاتی تخریب شدهاست» را مشاهده کرده (database disk image is malformed) و دیگر نمیتوانید با فایل بانک اطلاعاتی خود کار کنید.
به روز رسانی اطلاعات جدول مجازی FTS توسط EF Core
using System; using System.Collections.Generic; using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace EFCoreSQLiteFTS.DataLayer { public static class EFChangeTrackerExtensions { public static List<(EntityState State, TEntity NewEntity, TEntity OldEntity)> GetChangedEntities<TEntity>(this DbContext dbContext) where TEntity : class, new() { if (!dbContext.ChangeTracker.AutoDetectChangesEnabled) { // ChangeTracker.Entries() only calls `Try`DetectChanges() behind the scene. dbContext.ChangeTracker.DetectChanges(); } return dbContext.ChangeTracker.Entries<TEntity>() .Where(IsEntityChanged) .Select(entityEntry => (entityEntry.State, entityEntry.Entity, createWithValues<TEntity>(entityEntry.OriginalValues))) .ToList(); } private static bool IsEntityChanged(EntityEntry entry) { return entry.State == EntityState.Added || entry.State == EntityState.Modified || entry.State == EntityState.Deleted || entry.References.Any(r => r.TargetEntry?.Metadata.IsOwned() == true && IsEntityChanged(r.TargetEntry)); } private static T createWithValues<T>(PropertyValues values) where T : new() { var entity = new T(); foreach (var prop in values.Properties) { var value = values[prop.Name]; if (value is PropertyValues) { throw new NotSupportedException("nested complex object"); } else { prop.PropertyInfo.SetValue(entity, value); } } return entity; } } }
علت نیاز به نمونهی اصل و سپس تغییر کردهی موجودیتها، به نحوهی تعریف تریگرهای مخصوص به به روز رسانی FTS بر میگردد. اگر دقت کرده باشید در این تریگرها، new.a و همچنین old.a را داریم که برای شبیه سازی آنها دقیقا باید به اطلاعات یک رکورد، در پیش و پس از به روز رسانی آن، دسترسی یافت.
ب) تعریف تریگرهای SQL توسط سیستم tracking؛ به همراه عملیات نرمال سازی اطلاعات
using System.Collections.Generic; using System.Data; using System.Text.RegularExpressions; using EFCoreSQLiteFTS.Entities; using Microsoft.EntityFrameworkCore; namespace EFCoreSQLiteFTS.DataLayer { public static class FtsNormalizer { private static readonly Regex _htmlRegex = new Regex("<[^>]*>", RegexOptions.Compiled); public static string NormalizeText(this string text) { if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } // Remove html tags text = _htmlRegex.Replace(text, string.Empty); // TODO: add other normalizers here, such as `remove diacritics`, `fix Persian Ye-Ke` and so on ... return text; } } public static class UpdateFtsTriggers { public static void UpdateChapterFTS( this DbContext context, List<(EntityState State, Chapter NewEntity, Chapter OldEntity)> changedChapters) { var database = context.Database; try { database.BeginTransaction(IsolationLevel.ReadCommitted); foreach (var (State, NewEntity, OldEntity) in changedChapters) { var chapterNew = NewEntity; var chapterOld = OldEntity; var normalizedNewText = chapterNew.Text.NormalizeText(); var normalizedOldText = chapterOld.Text.NormalizeText(); var normalizedNewTitle = chapterNew.Title.NormalizeText(); var normalizedOldTitle = chapterOld.Title.NormalizeText(); switch (State) { case EntityState.Added: if (shouldSkipAddedChapter(chapterNew)) { continue; } database.ExecuteSqlRaw("INSERT INTO Chapters_FTS(rowid, Text, Title) values({0}, {1}, {2});", chapterNew.Id, normalizedNewText, normalizedNewTitle); break; case EntityState.Modified: if (shouldSkipModifiedChapter(chapterNew, chapterOld)) { continue; } // This format is important! Otherwise we will get `SQLite Error 11: 'database disk image is malformed'.` error! database.ExecuteSqlRaw(@"INSERT INTO Chapters_FTS(Chapters_FTS, rowid, Text, Title) VALUES('delete', {0}, {1}, {2}); ", chapterOld.Id, normalizedOldText, normalizedOldTitle); database.ExecuteSqlRaw("INSERT INTO Chapters_FTS(rowid, Text, Title) values({0}, {1}, {2});", chapterNew.Id, normalizedNewText, normalizedNewTitle); break; case EntityState.Deleted: // This format is important! Otherwise we will get `SQLite Error 11: 'database disk image is malformed'.` error! database.ExecuteSqlRaw(@"INSERT INTO Chapters_FTS(Chapters_FTS, rowid, Text, Title) VALUES('delete', {0}, {1}, {2}); ", chapterOld.Id, normalizedOldText, normalizedOldTitle); break; } } } finally { database.CommitTransaction(); } } private static bool shouldSkipAddedChapter(Chapter chapterNew) { // TODO: add your logic to avoid indexing this item return false; } private static bool shouldSkipModifiedChapter(Chapter chapterNew, Chapter chapterOld) { // TODO: add your logic to avoid indexing this item return chapterNew.Text == chapterOld.Text && chapterNew.Title == chapterOld.Title; } } }
همچنین در اینجا متد NormalizeText را نیز مشاهده میکند که بر روی ستونهای متنی اعمال شدهاست. کار آن پاکسازی تگهای یک متن HTML ای است و نگهداری اطلاعات صرفا متنی آن. در اینجا اگر نیاز بود میتوان منطقهای پاکسازی اطلاعات دیگری را نیز اعمال کرد.
اکنون که این اطلاعات به صورت پاکسازی شده در جدول مجازی درج میشوند، زمانیکه بر روی آنها جستجویی صورت میگیرد، دیگر شامل جستجوی بر روی تگهای HTML ای نیست و دقت بسیار بیشتری دارد.
ج) اتصال به سیستم
پس از تعریف متدهای الحاقی GetChangedEntities و UpdateChapterFTS، اکنون روش اتصال آنها به DbContext برنامه، با بازنویسی متد SaveChanges آن است:
namespace EFCoreSQLiteFTS.DataLayer { public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions options) : base(options) { } public DbSet<Chapter> Chapters { get; set; } public DbSet<User> Users { get; set; } public override int SaveChanges() { var changedChapters = this.GetChangedEntities<Chapter>(); this.ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again. var result = base.SaveChanges(); this.ChangeTracker.AutoDetectChangesEnabled = true; this.UpdateChapterFTS(changedChapters); return result; } } }
در قسمت بعدی، روش کوئری گرفتن از این جدول مجازی FTS را بررسی میکنیم.
تزریق وابستگی به زبانی ساده
معرفی کتابخانهی Redux Thunk
thunk، تابعی است که خروجی تابعی دیگر است؛ مانند مثال زیر:
function definitelyNotAThunk() { return function aThunk() { console.log('Hello, I am a thunk.'); } }
برای مثال فرض کنید که نیاز است یک فراخوانی Ajax ای صورت گیرد و پس از پایان آن، جهت به روز رسانی state، یک شیء اکشن، به سمت reducer متناظری dispatch شود. مشکل اینجا است که نمیتوان به Redux، یک callback حاصل از دریافت نتیجهی عملیات Ajax ای و یا یک Promise را ارسال کرد. تمام اینها یک اثر جانبی یا side effect هستند که با توابع خالص Redux ای سازگاری ندارند. برای مدیریت یک چنین مواردی، یک میانافزار را به نام redux-thunk ایجاد کردهاند که اجازهی dispatch تابعی را میدهد (همان thunk در اینجا) که قرار است action اصلی را در زمانی دیگر dispatch کند. به این ترتیب Redux اطلاعاتی را در مورد یک عمل async نخواهد داشت؛ میانافزاری در این بین آنرا دریافت میکند و زمانیکه آنرا dispatch میکنیم، آنگاه اکشن متناظر با آن، به redux منتقل میشود. به این ترتیب امکان منتظر ماندن تا زمان رسیدن پاسخ از شبکه، میسر میشود.
فرض کنید یک action creator متداول به صورت زیر ایجاد شدهاست:
export const getAllItems = () => ({ type: UPDATE_ALL_ITEMS, items, });
برای پاسخ به این سؤال، اینبار action creator فوق را بر اساس الگوی redux-thunk به صورت زیر بازنویسی میکنیم:
export const getAllItems = () => { return dispatch => { Api.getAll().then(items => { dispatch({ type: UPDATE_ALL_ITEMS, items, }); }); }; };
برپایی پیشنیازها
در اینجا برای افزودن کامپوننتی که اطلاعات خودش را از یک API خارجی تامین میکند، از همان برنامهی به همراه کامپوننت شمارشگر که در قسمت قبل آنرا تکمیل کردیم، استفاده میکنیم. فقط در آن کتابخانههای Axios و همچنین redux thunk را نیز نصب میکنید. به همین جهت در ریشهی پروژهی React این قسمت، دستور زیر را در خط فرمان صادر کنید:
> npm install --save axios redux-thunk
پس از نصب پیشنیازها و راه اندازی برنامهی backend، در ابتدا فایل src\config.json را جهت درج مشخصات آدرس REST Api، ایجاد میکنیم:
{ "apiUrl": "https://localhost:5001/api" }
افزودن میانافزار redux-thunk به برنامه
فرض کنید در قسمتی از صفحه، در کامپوننتی مجزا، دکمهای وجود دارد و با کلیک بر روی آن، قرار است اطلاعاتی از سرور دریافت شده و در کامپوننت مجزای دیگری نمایش داده شود:
چون نیاز به عملیات async وجود دارد، باید از میانافزار مخصوص thunk برای انجام آن استفاده کرد. برای این منظور به فایل src\index.js مراجعه کرده و میانافزار thunk را توسط تابع applyMiddleware، به متد createStore، معرفی میکنیم:
import { applyMiddleware, compose, createStore } from "redux"; import thunk from "redux-thunk"; //... const store = createStore( reducer, compose( applyMiddleware(thunk), window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() ) ); //...
دریافت اطلاعات از یک API خارجی به کمک redux-thunk
پس از آشنایی با روش کلی برقراری اتصالات سیستم react-redux در قسمت قبل، پیاده سازی دریافت اطلاعات را بر اساس همان الگو، پیاده سازی میکنیم:
1) ایجاد نام نوع اکشن متناظر با دکمهی افزودن مقدار
به فایل src\constants\ActionTypes.js، نوع جدید دریافت مطالب را اضافه میکنیم:
export const GetPostsSuccess = "GetPostsSuccess"; export const GetPostsStarted = "GetPostsStarted"; export const GetPostsFailure = "GetPostsFailure";
2) ایجاد متد Action Creator
در فایل src\actions\index.js، متد ایجاد کنندهی شیء اکشن ارسالی به reducer متناظری را تعریف میکنیم تا بتوان بر اساس نوع آن در reducer دریافت اطلاعات، منطق نهایی را پیاده سازی کرد:
import axios from "axios"; import { apiUrl } from "../config.json"; import * as types from "../constants/ActionTypes"; export const fetchPosts = () => { return (dispatch, getState) => { dispatch(getPostsStarted()); axios.get(apiUrl + "/posts").then(response => { dispatch(getPostsSuccess(response.data)).catch(err => { dispatch(getPostsFailure(err)); }); }); }; }; export const fetchPostsAsync = () => { return async (dispatch, getState) => { dispatch(getPostsStarted()); try { const { data } = await axios.get(apiUrl + "/posts"); console.log(data); dispatch(getPostsSuccess(data)); } catch (error) { dispatch(getPostsFailure(error)); } }; }; const getPostsSuccess = posts => ({ type: types.GetPostsSuccess, payload: { posts } }); const getPostsStarted = () => ({ type: types.GetPostsStarted }); const getPostsFailure = error => ({ type: types.GetPostsFailure, payload: { error } });
- تابع fetchPosts، از همان روش قدیمی callback، برای مدیریت اطلاعات دریافتی از سرور استفاده میکند. زمانیکه اطلاعاتی دریافت شد، آنرا با فراخوانی dispatch و با قالبی که تابع getPostsSuccess ارائه میدهد، به reducer متناظر، ارسال میکند.
- تابع fetchPostsAsync، نمونهی به همراه async/await کار با کتابخانهی axios است. هر دو روش callback و یا async/await در اینجا پشتیبانی میشوند.
به صورت پیشفرض action creators کتابخانهی redux از اعمال async پشتیبانی نمیکنند. برای رفع این مشکل پس از ثبت میانافزار thunk، اینبار متدهای action creator، بجای بازگشت یک شیء، یک تابع را بازگشت میدهند که این تابع درونی در زمانی دیگر توسط میانافزار thunk و پیش از رسیدن به reducer، فراخوانی خواهد شد. این تابع درونی، دو پارامتر dispatch و getState را دریافت میکند. هر دوی اینها نیز متد هستند. برای مثال اگر نیاز به دریافت وضعیت فعلی state در اینجا وجود داشت، میتوان متد ()getState رسیده را فراخوانی کرد و حاصل آنرا بررسی نمود. برای مثال شاید تصمیم گرفته شود که بر اساس وضعیت فعلی state، نیازی نیست تا اطلاعاتی از سرور دریافت شود و بهتر است همان اطلاعات کش شدهی موجود در state را بازگشت دهیم. البته در این مثال فقط از متد dispatch ارسالی، برای بازگشت نتیجهی نهایی به reducer متناظر، استفاده شدهاست.
- در نهایت آرایهی اشیاء مطلب دریافتی از سرور، به عنوان مقدار خاصیت posts شیء منتسب به خاصیت payload شیء ارسالی به reducer، در متد getPostsSuccess تعریف شدهاست. یعنی reducer متناظر، اطلاعات را از طریق خاصیت action.payload.posts شیء رسیده، دریافت میکند.
- همچنین دو اکشن شروع به دریافت اطلاعات (getPostsStarted) و بروز خطا (getPostsFailure) نیز در ابتدا و در قسمت catch عملیات async، به سمت reducer متناظر، dispatch خواهند شد.
3) ایجاد تابع reducer مخصوص دریافت اطلاعات از سرور
اکنون در فایل جدید src\reducers\posts.js، بر اساس نوع شیء رسیده و مقدار action.payload.posts آن، کار تامین آرایهی posts موجود در state انجام میشود:
import * as types from "../constants/ActionTypes"; const initialState = { loading: false, posts: [], error: null }; export default function postsReducer(state = initialState, action) { switch (action.type) { case types.GetPostsStarted: return { loading: true, posts: [], error: null }; case types.GetPostsSuccess: return { loading: false, posts: action.payload.posts, error: null }; case types.GetPostsFailure: return { loading: false, posts: [], error: action.payload.error }; default: return state; } }
- در حالت آغاز کار و یا GetPostsStarted، با تنظیم خاصیت loading به true، سبب نمایش یک div «لطفا منتظر بمانید» خواهیم شد.
- در حالت دریافت نهایی اطلاعات از سرور، خاصیت loading به false تنظیم میشود تا div «لطفا منتظر بمانید» را مخفی کند. همچنین آرایهی posts را نیز از payload رسیده استخراج کرده و به سمت کامپوننتها ارسال میکند.
- در حالت بروز خطا و یا GetPostsFailure، خاصیت error شیء action.payload استخراج شده و جهت نمایش div متناظری، بازگشت داده میشود.
پس از تعریف این reducer باید آنرا در فایل src\reducers\index.js به کمک combineReducers، با سایر reducerهای موجود، ترکیب و یکی کرد تا در نهایت این rootReducer در فایل index.js اصلی برنامه، جهت ایجاد store اصلی redux، مورد استفاده قرار گیرد:
import { combineReducers } from "redux"; import counterReducer from "./counter"; import postsReducer from "./posts"; const rootReducer = combineReducers({ counterReducer, postsReducer }); export default rootReducer;
تشکیل کامپوننتهای دکمهی دریافت اطلاعات و نمایش لیست مطالب
UI این قسمت از سه کامپوننت تشکیل شدهاست که کدهای کامل آنها را در ادامه مشاهده میکنید:
الف) کامپوننت src\components\FetchPosts.jsx
import React from "react"; const FetchPosts = ({ fetchPostsAsync }) => { return ( <section className="card mt-5"> <div className="card-header text-center"> <button className="btn btn-primary" onClick={fetchPostsAsync}> Fetch Posts </button> </div> </section> ); }; export default FetchPosts;
همانطور که مشاهده میکنید، این کامپوننت هیچ اطلاعاتی از وجود کامپوننت دومی که قرار است لیست مطالب را نمایش دهد، ندارد. کارش تنها dispatch یک اکشن است.
بنابراین این کامپوننت از طریق props فقط یک اشارهگر به متد رویدادگردانی را دریافت میکند و اطلاعات دیگری را نیاز ندارد.
ب) کامپوننت src\components\Posts.jsx
import React from "react"; import Post from "./Post"; const Posts = ({ posts, loading, error }) => { return ( <> <section className="card mt-5"> <div className="card-header"> <h2>Posts</h2> </div> <div className="card-body"> {loading ? ( <div className="alert alert-info">Loading ...</div> ) : ( <div className="list-group list-group-flush"> {posts.map(post => ( <Post key={post.id} post={post} /> ))} </div> )} {error && <div className="alert alert-warning">{error.message}</div>} </div> </section> </> ); }; export default Posts;
در این کامپوننت اگر loading رسیده به true تنظیم شده باشد، یک div با عبارت loading نمایش داده میشود. در غیراینصورت، لیست مطالب را درج میکند. همچنین اگر خطایی نیز رخ داده باشد، آنرا نیز درون یک div در صفحه نمایش میدهد.
ج) کامپوننت src\components\Post.jsx
import React from "react"; const Post = ({ post }) => { return ( <article className="list-group-item"> <header> <h2>{post.title}</h2> </header> <p>{post.body}</p> </article> ); }; export default Post;
اتصال کامپوننتهای FetchPosts و Posts به مخزن redux
مرحلهی آخر کار، تامین state کامپوننتهای FetchPosts و Posts از طریق props است. به همین جهت باید دو دربرگیرنده را برای این دو کامپوننت ایجاد کنیم.
الف) ایجاد دربرگیرندهی کامپوننت FetchPosts
برای این منظور فایل جدید src\containers\FetchPosts.js را با محتوای زیر ایجاد میکنیم:
import { connect } from "react-redux"; import { fetchPostsAsync } from "../actions"; import FetchPosts from "../components/FetchPosts"; const mapDispatchToProps = { fetchPostsAsync }; export default connect(null, mapDispatchToProps)(FetchPosts);
- چون اطلاعات state ای قرار نیست به این کامپوننت ارسال شود، تابع mapStateToProps را در اینجا مشاهده نمیکنید و با نال مقدار دهی شدهاست.
ب) ایجاد دربرگیرندهی کامپوننت Posts
برای این منظور فایل جدید src\containers\Posts.js را با محتوای زیر ایجاد میکنیم:
import { connect } from "react-redux"; import Posts from "../components/Posts"; const mapStateToProps = state => { console.log("PostsContainer->mapStateToProps", state); return { ...state.postsReducer }; }; export default connect(mapStateToProps)(Posts);
- کامپوننت Posts رویدادی را سبب نخواهد شد. به همین جهت تابع mapDispatchToProps را در اینجا تعریف و ذکر نکردهایم.
استفاده از کامپوننتهای دربرگیرنده جهت نمایش نهایی کامپوننتهای تحت کنترل Redux
اکنون به فایل src\App.js مراجعه کرده و دو تامین کنندهی فوق را درج میکنیم:
import "./App.css"; import React from "react"; import CounterContainer from "./containers/Counter"; import FetchPostsContainer from "./containers/FetchPosts"; import PostsContainer from "./containers/Posts"; function App() { const prop1 = 123; return ( <main className="container"> <div className="row"> <div className="col"> <CounterContainer prop1={prop1} /> </div> <div className="col"> <FetchPostsContainer /> </div> <div className="col"> <PostsContainer /> </div> </div> </main> ); } export default App;
یک نکته: برای مثال در انتهای کامپوننت FetchPosts، سطر export default FetchPosts را داریم. اگر این سطر را حذف کنیم و بجای آن export default connect فوق را قرار دهیم، دیگر نیازی نخواهد بود تا FetchPostsContainer را از دربرگیرندهها، import کرد و سپس بجای درج المان </FetchPosts> نوشت </FetchPostsContainer>. میتوان همانند قبل از همان نام متداول </FetchPosts> استفاده کرد و import انجام شده نیز همانند سابق از همان فایل ماژول کامپوننت صورت میگیرد. یعنی میتوان پوشهی containers را حذف کرد و کدهای آن را دقیقا ذیل کلاس کامپوننت درج نمود.
کدهای کامل این قسمت را میتوانید از اینجا دریافت کنید: state-management-redux-mobx-part04-backend.zip و state-management-redux-mobx-part04-frontend.zip
استفاده از StructureMap به عنوان یک IoC Container
دریافت StructureMap
برای دریافت آن نیاز است دستور پاورشل ذیل را در کنسول نیوگت ویژوال استودیو فراخوانی کنید:
PM> Install-Package structuremap
آشنایی با ساختار برنامه
ابتدا یک برنامه کنسول را آغاز کرده و سپس یک Class library جدید را به نام Services نیز به آن اضافه کنید. در ادامه کلاسها و اینترفیسهای زیر را به Class library ایجاد شده، اضافه کنید. سپس از طریق نیوگت به روشی که گفته شد، StructureMap را به پروژه اصلی (ونه پروژه Class library) اضافه نمائید و Target framework آنرا نیز در حالت Full قرار دهید بجای حالت Client profile.
namespace DI03.Services { public interface IUsersService { string GetUserEmail(int userId); } } namespace DI03.Services { public interface IEmailsService { void SendEmailToUser(int userId, string subject, string body); } } using System; namespace DI03.Services { public class UsersService : IUsersService { public UsersService() { //هدف صرفا نمایش وهله سازی خودکار این وابستگی است Console.WriteLine("UsersService ctor."); } public string GetUserEmail(int userId) { //برای مثال دریافت از بانک اطلاعاتی و بازگشت یک نمونه جهت آزمایش برنامه return "name@site.com"; } } } using System; namespace DI03.Services { public class EmailsService: IEmailsService { private readonly IUsersService _usersService; public EmailsService(IUsersService usersService) { Console.WriteLine("EmailsService ctor."); _usersService = usersService; } public void SendEmailToUser(int userId, string subject, string body) { var email = _usersService.GetUserEmail(userId); Console.WriteLine("SendEmailTo({0})", email); } } }
سرویس کاربران بر اساس آی دی یک کاربر، برای مثال از بانک اطلاعاتی ایمیل او را بازگشت میدهد. سرویس ارسال ایمیل، نیاز به ایمیل کاربری برای ارسال ایمیلی به او دارد. بنابراین وابستگی مورد نیاز خود را از طریق تزریق وابستگیها در سازنده کلاس و وهله سازی شده در خارج از آن (معکوس سازی کنترل)، دریافت میکند.
در سازندههای هر دو کلاس سرویس نیز از Console.WriteLine استفاده شدهاست تا زمان وهله سازی خودکار آنها را بتوان بهتر مشاهده کرد.
نکته مهمی که در اینجا وجود دارد، بیخبری لایه سرویس از وجود IoC Container مورد استفاده است.
استفاده از لایه سرویس و تزریق وابستگیها به کمک StructureMap
using DI03.Services; using StructureMap; namespace DI03 { class Program { static void Main(string[] args) { // تنظیمات اولیه برنامه که فقط یکبار باید در طول عمر برنامه انجام شود ObjectFactory.Initialize(x => { x.For<IEmailsService>().Use<EmailsService>(); x.For<IUsersService>().Use<UsersService>(); }); //نمونهای از نحوه استفاده از تزریق وابستگیهای خودکار var emailsService = ObjectFactory.GetInstance<IEmailsService>(); emailsService.SendEmailToUser(userId: 1, subject: "Test", body: "Hello!"); } } }
به این ترتیب IoC Container ما زمانیکه قرار است object graph مربوط به IEmailsService درخواستی را تشکیل دهد، خواهد دانست ابتدا به سازندهی کلاس EmailsService میرسد. در اینجا برای وهله سازی این کلاس به صورت خودکار، باید وابستگیهای آنرا نیز وهله سازی کند. بنابراین بر اساس تنظیمات آغازین برنامه میداند که باید از کلاس UsersService برای تزریق خودکار وابستگیها در سازنده کلاس ارسال ایمیل استفاده نماید.
در این حالت اگر برنامه را اجرا کنیم، به خروجی زیر خواهیم رسید:
UsersService ctor. EmailsService ctor. SendEmailTo(name@site.com)
ابتداییترین مزیت استفاده از تزریق وابستگیها امکان تعویض آنها است؛ خصوصا در حین Unit testing. اگر کلاسی برای مثال قرار است با شبکه کار کند، میتوان پیاده سازی آنرا با یک نمونه اصطلاحا Fake جایگزین کرد و در این نمونه تنها نتیجهی کار را بازگشت داد. کلاسهای لایه سرویس ما تنها با اینترفیسها کار میکنند. این تنظیمات قابل تغییر اولیه IoC container مورد استفاده هستند که مشخص میکنند چه کلاسهایی باید در سازندههای کلاسها تزریق شوند.
تعیین طول عمر اشیاء در StructureMap
برای اینکه بتوان طول عمر اشیاء را بهتر توضیح داد، کلاس سرویس کاربران را به نحو زیر تغییر دهید:
using System; namespace DI03.Services { public class UsersService : IUsersService { private int _i; public UsersService() { //هدف صرفا نمایش وهله سازی خودکار این وابستگی است Console.WriteLine("UsersService ctor."); } public string GetUserEmail(int userId) { _i++; Console.WriteLine("i:{0}", _i); //برای مثال دریافت از بانک اطلاعاتی و بازگشت یک نمونه جهت آزمایش برنامه return "name@site.com"; } } }
//نمونهای از نحوه استفاده از تزریق وابستگیهای خودکار var emailsService1 = ObjectFactory.GetInstance<IEmailsService>(); emailsService1.SendEmailToUser(userId: 1, subject: "Test1", body: "Hello!"); var emailsService2 = ObjectFactory.GetInstance<IEmailsService>(); emailsService2.SendEmailToUser(userId: 1, subject: "Test2", body: "Hello!");
UsersService ctor. EmailsService ctor. i:1 SendEmailTo(name@site.com) UsersService ctor. EmailsService ctor. i:1 SendEmailTo(name@site.com)
اگر به هر دلیلی نیاز بود تا این رویه تغییر کند، میتوان بر روی طول عمر اشیاء تشکیل شده نیز تاثیر گذار بود. برای مثال تنظیمات آغازین برنامه را به نحو ذیل تغییر دهید:
// تنظیمات اولیه برنامه که فقط یکبار باید در طول عمر برنامه انجام شود ObjectFactory.Initialize(x => { x.For<IEmailsService>().Use<EmailsService>(); x.For<IUsersService>().Singleton().Use<UsersService>(); });
UsersService ctor. EmailsService ctor. i:1 SendEmailTo(name@site.com) EmailsService ctor. i:2 SendEmailTo(name@site.com)
حالتهای دیگر تعیین طول عمر مطابق متدهای زیر هستند:
Singleton() HttpContextScoped() HybridHttpOrThreadLocalScoped()
در حالت ThreadLocal، به ازای هر Thread، وهلهای متفاوت در اختیار مصرف کننده قرار میگیرد.
حالت Hybrid ترکیبی است از حالتهای HttpContext و ThreadLocal. اگر برنامه وب بود، از HttpContext استفاده خواهد کرد در غیراینصورت به ThreadLocal سوئیچ میکند.
شاید بپرسید که کاربرد مثلا HttpContextScoped در کجا است؟
در یک برنامه وب نیاز است تا یک وهله از DbContext (مثلا Entity framework) را در اختیار کلاسهای مختلف لایه سرویس قرار داد. به این ترتیب چون هربار new Context صورت نمیگیرد، هربار هم اتصال جداگانهای به بانک اطلاعاتی باز نخواهد شد. نتیجه آن رسیدن به یک برنامه سریع، با سربار کم و همچنین کار کردن در یک تراکنش واحد است. چون هربار فراخوانی new Context به معنای ایجاد یک تراکنش جدید است.
همچنین در این برنامه وب قصد نداریم از حالت طول عمر Singleton استفاده کنیم، چون در این حالت یک وهله از Context در اختیار تمام کاربران سایت قرار خواهد گرفت (و DbContext به صورت Thread safe طراحی نشده است). نیاز است به ازای هر کاربر و به ازای طول عمر هر درخواست، تنها یکبار این وهله سازی صورت گیرد. بنابراین در این حالت استفاده از HttpContextScoped توصیه میشود. به این ترتیب در طول عمر کوتاه Object graphهای تشکیل شده، فقط یک وهله از DbContext ایجاد و استفاده خواهد شد که بسیار مقرون به صرفه است.
مزیت دیگر مشخص سازی طول عمر به نحو HttpContextScoped، امکان Dispose خودکار آن به صورت زیر است:
protected void Application_EndRequest(object sender, EventArgs e) { ObjectFactory.ReleaseAndDisposeAllHttpScopedObjects(); }
تنظیمات خودکار اولیه در StructureMap
اگر نام اینترفیسهای شما فقط یک I در ابتدا بیشتر از نام کلاسهای متناظر با آنها دارد، مثلا مانند ITest و کلاس Test هستند؛ فقط کافی است از قراردادهای پیش فرض StructureMap برای اسکن یک یا چند اسمبلی استفاده کنیم:
// تنظیمات اولیه برنامه که فقط یکبار باید در طول عمر برنامه انجام شود ObjectFactory.Initialize(x => { //x.For<IEmailsService>().Use<EmailsService>(); //x.For<IUsersService>().Singleton().Use<UsersService>(); x.Scan(scan => { scan.AssemblyContainingType<IEmailsService>(); scan.WithDefaultConventions(); }); });
دریافت مثال قسمت جاری
DI03.zip
به روز شدهی این مثالها را بر اساس آخرین تغییرات وابستگیهای آنها از مخزن کد ذیل میتوانید دریافت کنید:
Dependency-Injection-Samples
طراحی یک Interceptor برای یک دست سازی ی و ک
در اینجا کدهای کلاس YeKeInterceptor را ملاحظه میکنید. در متدهایی که به کلمهی Executing ختم میشوند، میتوان به دستورات SQL تولید شده توسط EF، پیش از اعمال بر روی بانک اطلاعاتی دسترسی داشت:
public class YeKeInterceptor : IDbCommandInterceptor { public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext) { command.ApplyCorrectYeKe(); } public void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext) { } public void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext) { command.ApplyCorrectYeKe(); } public void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext) { } public void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext) { } public void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext) { command.ApplyCorrectYeKe(); } }
public static class YeKe { public const char ArabicYeChar = (char)1610; public const char PersianYeChar = (char)1740; public const char ArabicKeChar = (char)1603; public const char PersianKeChar = (char)1705; public static string ApplyCorrectYeKe(this object data) { return data == null ? null : ApplyCorrectYeKe(data.ToString()); } public static string ApplyCorrectYeKe(this string data) { return string.IsNullOrWhiteSpace(data) ? string.Empty : data.Replace(ArabicYeChar, PersianYeChar).Replace(ArabicKeChar, PersianKeChar).Trim(); } public static void ApplyCorrectYeKe(this DbCommand command) { command.CommandText = command.CommandText.ApplyCorrectYeKe(); foreach (DbParameter parameter in command.Parameters) { switch (parameter.DbType) { case DbType.AnsiString: case DbType.AnsiStringFixedLength: case DbType.String: case DbType.StringFixedLength: case DbType.Xml: parameter.Value = parameter.Value is DBNull ? parameter.Value : parameter.Value.ApplyCorrectYeKe(); break; } } } }
در آن، CommandText و همچنین parameter.Valueها در صورت رشتهای بودن، اصلاح میشوند.
سربار این روش نسبت به روشهای پیشین استفاده از Reflection کمتر است. همچنین اشیاء پیچیده و تو در تو را نیز بهتر پشتیبانی میکند؛ چون در مرحله Executing، کار پردازش این اشیاء پایان یافته و SQL خام نهایی آن در اختیار ما است.
نحوهی استفاده از YeKeInterceptor
در آغاز برنامه (برای مثال متد Application_Start فایل Global.asax.cs برنامههای MVC )، سطر زیر را فراخوانی کنید:
DbInterception.Add(new YeKeInterceptor());
یک مثال کامل برای دریافت
Sample32.cs