Contact me
بله. من از این طریق امرار معاش میکنم.
خیلی از شرکتها به دنبال تهیه برنامه حاضر و آماده از جایی نیستند و ترجیح میدهند یک یا چند نفر برنامه نویس مستقر داشته باشند تا اینها بتوانند مطابق شرایط و نیازهای روز شرکت، برنامهها را توسعه و تغییر دهند و یا سیستمها را نگهداری کنند.
علت هم این است که برنامه بدون پشتیبانی یعنی برنامه بیمصرف. نیازهای شرکتها گاهی به صوت هفتگی تغییر میکند و این نیازها، وجود یک برنامه نویس مستقر را ضروری میکند.
ایجاد یک پروژهی جدید Blazor WASM
برای پیاده سازی و اجرای مثالهای این قسمت، نیاز به یک پروژهی جدید Blazor WASM را داریم که میتوان آنرا با اجرای دستور dotnet new blazorwasm --hosted در یک پوشهی خالی، ایجاد کرد.
یک نکته: دستور فوق به همراه یک سری پارامتر اختیاری مانند hosted-- نیز هست. برای مشاهدهی لیست آنها دستور dotnet new blazorwasm --help را صادر کنید. برای مثال ذکر پارامتر hosted-- سبب میشود تا یک ASP.NET Core host نیز برای Blazor WebAssembly app ایجاد شده تولید شود.
حالت hosted-- آن یک چنین ساختاری را دارد که از سه پروژه و پوشهی Client ،Server و Shared تشکیل میشود:
در اینجا یک پروژهی خالی WASM ایجاد شده که برخلاف حالت معمولی dotnet new blazorwasm که در قسمت قبل آنرا بررسی کردیم، دیگر از فایل استاتیک wwwroot\sample-data\weather.json در آن خبری نیست. بجای آن، یک پروژهی استاندارد ASP.NET Core Web API را در پوشهی جدید Server ایجاد کرده که کار ارائهی اطلاعات این سرویس آب و هوا را انجام میدهد و برنامهی WASM ایجاد شده، این اطلاعات را توسط HTTP Client خود، از سرور Web API دریافت میکند.
بنابراین اگر مدل برنامهای که قصد دارید تهیه کنید، ترکیبی از یک Web API و WASM است، روش hosted--، آغاز آنرا بسیار ساده میکند.
نکته: روش اجرای این نوع برنامهها با اجرای دستور dotnet run در داخل پوشهی Server پروژه، انجام میشود. با اینکار هم سرور ASP.NET Core آغاز میشود و هم برنامهی WASM توسط آن ارائه میگردد. در این حالت اگر آدرس https://localhost:5001 را در مرورگر باز کنیم، هم قسمتهای بدون نیاز به سرور پروژهی WASM قابل دسترسی است (مانند کار با شمارشگر آن) و هم قسمت دریافت اطلاعات از سرور آن، در منوی Fetch Data.
شروع به کار با Razor
پس از ایجاد یک پروژهی جدید WASM، به فایل Client\Pages\Index.razor آن مراجعه کرده و محتوای پیشفرض آنرا بجز سطر اول زیر، حذف میکنیم:
@page "/"
در فایلهای razor. میتوان ترکیبی از کدهای #C و HTML را نوشت. برای مثال:
@page "/" <p>Hello, @name</p> @code { string name = "Vahid N."; }
یک نکته: با توجه به اینکه تغییرات زیادی را در فایل جاری اعمال خواهیم کرد، بهتر است برنامه را با دستور dotnet watch run اجرا کرد، تا این تغییرات را تحت نظر قرار داده و آنها را به صورت خودکار کامپایل کند. به این صورت دیگر نیازی نخواهد بود به ازای هر تغییر، یکبار دستور dotnet run اجرا شود.
در زمان درج متغیرهای #C در بین کدهای HTML توسط razor، استفاده از تمام متدهای الحاقی زبان #C نیز مجاز هستند؛ مانند:
<p>Hello, @name.ToUpper()</p>
یا حتی میتوان یک متد جدید را مانند CustomToUpper در قطعه کد razor، تعریف کرد و از آن به صورت زیر استفاده نمود:
@page "/" <p>Hello, @name.ToUpper()</p> <p>Hello, @CustomToUpper(name)</p> @code { string name = "Vahid N."; string CustomToUpper(string value) => value.ToUpper(); }
<p>Let's add 2 + 2 : @2 + 2 </p>
<p>Let's add 2 + 2 : @(2 + 2) </p>
<button @onclick="@(()=>Console.WriteLine("Test"))">Click me</button>
در اینجا اگر از Console.WriteLine("Test")@ استفاده میشد، به معنای انتساب یک رشتهی محاسبه شده به رویداد onclick بود که مجاز نیست.
روش دیگر انجام اینکار به صورت زیر است:
@page "/" <button @onclick="@WriteLog">Click me 2</button> @code { void WriteLog() { Console.WriteLine("Test"); } }
@page "/" <button @onclick="@(()=>WriteLogWithParam("Test 3"))">Click me 3</button> @code { void WriteLogWithParam(string value) { Console.WriteLine(value); } }
یک نکته: اگر به اشتباه بجای WriteLogWithParam، همان WriteLog قبلی را بنویسیم، کامپایلر (در حال اجرای توسط دستور dotnet watch run) خطای زیر را نمایش میدهد؛ پیش از اینکه برنامه در مرورگر اجرا شود:
BlazorRazorSample\Client\Pages\Index.razor(12,25): error CS1501: No overload for method 'WriteLog' takes 1 arguments
امکان تعریف کلاسها در فایلهای razor.
در فایلهای razor.، محدود به تعریف یک سری متدها و متغیرهای ساده نیستیم. در اینجا امکان تعریف کلاسها نیز وجود دارد و همچنین میتوان از کلاسهای خارجی (کلاسهایی که خارج از فایل razor جاری تعریف شدهاند) نیز استفاده کرد.
@page "/" <p>Hello, @StringUtils.MyCustomToUpper(name)</p> @code { public class StringUtils { public static string MyCustomToUpper(string value) => value.ToUpper(); } }
البته این کلاس را تنها میتوان داخل همین کامپوننت استفاده کرد. برای اینکه بتوان از امکانات این کلاس، در سایر کامپوننتها نیز استفاده کرد، میتوان آنرا در پروژهی Shared قرار داد. اگر به تصویر ابتدای مطلب جاری دقت کنید، سه پروژه ایجاد شدهاست:
الف) پروژهی کلاینت: که همان WASM است.
ب) پروژهی سرور: که یک پروژهی ASP.NET Core Web API ارائه کنندهی سرویس و API آب و هوا است و همچنین هاست کنندهی WASM ما.
ج) پروژهی Shared: کدهای این پروژه، بین هر دو پروژه به اشتراک گذاشته میشوند و برای مثال محل مناسبی است برای تعریف DTO ها. برای نمونه WeatherForecast.cs قرار گرفتهی در آن، DTO یا data transfer object سرویس API برنامه است که قرار است به کلاینت بازگشت داده شود. به این ترتیب دیگر نیازی نخواهد بود تا این تعاریف را در پروژههای سرور و کلاینت تکرار کنیم و میتوان کدهای اینگونه را به اشتراک گذاشت.
کاربرد دیگر آن تعریف کلاسهای کمکی است؛ مانند StringUtils فوق. به همین به پروژهی Shared مراجعه کرده و کلاس StringUtils را به صورت زیر در آن تعریف میکنیم (و یا حتی میتوان این قطعه کد را داخل یک پوشهی جدید، در همان پروژهی WASM نیز قرار داد):
namespace BlazorRazorSample.Shared { public class StringUtils { public static string MyNewCustomToUpper(string value) => value.ToUpper(); } }
پس از آن روش استفادهی از این کلاس کمکی خارجی اشتراکی به صورت زیر است:
@page "/" @using BlazorRazorSample.Shared <p>Hello, @StringUtils.MyNewCustomToUpper(name)</p>
یک نکته: میتوان به فایل Client\_Imports.razor مراجعه و مدخل زیر را به انتهای آن اضافه کرد:
@using BlazorRazorSample.Shared
کار با حلقهها در فایلهای razor.
همانطور که عنوان شد، یکی از کاربردهای پروژهی Shared، امکان به اشتراک گذاشتن مدلها، در برنامههای کلاینت و سرور است. برای مثال یک پوشهی جدید Models را در این پروژه ایجاد کرده و کلاس MovieDto را به صورت زیر در آن تعریف میکنیم:
using System; namespace BlazorRazorSample.Shared.Models { public class MovieDto { public string Title { set; get; } public DateTime ReleaseDate { set; get; } } }
@using BlazorRazorSample.Shared.Models
@page "/" <div> <h3>Movies</h3> @foreach(var movie in movies) { <p>Title: <b>@movie.Title</b></p> <p>ReleaseDate: @movie.ReleaseDate.ToString("dd MMM yyyy")</p> } </div> @code { List<MovieDto> movies = new List<MovieDto> { new MovieDto { Title = "Movie 1", ReleaseDate = DateTime.Now.AddYears(-1) }, new MovieDto { Title = "Movie 2", ReleaseDate = DateTime.Now.AddYears(-2) }, new MovieDto { Title = "Movie 3", ReleaseDate = DateTime.Now.AddYears(-3) } }; }
یک نکته: در حین تعریف فیلدهای code@، امکان استفادهی از var وجود ندارد؛ مگر اینکه از آن بخواهیم در داخل بدنهی یک متد استفاده کنیم.
و یا نمونهی دیگری از حلقههای #C مانند for را میتوان به صورت زیر تعریف کرد:
@for(var i = 0; i < movies.Count; i++) { <div style="background-color: @(i % 2 == 0 ? "blue" : "red")"> <p>Title: <b>@movies[i].Title</b></p> <p>ReleaseDate: @movies[i].ReleaseDate.ToString("dd MMM yyyy")</p> </div> }
نمایش شرطی عبارات در فایلهای razor.
اگر به مثال توکار Client\Pages\FetchData.razor مراجعه کنیم (مربوط به حالت host-- که در ابتدای مطلب عنوان شد)، کدهای زیر قابل مشاهده هستند:
@page "/fetchdata" @using BlazorRazorSample.Shared @inject HttpClient Http <h1>Weather forecast</h1> <p>This component demonstrates fetching data from the server.</p> @if (forecasts == null) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @foreach (var forecast in forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } @code { private WeatherForecast[] forecasts; protected override async Task OnInitializedAsync() { forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast"); } }
برای رفع این مشکل، ابتدا یک if@ مشاهده میشود، تا نال بودن forecasts را بررسی کند:
@if (forecasts == null) { <p><em>Loading...</em></p> }
روش نمایش عبارات HTML در فایلهای razor.
فرض کنید عنوان اول فیلم مثال جاری، به همراه یک تگ HTML هم هست:
new MovieDto { Title = "<i>Movie 1</i>", ReleaseDate = DateTime.Now.AddYears(-1) },
<p>Title: <b>@((MarkupString)movie.Title)</b></p>
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-03.zip
برای اجرای آن وارد پوشهی Server شده و دستور dotnet run را اجرا کنید.
ASP.NET MVC #19
مروری بر امکانات Caching اطلاعات در ASP.NET MVC
در برنامههای وب، بالاترین حد کارآیی برنامهها از طریق بهینه سازی الگوریتمها حاصل نمیشود، بلکه با بکارگیری امکانات Caching سبب خواهیم شد تا اصلا کدی اجرا نشود. در ASP.NET MVC این هدف از طریق بکارگیری فیلتری به نام OutputCache میسر میگردد:
using System.Web.Mvc;
namespace MvcApplication16.Controllers
{
public class HomeController : Controller
{
[OutputCache(Duration = 60, VaryByParam = "none")]
public ActionResult Index()
{
return View();
}
}
}
همانطور که ملاحظه میکنید، OutputCache را به یک اکشن متد یا حتی به یک کنترلر نیز میتوان اعمال کرد. به این ترتیب HTML نهایی حاصل از View متناظر با اکشن متد جاری فراخوانی شده، Cache خواهد شد. سپس زمانیکه درخواست بعدی به سرور ارسال میشود، نتیجه دریافت شده، همان اطلاعات Cache شده قبلی است و عملا در سمت سرور کدی اجرا نخواهد شد. در اینجا توسط پارامتر Duration، مدت زمان معتبر بودن کش حاصل، برحسب ثانیه مشخص میشود. VaryByParam مشخص میکند که اگر متدی پارامتری را دریافت میکند، آیا باید به ازای هر مقدار دریافتی، مقادیر کش شده متفاوتی ذخیره شوند یا خیر. در اینجا چون متد Index پارامتری ندارد، از مقدار none استفاده شده است.
مثال یک
یک پروژه جدید خالی ASP.NET MVC را آغاز کنید. سپس کنترلر جدید Home را نیز به آن اضافه نمائید:
using System;
using System.Web.Mvc;
namespace MvcApplication16.Controllers
{
public class HomeController : Controller
{
[OutputCache(Duration = 60, VaryByParam = "none")]
public ActionResult Index()
{
ViewBag.ControllerTime = DateTime.Now;
return View();
}
}
}
همچنین کدهای View متد Index را نیز به نحو زیر تغییر دهید:
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
<p>@ViewBag.ControllerTime</p>
<p>@DateTime.Now</p>
در اینجا نمایش دو زمان دریافتی از کنترلر و زمان محاسبه شده در View را مشاهده میکنید. هدف این است که بررسی کنیم آیا فیلتر OutputCache بر روی این دو مقدار تاثیری دارد یا خیر.
برنامه را اجرا نمائید. سپس چند بار صفحه را Refresh کنید. مشاهده خواهید کرد که هر دو زمان یاد شده تا 60 ثانیه، تغییری نخواهند کرد و حاصل نهایی از Cache خواهنده میشود.
کاربرد یک چنین حالتی برای مثال نمایش اطلاعات بازدیدهای یک سایت است. نباید به ازای هر کاربر وارد شده به سایت، یکبار به بانک اطلاعاتی مراجعه کرد و آمار جدیدی را تهیه نمود. یا برای نمونه اگر جایی قرار است اطلاعات وضعیت آب و هوا نمایش داده شود، بهتر است این اطلاعات، مثلا هر نیم ساعت یکبار به روز شود و نه به ازای هر بازدید جدید از سایت، توسط صدها بازدید کننده همزمان. یا برای مثال کش کردن خروجی فید RSS یک بلاگ به مدت چند ساعت نیز ایده خوبی است. از این لحاظ که اگر اطلاعات بلاگ شما روزی یکبار به روز میشود، نیازی نیست تا به ازای هر برنامه فیدخوان، یکبار اطلاعات از بانک اطلاعاتی دریافت شده و پروسه رندر نهایی فید صورت گیرد. منوهای پویای یک سایت نیز در همین رده قرار میگیرند. دریافت اطلاعات منوهای پویای سایت به ازای هر درخواست رسیده کاربری جدید، کار اشتباهی است. این اطلاعات نیز باید کش شوند تا بار سرور کاهش یابد. البته تمام اینها زمانی میسر خواهند شد که اطلاعات سمت سرور کش شوند.
مثال دو
همان مثال قبلی را در اینجا جهت بررسی پارامتر VaryByParam به نحو زیر تغییر میدهیم:
using System;
using System.Web.Mvc;
namespace MvcApplication16.Controllers
{
public class HomeController : Controller
{
[OutputCache(Duration = 60, VaryByParam = "none")]
public ActionResult Index(string parameter)
{
ViewBag.Msg = parameter ?? string.Empty;
ViewBag.ControllerTime = DateTime.Now;
return View();
}
}
}
در اینجا یک پارامتر به متد Index اضافه شده است. مقدار آن به ViewBag.Msg انتساب داده شده و سپس در View ، در بین تگهای h2 نمایش داده خواهد شد. همچنین یک فرم ساده هم جهت ارسال parameter به متد Index اضافه شده است:
@{
ViewBag.Title = "Index";
}
<h2>@ViewBag.Msg</h2>
<p>@ViewBag.ControllerTime</p>
<p>@DateTime.Now</p>
@using (Html.BeginForm())
{
@Html.TextBox("parameter")
<input type="submit" />
}
اکنون برنامه را اجرا کنید. در TextBox نمایش داده شده یکبار مثلا بنویسید Test1 و فرم را به سرور ارسال نمائید. سپس مقدار Test2 را وارد کرده و ارسال نمائید. در بار دوم، خروجی صفحه همانند زمانی است که مقدار Test1 ارسال شده است. علت این است که مقدار VaryByParam به none تنظیم شده است و صرفنظر از ورودی کاربر، همان اطلاعات کش شده قبلی بازگشت داده خواهد شد. برای رفع این مشکل، متد Index را به نحو زیر تغییر دهید، به طوریکه مقدار VaryByParam به نام پارامتر متد جاری اشاره کند:
[OutputCache(Duration = 60, VaryByParam = "parameter")]
public ActionResult Index(string parameter)
در ادامه مجددا برنامه را اجرا کنید. اکنون یکبار مقدار Test1 را به سرور ارسال کنید. سپس مقدار Test2 را ارسال نمائید. مجددا همین دو مرحله را با مقادیر Test1 و Test2 تکرار کنید. مشاهده خواهید کرد که اینبار اطلاعات بر اساس مقدار پارامتر ارسالی کش شده است.
تنظیمات متفاوت OutputCache
الف) VaryByParam : اگر مساوی none قرار گیرد، همواره همان مقدار کش شده قبلی نمایش داده میشود. اگر مقدار آن به نام پارامتر خاصی تنظیم شود، اطلاعات کش شده بر اساس مقادیر متفاوت پارامتر دریافتی، متفاوت خواهند بود. در اینجا پارامترهای متفاوت را با یک «,» میتوان از هم جدا ساخت. اگر تعداد پارامترها زیاد است میتوان مقدار VaryByParam را مساوی با * قرار داد. در این حالت به ازای مقادیر متفاوت دریافتی پارامترهای مختلف، اطلاعات مجزایی در کش قرار خواهد گرفت. این روش آخر آنچنان توصیه نمیشود چون سربار بالایی دارد و حجم بالایی از اطلاعات بر اساس پارامترهای مختلف، باید در کش قرار گیرند.
ب) Location : مکان قرارگیری اطلاعات کش شده را مشخص میکند. مقدار آن نیز بر اساس یک enum به نام OutputCacheLocation مشخص میگردد. در این حالت برای مثال میتوان مکانهای Server، Client و ServerAndClient را مقدار دهی نمود. مقدار Downstream به معنای کش شدن اطلاعات بر روی پروکسی سرورهای بین راه و یا مرورگرها است. پیش فرض آن Any است که ترکیبی از Server و Downstream میباشد.
اگر قرار است اطلاعات یکسانی به تمام کاربران نمایش داده شود، مثلا محتوای لیست یک منوی پویا، محل قرارگیری اطلاعات کش باید سمت سرور باشد. اگر نیاز است به ازای هر کاربر محتوای اطلاعات کش شده متفاوت باشد، بهتر است محل سمت کلاینت را مقدار دهی نمود.
ج) VaryByHeader : اطلاعات، بر اساس هدرهای مشخص شده، کش میشوند. برای مثال مرسوم است که از Accept-Language در اینجا استفاده شود تا اطلاعات مثلا فرانسوی کش شده، به کاربر آلمانی تحویل داده نشود.
د) VaryByCustom : در این حالت نام یک متد استاتیک تعریف شده در فایل global.asax.cs باید مشخص گردد. توسط این متد کلید رشتهای اطلاعاتی که قرار است کش شود، بازگشت داده خواهد شد.
ه) SqlDependency : در این حالت اطلاعات تا زمانیکه تغییری در جداول بانک اطلاعاتی SQL Server صورت نگیرد، کش خواهد شد.
و) Nostore : به پروکسی سرورهای بین راه و همچنین مرورگرها اطلاع میدهد که اطلاعات را نباید کش کنند. اگر قسمت اعتبار سنجی این سری را به خاطر داشته باشید، چنین تعریفی در قسمت Remote validation بکارگرفته شد:
[OutputCache(Location = OutputCacheLocation.None, NoStore = true)]
و یا میتوان برای اینکار یک فیلتر سفارشی را نیز تهیه کرد:
using System;
using System.Web.Mvc;
namespace MvcApplication16.Helper
{
/// <summary>
/// Adds "Cache-Control: private, max-age=0" header,
/// ensuring that the responses are not cached by the user's browser.
/// </summary>
public class NoCachingAttribute : ActionFilterAttribute
{
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
base.OnActionExecuted(filterContext);
filterContext.HttpContext.Response.CacheControl = "private";
filterContext.HttpContext.Response.Cache.SetMaxAge(TimeSpan.FromSeconds(0));
}
}
}
کار این فیلتر اضافه کردن هدر «Cache-Control: private, max-age=0» به Response است.
استفاده از فایل Web.Config برای معرفی تنظیمات Caching
یکی دیگر از تنظیمات ویژگی OutputCache، پارامتر CacheProfile است که امکان تنظیم آن در فایل web.config نیز وجود دارد. برای نمونه تنظیمات زیر را به قسمت system.web فایل وب کانفیگ برنامه اضافه کنید:
<system.web>
<caching>
<outputCacheSettings>
<outputCacheProfiles>
<add name="Aggressive" location="ServerAndClient" duration="300"/>
<add name="Mild" duration="100" location="Server" />
</outputCacheProfiles>
</outputCacheSettings>
</caching>
سپس مثلا برای استفاده از پروفایلی به نام Aggressive، خواهیم داشت:
[OutputCache(CacheProfile = "Aggressive", VaryByParam = "parameter")]
public ActionResult Index(string parameter)
استفاده از ویژگی به نام donut caching
تا اینجا به این نتیجه رسیدیم که OutputCache، کل خروجی یک View را بر اساس پارامترهای مختلفی که دریافت میکند، کش خواهد کرد. در این بین اگر بخواهیم تنها قسمت کوچکی از صفحه کش نشود چه باید کرد؟ برای حل این مشکل قابلیتی به نام cache substitution که به donut caching هم معروف است (چون آنرا میتوان به شکل یک donut تصور کرد!) در ASP.NET MVC قابل استفاده است.
@{ Response.WriteSubstitution(ctx => DateTime.Now.ToShortTimeString()); }
همانطور که ملاحظه میکنید برای تعریف یک چنین اطلاعاتی باید از متد Response.WriteSubstitution در یک view استفاده کرد. در این مثال، نمایش زمان جاری معرفی شده، صرف نظر از وضعیت کش صفحه جاری، کش نخواهد شد.
عکس آن هم ممکن است. فرض کنید که صفحه جاری شما از سه partial view تشکیل شده است. هر کدام از این partial viewها نیز مزین به OutpuCache هستند. اما صفحه اصلی درج کننده اطلاعات این سه partial view فاقد ویژگی Output کش است. در این حالت تنها اطلاعات این partial viewها کش خواهند شد و سایر قسمتهای صفحه با هر بار درخواست از سرور، مجددا بر اساس اطلاعات جدید به روز خواهند شد. حالت توصیه شده نیز همین مورد است و متد Response.WriteSubstitution را صرفا جهت اطلاعات عمومی درنظر داشته باشید.
استفاده از امکانات Data Caching به صورت مستقیم
مطالبی که تا اینجا عنوان شدند به کش کردن اطلاعات Response اختصاص داشتند. اما امکانات Caching موجود، به این مورد خلاصه نشده و میتوان اطلاعات و اشیاء را نیز کش کرد. برای مثال اطلاعات «با سطح دسترسی عمومی» دریافتی از بانک اطلاعاتی توسط یک کوئری را نیز میتوان کش کرد. جهت انجام اینکار میتوان از متدهای HttpRuntime.Cache.Insert و یا HttpContext.Cache.Insert استفاده کرد. استفاده از HttpContext.Cache.Insert حین نوشتن Unit tests دردسر کمتری دارد و mocking آن ساده است؛ از این جهت که بر اساس HttpContextBase تعریف شدهاست.
در ادامه یک کلاس کمکی نوشتن اطلاعات در cache و سپس بازیابی آنرا ملاحظه میکنید:
using System;
using System.Web;
using System.Web.Caching;
namespace MvcApplication16.Helper
{
public static class CacheManager
{
public static void CacheInsert(this HttpContextBase httpContext, string key, object data, int durationMinutes)
{
if (data == null) return;
httpContext.Cache.Add(
key,
data,
null,
DateTime.Now.AddMinutes(durationMinutes),
TimeSpan.Zero,
CacheItemPriority.AboveNormal,
null);
}
public static T CacheRead<T>(this HttpContextBase httpContext, string key)
{
var data = httpContext.Cache[key];
if (data != null)
return (T)data;
return default(T);
}
public static void InvalidateCache(this HttpContextBase httpContext, string key)
{
httpContext.Cache.Remove(key);
}
}
}
و برای استفاده از آن در یک اکشن متد، ابتدا نیاز است فضای نام این کلاس تعریف شود و سپس برای نمونه متد HttpContext.CacheInsert در دسترس خواهد بود. HttpContext یکی از خواص تعریف شده در شیء کنترلر است که با ارث بری کنترلرها از آن، همواره در دسترس میباشد.
در اینجا برای نمونه اطلاعات یک لیست جنریک دریافتی از بانک اطلاعاتی را مثلا 10 دقیقه (بسته به پارامتر durationMinutes آن) میتوان کش کرد و سپس توسط متد CacheRead آنرا دریافت نمود. اگر متد CacheRead نال برگرداند به معنای خالی بودن کش است. بنابراین یکبار اطلاعات را از بانک اطلاعاتی دریافت نموده و سپس آنرا کش خواهیم کردیم.
البته هستند ORMهایی که یک چنین کارهایی را به صورت توکار پشتیبانی کنند. به مکانیزم آن، Second level cache هم گفته میشود؛ به علاوه امکان استفاده از پروایدرهای دیگری را بجز کش IIS برای ذخیره سازی موقتی اطلاعات نیز فراهم میکنند.
همچنین باید دقت داشت این اعداد مدت زمان، هیچگونه ضمانتی ندارند. اگر IIS احساس کند که با کمبود منابع مواجه شده است، به سادگی شروع به حذف اطلاعات موجود در کش خواهد کرد.
نکته امنیتی مهم!
به هیچ عنوان از OutputCache در صفحاتی که نیاز به اعتبار سنجی دارند، استفاده نکنید و به همین جهت در قسمت کش کردن اطلاعات، بر روی «اطلاعاتی با سطح دسترسی عمومی» تاکید شد.
فرض کنید کارمندی به صفحه مشاهده فیش حقوقی خودش مراجعه کرده است. این ماه هم اضافه حقوق آنچنانی داشته است. شما هم این صفحه را به مدت سه ساعت کش کردهاید. آیا میتوانید تصور کنید اگر همین گزارش کش شده با این اطلاعات، به سایر کارمندان نمایش داده شود چه قشقرقی به پا خواهد شد؟!
بنابراین هیچگاه اطلاعات مخصوص به یک کاربر اعتبار سنجی شده را کش نکنید و «تنها» اطلاعاتی نیاز به کش شدن دارند که عمومی باشند. برای مثال لیست آخرین اخبار سایت؛ لیست آخرین مدخلهای فید RSS سایت؛ لیست اطلاعات منوی عمومی سایت؛ لیست تعداد کاربران مراجعه کننده به سایت در طول یک روز؛ گزارش آب و هوا و کلیه اطلاعاتی با سطح دسترسی عمومی که کش شدن آنها مشکل ساز نباشد.
به صورت خلاصه هیچگاه در کدهای شما چنین تعریفی نباید مشاهده شود:
[Authorize]
[OutputCache(Duration = 60)]
public ActionResult Index()
جدول AppUserClaims
جدول AppUserClaims، جزو جداول اصلی ASP.NET Core Identity است و هدف آن ذخیرهی اطلاعات ویژهی کاربران و بازیابی سادهتر آنها از طریق کوکیهای آنها است (همانند User.Identity.Name). زمانیکه کاربری به سیستم وارد میشود، بر اساس UserId او، تمام رکوردهای User Claims متعلق به او از این جدول واکشی شده و به صورت خودکار به کوکی او اضافه میشوند.
در پروژهی DNT Identity از این جدول استفاده نمیشود. چون اطلاعات User Claims مورد نیاز آن، هم اکنون در جدول AppUsers موجود هستند. به همین جهت افزودن این نوع User Claimها به جدول AppUserClaims، به ازای هر کاربر، کاری بیهوده است. سناریویی که استفادهی از این جدول را با مفهوم میکند، ذخیره سازی تنظیمات ویژهی هرکاربر است (خارج از فیلدهای جدول کاربران). برای مثال اگر سایتی را چندزبانه طراحی کردید، میتوانید یک User Claim سفارشی جدید را برای این منظور ایجاد و زبان انتخابی کاربر را به عنوان یک رکورد جدید مخصوص آن در این جدول ویژه ثبت کنید. مزیت آن این است که واکشی و افزوده شدن اطلاعات آن به کوکی شخص، به صورت خودکار توسط فریم ورک صورت گرفته و در حین مرور صفحات توسط کاربر، دیگر نیازی نیست تا اطلاعات زبان انتخابی او را از بانک اطلاعاتی واکشی کرد.
بنابراین برای ذخیره سازی تنظیمات با کارآیی بالای ویژهی هرکاربر، جدول جدیدی را ایجاد نکنید. جدول User Claim برای همین منظور درنظر گرفته شدهاست و پردازش اطلاعات آن توسط فریم ورک صورت میگیرد.
ASP.NET Core Identity چگونه اطلاعات جدول AppUserClaims را پردازش میکند؟
ASP.NET Identity Core در حین لاگین کاربر به سیستم، از سرویس SignInManager خودش استفاده میکند که با نحوهی سفارشی سازی آن پیشتر در قسمت دوم این سری آشنا شدیم. سرویس SignInManager پس از لاگین شخص، از یک سرویس توکار دیگر این فریم ورک به نام UserClaimsPrincipalFactory جهت واکشی اطلاعات User Claims و همچنین Role Claims و افزودن آنها به کوکی رمزنگاری شدهی شخص، استفاده میکند.
بنابراین اگر قصد افزودن User Claim سفارشی دیگری را داشته باشیم، میتوان همین سرویس توکار UserClaimsPrincipalFactory را سفارشی سازی کرد (بجای اینکه الزاما رکوردی را به جدول AppUserClaims اضافه کنیم).
اطلاعات جالبی را هم میتوان از پیاده سازی متد CreateAsync آن استخراج کرد:
public virtual async Task<ClaimsPrincipal> CreateAsync(TUser user)
2) userName شخص پس از لاگین از طریق User Claims ایی با نوع Options.ClaimsIdentity.UserNameClaimType به کوکی او اضافه میشود.
3) security stamp او (آخرین بار تغییر اطلاعات اکانت کاربر) نیز یک Claim پیشفرض است.
4) اگر نقشهایی به کاربر انتساب داده شده باشند، تمام این نقشها واکشی شده و به عنوان یک Claim جدید به کوکی او اضافه میشوند.
5) اگر یک نقش منتسب به کاربر دارای Role Claim باشد، این موارد نیز واکشی شده و به کوکی او به عنوان یک Claim جدید اضافه میشوند. در ASP.NET Identity Core نقشها نیز میتوانند Claim داشته باشند (امکان پیاده سازی سطوح دسترسی پویا).
بنابراین حداقل مدیریت Claims این 5 مورد خودکار است و اگر برای مثال نیاز به Id کاربر لاگین شده را داشتید، نیازی نیست تا آنرا از بانک اطلاعاتی واکشی کنید. چون این اطلاعات هم اکنون در کوکی او موجود هستند.
سفارشی سازی کلاس UserClaimsPrincipalFactory جهت افزودن User Claims سفارشی
تا اینجا دریافتیم که کلاس UserClaimsPrincipalFactory کار مدیریت Claims پیشفرض این فریم ورک را برعهده دارد. در ادامه از این کلاس ارث بری کرده و متد CreateAsync آنرا جهت افزودن Claims سفارشی خود بازنویسی میکنیم. این پیاده سازی سفارشی را در کلاس ApplicationClaimsPrincipalFactory میتوانید مشاهده کنید:
public override async Task<ClaimsPrincipal> CreateAsync(User user) { var principal = await base.CreateAsync(user).ConfigureAwait(false); addCustomClaims(user, principal); return principal; } private static void addCustomClaims(User user, IPrincipal principal) { ((ClaimsIdentity) principal.Identity).AddClaims(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), ClaimValueTypes.Integer), new Claim(ClaimTypes.GivenName, user.FirstName ?? string.Empty), new Claim(ClaimTypes.Surname, user.LastName ?? string.Empty), new Claim(PhotoFileName, user.PhotoFileName ?? string.Empty, ClaimValueTypes.String), }); }
برای مثال نام، نام خانوادگی و نام تصویر شخص به صورت Claimهایی جدید به کوکی او اضافه میشوند. در این حالت دیگر نیازی نیست تا به ازای هر کاربر، جدول AppUserClaims را ویرایش کرد و اطلاعات جدیدی را افزود و یا تغییر داد. همینقدر که کاربر به سیستم لاگین کند، شیء User او به متد Create کلاس UserClaimsPrincipalFactory ارسال میشود. به این ترتیب میتوان به تمام خواص این کاربر دسترسی یافت و در صورت نیاز آنها را به صورت Claimهایی به کوکی او افزود.
پس از تدارک کلاس ApplicationClaimsPrincipalFactory، تنها کاری را که باید در جهت معرفی و جایگرینی آن انجام داد، تغییر ذیل در کلاس IdentityServicesRegistry است:
services.AddScoped<IUserClaimsPrincipalFactory<User>, ApplicationClaimsPrincipalFactory>(); services.AddScoped<UserClaimsPrincipalFactory<User, Role>, ApplicationClaimsPrincipalFactory>();
چگونه به اطلاعات User Claims در سرویسهای برنامه دسترسی پیدا کنیم؟
برای دسترسی به اطلاعات User Claims نیاز به دسترسی به HttpContext جاری را داریم. در این مورد و تزریق سرویس IHttpContextAccessor جهت تامین آن، در مطلب «بررسی روش دسترسی به HttpContext در ASP.NET Core» پیشتر بحث شدهاست.
به علاوه در کلاس IdentityServicesRegistry، تزریق وابستگیهای سفارشیتری نیز صورت گرفتهاست:
services.AddScoped<IPrincipal>(provider => provider.GetService<IHttpContextAccessor>()?.HttpContext?.User ?? ClaimsPrincipal.Current);
چگونه اطلاعات User Claims سفارشی را دریافت کنیم؟
برای کار سادهتر با Claims یک کلاس کمکی به نام IdentityExtensions به پروژه اضافه شدهاست و متدهایی مانند دو متد ذیل را میتوانید در آن مشاهده کنید:
public static string FindFirstValue(this ClaimsIdentity identity, string claimType) { return identity?.FindFirst(claimType)?.Value; } public static string GetUserClaimValue(this IIdentity identity, string claimType) { var identity1 = identity as ClaimsIdentity; return identity1?.FindFirstValue(claimType); }
برای نمونه متد GetUserDisplayName این کلاس کمکی، از همان Claims سفارشی که در کلاس ApplicationClaimsPrincipalFactory تعریف کردیم، اطلاعات خود را استخراج میکند و اگر در View ایی خواستید این اطلاعات را نمایش دهید، میتوانید بنویسید:
@User.Identity.GetUserDisplayName()
چگونه پس از ویرایش اطلاعات کاربر، اطلاعات کوکی او را نیز به روز کنیم؟
در پروژه قسمتی وجود دارد جهت ویرایش اطلاعات کاربران (UserProfileController). اگر کاربری برای مثال نام و نام خانوادگی خود را ویرایش کرد، میخواهیم بلافاصله متد GetUserDisplayName اطلاعات صحیح و به روزی را از کوکی او دریافت کند. برای اینکار یا میتوان او را وادار به لاگین مجدد کرد (تا پروسهی رسیدن به متد CreateAsync کلاس ApplicationClaimsPrincipalFactory طی شود) و یا روش بهتری نیز وجود دارد:
// reflect the changes, in the current user's Identity cookie await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false);
کدهای کامل این سری را در مخزن کد DNT Identity میتوانید ملاحظه کنید.
شروع به کار با NBench
برای شروع به کار با NBench، ابتدا نیاز است دو بستهی نیوگت ذیل را نصب کرد:
PM> Install-Package NBench PM> Install-Package NBench.Runner
[PerfBenchmark(RunMode = RunMode.Iterations, TestMode = TestMode.Measurement)] [MemoryMeasurement(MemoryMetric.TotalBytesAllocated)] public void AddMemoryMeasurement() { const int numberOfAdds = 1000000; var dictionary = new Dictionary<int, int>(); for (var i = 0; i < numberOfAdds; i++) { dictionary.Add(i, i); } } [PerfBenchmark(RunMode = RunMode.Iterations, TestMode = TestMode.Measurement)] [GcMeasurement(GcMetric.TotalCollections, GcGeneration.AllGc)] public void MeasureGarbageCollections() { var dataCache = new List<int[]>(); for (var i = 0; i < 500; i++) { for (var j = 0; j < 10000; j++) { var data = new int[100]; dataCache.Add(data.ToArray()); } dataCache.Clear(); } }
سپس هر متد تست به ویژگی PerfBenchmark مزین میشود. در اینجا RunMode.Iterations به این معنا است که خودمان قصد داریم در طی یک حلقه، تعداد بار انجام را مشخص کنیم.
ویژگی MemoryMeasurement برای اندازه گیری حافظهی مصرفی یک قطعه کد و GcMeasurement برای اندازه گیری فشار بر روی Garbage collector بکار میرود.
اجرای آزمونهای NBench
پس از تهیهی دو متد فوق، به پوشهی packages\NBench.Runner.0.3.4\lib\net45 مراجعه کنید. یک فایل exe در آن موجود است که کار یافتن و اجرای آزمونهای NBench را انجام میدهد. به عنوان پارامتر آن تنها کافی است مسیر اسمبلی برنامه (فایل exe و یا dll) را به آن ارسال کنیم:
D:\Prog\NBenchSample\packages\NBench.Runner.0.3.4\lib\net45\NBench.Runner.exe "D:\Prog\NBenchSample\NBenchSample\bin\Release\NBenchSample.exe"
--------------- RESULTS: NBenchSample.Program+AddMemoryMeasurement --------------- TotalBytesAllocated: Max: 47,842,944.00 bytes, Average: 42,002,757.60 bytes, Min: 41,353,848.00 bytes, StdDev: 2,052,032.33 bytes TotalBytesAllocated: Max / s: 359,074,078.19 bytes, Average / s: 311,474,786.96 bytes, Min / s: 300,926,928.79 bytes, StdDev / s: 16,869,581.62 bytes --------------- RESULTS: NBenchSample.Program+MeasureGarbageCollections --------------- TotalCollections [Gen0]: Max: 708.00 collections, Average: 702.80 collections, Min: 697.00 collections, StdDev: 3.65 collections TotalCollections [Gen0]: Max / s: 111.55 collections, Average / s: 109.87 collections, Min / s: 107.88 collections, StdDev / s: 1.28 collections TotalCollections [Gen1]: Max: 338.00 collections, Average: 334.60 collections, Min: 330.00 collections, StdDev: 2.41 collections TotalCollections [Gen1]: Max / s: 53.61 collections, Average / s: 52.31 collections, Min / s: 51.10 collections, StdDev / s: 0.70 collections TotalCollections [Gen2]: Max: 32.00 collections, Average: 24.80 collections, Min: 18.00 collections, StdDev: 4.73 collections TotalCollections [Gen2]: Max / s: 4.91 collections, Average / s: 3.87 collections, Min / s: 2.86 collections, StdDev / s: 0.72 collections
نکتهای در مورد اندازه گیری فشار حافظه
حافظه توسط سیستم عامل، به صورت صفحات تخصیص داده میشود. برای مثال اگر شما به 12 بایت نیاز داشته باشید، سیستم عامل ممکن است 8 کیلوبایت را جهت کاهش تعداد بار تخصیصهای حافظه و بالا بردن سرعت کار، در اختیار برنامه قرار دهد. بنابراین جهت رسیدن به بهترین نتیجه، در اینجا بهتر است تعداد زیادی شیء را مورد آزمایش قرار داد. برای مثال در آزمایش فوق بجای افزودن یک آیتم به دیکشنری، افزودن میلیونها شیء، نویز استراتژی تخصیص حافظهی توسط سیستم عامل را به حداقل میرساند.
شبیه به همین استراتژی، در پیاده سازی Dictionary نیز بکارگرفته شدهاست:
[PerfBenchmark(RunMode = RunMode.Iterations, TestMode = TestMode.Measurement)] [MemoryMeasurement(MemoryMetric.TotalBytesAllocated)] public void AddMemoryMeasurement_With_initial_Size() { const int numberOfAdds = 1000000; var dictionary = new Dictionary<int, int>(numberOfAdds); for (var i = 0; i < numberOfAdds; i++) { dictionary.Add(i, i); } }
--------------- RESULTS: NBenchSample.Program+AddMemoryMeasurement_With_initial_Size --------------- TotalBytesAllocated: Max: 23,245,912.00 bytes, Average: 23,245,912.00 bytes, Min: 23,245,912.00 bytes, StdDev: 0.00 bytes TotalBytesAllocated: Max / s: 394,032,435.34 bytes, Average / s: 389,108,363.43 bytes, Min / s: 378,502,981.34 bytes, StdDev / s: 5,575,519.09 bytes
علت اینجا است که دیکشنری در پشت صحنه، از یک متد ReSize استفاده میکند که شبیه به سیستم عامل، بیشتر از مقدار مورد نیاز جهت ذخیرهی اشیاء، برای کاهش تعداد بار تخصیصهای حافظه، حافظه به خود اختصاص میدهد. به همین جهت زمانیکه اندازهی اولیه را مشخص کردهایم، کار تخصیص حافظهی بیش از اندازهی این شیء، به شدت کاهش یافتهاست.
بررسی متد MeasureGarbageCollections
در متد MeasureGarbageCollections، مقدار زیادی شیء بر روی heap ایجاد شده و GC را وادار به عکس العمل شدید میکند.
حلقهی داخلی ایجاد شده نیز تعداد زیادی شیء را در جهت پاکسازی GC تخصیص میدهد. این پاکسازی در مرحلهای به نام generation 0 صورت میگیرد.
اشیاء اضافه شدهی به لیست، طول عمر بیشتری دارند (تا پایان حلقه). بنابراین از garbage collection at generation 0 جان سالم به در خواهند برد و در garbage collection at generation 1 به عمر آنها پایان داده خواهد شد. هرچند ممکن است تعدادی از آنها پاکسازی نشده و تا پایان full garbage collection (generation 2) باقی بمانند.
در آزمایش انجام شده، با ذکر GcGeneration.AllGc، هر سه مورد Gen0 تا Gen2 اندازه گیری خواهند شد. عموما اندازه گیری Gen0 و Gen1 مهم نیستند و اینها خیلی زود به پایان خواهند رسید. اگر تعداد بار رخدادن Gen2 زیاد بود (یا اصلا وجود داشت)، میتواند سبب بروز مشکلات کارآیی شدیدی گردد.
بنابراین میتوان بجای تنظیم GcGeneration.AllGc، صرفا از GcGeneration.Gen2 استفاده کرد.
اندازهگیری Throughput یا تعداد بار اجرای یک متد در ثانیه
روش دیگر کار با فریم ورک NBench، ایجاد یک کلاس مخصوص و سپس افزودن متدهای Setup مزین به PerfSetup، متد Cleanup مزین به PerfCleanup و سپس تعدادی متد اندازه گیری کارآیی توسط ویژگی PerfBenchmark است. در اینجا برای اندازهگیری سرعت اجرای متدها، از ویژگی CounterThroughputAssertion استفاده خواهد شد که پارامتر اول آن نام یک شمارشگر است. این شمارشگر در متد Setup ایجاد میشود (با یک نام دلخواه).
public class DictionaryThroughputTests { private readonly Dictionary<int, int> _dictionary = new Dictionary<int, int>(); private const string AddCounterName = "AddCounter"; private Counter _addCounter; private int _key; private const int AverageOperationsPerSecond = 20000000; [PerfSetup] public void Setup(BenchmarkContext context) { _addCounter = context.GetCounter(AddCounterName); _key = 0; } [PerfBenchmark(RunMode = RunMode.Throughput, TestMode = TestMode.Test)] [CounterThroughputAssertion(AddCounterName, MustBe.GreaterThan, AverageOperationsPerSecond)] public void AddThroughput_ThroughputMode(BenchmarkContext context) { _dictionary.Add(_key++, _key); _addCounter.Increment(); } [PerfBenchmark(RunMode = RunMode.Iterations, TestMode = TestMode.Test)] [CounterThroughputAssertion(AddCounterName, MustBe.GreaterThan, AverageOperationsPerSecond)] public void AddThroughput_IterationsMode(BenchmarkContext context) { for (var i = 0; i < AverageOperationsPerSecond; i++) { _dictionary.Add(i, i); _addCounter.Increment(); } } [PerfCleanup] public void Cleanup(BenchmarkContext context) { _dictionary.Clear(); } }
--------------- RESULTS: NBenchSample.DictionaryThroughputTests+AddThroughput_ThroughputMode --------------- [Counter] AddCounter: Max: 575,654.00 operations, Average: 575,654.00 operations, Min: 575,654.00 operations, StdDev: 0.00 operations [Counter] AddCounter: Max / s: 7,205,997.59 operations, Average / s: 7,163,894.30 operations, Min / s: 7,075,316.79 operations, StdDev / s: 42,518.20 operations --------------- RESULTS: NBenchSample.DictionaryThroughputTests+AddThroughput_IterationsMode --------------- [Counter] AddCounter: Max: 20,000,000.00 operations, Average: 20,000,000.00 operations, Min: 20,000,000.00 operations, StdDev: 0.00 operations [Counter] AddCounter: Max / s: 7,409,380.61 operations, Average / s: 7,250,991.24 operations, Min / s: 6,880,938.73 operations, StdDev / s: 148,085.19 operations
در اینجا برای گزارش دادن، عددهای Average و Average / s باید مورد استفاده قرار گیرند.
تهیه یک بانک اطلاعاتی نمونه
برای نمایش امکانات کار با روش Database first، نیاز است یک بانک اطلاعاتی را به صورت مستقل و متداولی ایجاد کنیم. به همین جهت اسکریپت SQL ذیل را توسط Management studio اجرا کنید تا بانک اطلاعاتی BloggingCore2016، به همراه دو جدول به هم وابسته، در آن ایجاد شوند:
CREATE DATABASE [BloggingCore2016] GO USE [BloggingCore2016] GO CREATE TABLE [Blog] ( [BlogId] int NOT NULL IDENTITY, [Url] nvarchar(max) NOT NULL, CONSTRAINT [PK_Blog] PRIMARY KEY ([BlogId]) ); GO CREATE TABLE [Post] ( [PostId] int NOT NULL IDENTITY, [BlogId] int NOT NULL, [Content] nvarchar(max), [Title] nvarchar(max), CONSTRAINT [PK_Post] PRIMARY KEY ([PostId]), CONSTRAINT [FK_Post_Blog_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blog] ([BlogId]) ON DELETE CASCADE ); GO INSERT INTO [Blog] (Url) VALUES ('https://www.dntips.ir/'), ('http://blogs.msdn.com/dotnet'), ('http://blogs.msdn.com/webdev'), ('http://blogs.msdn.com/visualstudio') GO
پیشنیازهای مهندسی معکوس ساختار بانک اطلاعاتی در EF Core
در قسمت اول در حین بررسی «برپایی تنظیمات اولیهی EF Core 1.0 در یک برنامهی ASP.NET Core 1.0»، چهار مدخل جدید را به فایل project.json برنامه اضافه کردیم. مدخل جدید Microsoft.EntityFrameworkCore.Tools که به قسمت tools آن اضافه شد، پیشنیاز اصلی کار با EF Core Migrations است. همچنین وجود مدخل Microsoft.EntityFrameworkCore.SqlServer.Design برای تدارک امکانات مهندسی معکوس ساختار یک بانک اطلاعاتی SQL Server ضروری است.
تبدیل ساختار دیتابیس BloggingCore2016 به کدهای معادل EF Core آن
پس از فعال سازی ابزارهای خط فرمان EF Core، به پوشهی اصلی پروژه مراجعه کرده، کلید shift را نگه دارید. سپس کلیک راست کرده و گزینهی Open command window here را انتخاب کنید تا خط فرمان از این پوشه آغاز شود. در ادامه دستور ذیل را صادر کنید:
dotnet ef dbcontext scaffold "Data Source=(local);Initial Catalog=BloggingCore2016;Integrated Security = true" Microsoft.EntityFrameworkCore.SqlServer -o Entities --context MyDBDataContext --verbose
اجرا این دستور سبب اتصال به رشتهی اتصالی ذکر شده که به بانک اطلاعاتی BloggingCore2016 اشاره میکند، میشود. سپس پروایدر مدنظر ذکر شدهاست. سوئیچ o محل درج فایلهای نهایی را مشخص میکند. برای مثال در اینجا فایلهای نهایی مهندسی معکوس شده در پوشهی Entities درج میشوند (تصویر فوق). همچنین در اینجا امکان ذکر فایل context تولیدی نیز وجود دارد. اگر علاقمند باشید تا تمام ریز جزئیات این عملیات را نیز مشاهده کنید، میتوانید پارامتر اختیاری verbose را نیز به انتهای دستور اضافه نمائید.
بقیه مراحل کار با این فایلهای تولید شده، با نکاتی که تاکنون عنوان شدهاند یکی است. برای مثال اگر میخواهید رشتهی اتصالی پیش فرض را از این Context تولید شده خارج کنید:
public partial class MyDBDataContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(@"Data Source=(local);Initial Catalog=BloggingCore2016;Integrated Security = true"); }
بررسی پارامترهای دیگر ابزار مهندسی معکوس به Code First
اگر دستور dotnet ef dbcontext scaffold --help را صادر کنیم، خروجی راهنمای ذیل را میتوان مشاهده کرد:
Usage: dotnet ef dbcontext scaffold [arguments] [options] Arguments: [connection] The connection string of the database [provider] The provider to use. For example, Microsoft.EntityFrameworkCore.SqlServer Options: -a|--data-annotations Use DataAnnotation attributes to configure the model where possible. If omitted, the output code will use only the fluent API. -c|--context <name> Name of the generated DbContext class. -f|--force Force scaffolding to overwrite existing files. Otherwise, the code will only proceed if no output files would be overwritten. -o|--output-dir <path> Directory of the project where the classes should be output. If omitted, the top-level project directory is used. --schema <schema> Selects a schema for which to generate classes. -t|--table <schema.table> Selects a table for which to generate classes. -e|--environment <environment> The environment to use. If omitted, "Development" is used. -h|--help Show help information -v|--verbose Enable verbose output
- حالت پیش فرض تنظیمات روابط مدلها در این روش، حالت استفاده از Fluent API است. اگر میخواهید آنرا به حالت استفادهی از Data Annotations تغییر دهید، پارامتر a- و یا data-annotations-- را در دستور نهایی ذکر کنید.
- حالت پیش فرض تولید فایلهای نهایی این روش، عدم بازنویسی فایلهای موجود است. اگر میخواهید پس از تغییر بانک اطلاعاتی، مجددا این فایلها را از صفر تولید کنید، پارامتر f- و یا force- را در دستور نهایی ذکر کنید.
بنابراین اگر میخواهید هربار فایلهای نهایی را بازنویسی کنید و همچنین روش کار با Data Annotations را ترجیح میدهید، دستور نهایی، شکل زیر را پیدا خواهد کرد:
dotnet ef dbcontext scaffold "Data Source=(local);Initial Catalog=BloggingCore2016;Integrated Security = true" Microsoft.EntityFrameworkCore.SqlServer -o Entities --context MyDBDataContext --verbose --force --data-annotations
کار با یک بانک اطلاعاتی موجود، با روش مهاجرتهای Code First
فرض کنید میخواهید از یک بانک اطلاعاتی از پیش موجود EF 6.x (یا هر بانک اطلاعاتی از پیش موجود دیگری)، به روش پیش فرض EF Core استفاده کنید. برای این منظور:
- ابتدا جدول migration history قدیمی آنرا حذف کنید؛ چون ساختار آن با EF Core یکی نیست.
- سپس با استفاده از دستور dotnet ef dbcontext scaffold فوق، معادل کلاسها، روابط و Context سازگار با EF Core آنرا تولید کنید.
- در ادامه رشتهی اتصالی پیش فرض آنرا از کلاس Context تولیدی خارج کرده و از یکی از روشهای مطرح شدهی در مطلب «شروع به کار با EF Core 1.0 - قسمت 1 - برپایی تنظیمات اولیه» استفاده کنید.
- سپس نیاز است این Context جدید را توسط متد services.AddDbContext به لیست سرویسهای برنامه اضافه کنید. این مورد نیز در قسمت اول بررسی شدهاست.
- مرحلهی بعد، افزودن جدول __EFMigrationsHistory جدید EF Core، به این بانک اطلاعاتی است. برای این منظور به روش متداول فعال کردن مهاجرتها، دستور ذیل را صادر کنید:
dotnet ef migrations add InitialDatabase
using Microsoft.EntityFrameworkCore.Migrations; namespace Core1RtmEmptyTest.DataLayer.Migrations { public partial class InitialDatabase : Migration { protected override void Up(MigrationBuilder migrationBuilder) { } protected override void Down(MigrationBuilder migrationBuilder) { } } }
سپس دستور به روز رسانی بانک اطلاعاتی را صادر کنید:
dotnet ef database update
پس از این مرحله، روش کار، Code first خواهد بود. برای مثال خاصیتی را به کلاسی اضافه میکنید و سپس دو دستور ذیل را صادر خواهید کرد که در آن v2 یک نام دلخواه است:
dotnet ef migrations add v2 dotnet ef database update
- طراحی دیتابیس یا بانک اطلاعاتی بر پایه چند زبانه بودن و بررسی سناریوهای مختلف.
- نکاتی که باید در ساخت سایتهای چند زبانه به آنها دقت کرد.
- شیوهی تشخیص و تغییر زبان سایت
- معرفی چند کامپوننت وب، برای مباحث چند زبانه
طراحی مدل دیتابیس
اولین کار برای داشتن یک سایت چند زبانه، این است که یک مدل صحیح و مناسب را برای دیتابیس خود انتخاب کنید. یکی از اولین روشهایی که به ذهن هر فردی میرسد این است که برای هر ستون متنی که قرار است چند زبانه باشد، به تعداد زبانها برایش یک ستون در نظر بگیریم. یعنی برای جدول مقالات که قرار است در سه زبان فارسی و انگلیسی و عربی باشد، سه ستون برای عنوان مقاله و سه ستون نیز برای متن آن داشته باشیم. تصویر زیر نمونهای از این مدل را نشان میدهد.
مزایا:
- پیاده سازی آسان
معایب:
- در این روش با زیاد شدن هر زبان، تعداد ستونها افزایش مییابد که باعث میشود طراحی مناسبی نداشته باشد.
- در ضمن این مورد باید توسط برنامه نویس مرتبا اضافه گردد یا اینکه برنامه نویس این امکان را در سیستم قرار دهد که مدیر سایت بتواند در پشت صحنه کوئری افزودن ستون را ایجاد کند که باید جدول مرتبا مورد alter گرفتن قرار بگیرد.
- ممکن است همیشه برای هر زبانی مطلبی قرار نگیرد و این مورد باعث میشود بی جهت فضایی برای آن در نظر گرفته شود.
پی نوشت: با اینکه امروزه بحث فیلدهای sparse Column وجود دارد ولی این فیلدها در هر شرایطی مورد استفاده قرار نمیگیرند وبیشتر متعلق به زمانی است که میدانیم آن فیلد به شدت کم مورد استفاده قرار میگیرد.
پی نوشت دوم : در صورتی که فیلد شما مانند متن مقاله که عموما از نوع داده (varchar(max است استفاده میکنید و در صورتی که زبان مورد استفاده قرار نگیرد در خیلی از اوقات بی جهت فیلدهای Blob ساخته اید که بهینه سازی آن را نیز باید در نظر بگیرید.
ID | کد |
Language | زبان |
ISO | کد دو رقمی آن زبان |
Flag | پرچم آن کشور |
مزایا:
- پیاده سازی آسان
معایب:
- ایجاد رکوردهای تکراری، هر مقاله برای بعضی از اطلاعاتش که چند زبانه نیستند دادههای تکراری خواهد داشت.
- هر مقاله یک مقالهی جدا شناخته میشود و ارتباطی میان آنان نخواهد بود. بدین ترتیب توانایی ایجاد گزارشهایی چون هر گروه از مقاله و دسته بندی آنها از بین خواهد رفت. در ضمن مدیر عموما در یک سیستم مدیریتی میخواهد تنها یک لینک را به یک مقاله بدهد و سایت بنا به تشخیص در زبان مزبور، یکی از این مقالات را به کاربر نمایش دهد؛ نه اینکه مرتبا مدیر برای هر زبان، لینکی را مهیا کند و در این حالت چنین چیزی ممکن نخواهد بود.
- در یک سیستم فروشگاهی همانند تصویر بالا کار هم سختتر میشود و هر رکورد، یک محصول جدا شناخته میشود و ویرایشها هم برای هر کدام باید جداگانه صورت بگیرد که در عمل این طرح را رد میکند.
سومین راه حل این است که سه جدول ایجاد کنیم:
یک. جدول زبانها (که بالاتر ایجاد شده بود)
دو . جدول نام مقاله به همراه اطلاعات پایه و فیلدها بی نیاز به چند زبانه بودن
سه : یک جدول که هر دو ستون آن کدهای کلید دو جدول بالا را دارند و فیلدهای چند زبانه در آن وجود دارند.
جدول پایه
ID | کد |
Name | نام مقاله |
CreationDate | تاریخ ایجاد |
Writer | نویسنده |
Visibilty | وضعیت نمایش |
LanguageCode | کد زبان |
ArticleID | کد مقاله |
CreationDate | تاریخ ایجاد |
Visibility | وضعیت نمایش مقاله |
Title | عنوان مقاله |
ContentText | متن مقاله |
در جدول پایه یک مقاله ایجاد میشود که اطلاعات عمومی همه مقالات را دارد و حتی خصوصیت وضعیت نمایش آن، روی همهی مقالات با هر زبانی تاثیر میگذارد. در جدول دو، هر مقاله یک رکورد دارد که کد زبان و کد مقاله برای آن یک کلید ترکیبی به حساب میآیند. پس، از هر مقاله یک یا چند زبان خواهیم داشت. همچنین دارای فیلدهایی با وضعیت مخصوص به خود هم هستند؛ مثل فیلد وضعیت نمایش مقاله که فقط برای این مقاله با این زبان کاربرد دارد.
مزایا:
- گزارش گیری آسان برای هر دسته مقاله با زبانهای مختلف و ارتباط و یکپارچگی
- آسان در افزودن زبان.
معایب:
- ایجاد کوئریهای پیچیدهتر و جوین دار که به نسبت روشهای قبلی کوئریها پیچیدهتر شده اند.
- کدنویسی زیادتر.
استفاده از ساختارهای XML یا JSON برای ذخیره سازی اطلاعات چند زبانه مانند ساختارهای زیر:
XML<Articles> <Article> this is english text </Article> <Article> این یک متن فارسی است </Article> </Articles> یا <Articles> <en-us> this is english text </en-us> <fa-ir> این یک متن فارسی است </fa-ir> </Articles>
"Articles":["en-us':{"title":"this is english text","content":" english content"},"fa-ir":{"title":"متن فارسی","content":"محتوای فارسی"}]
از مزایای این روش ذخیرهی همه دادهها در یک ستون و یک جدول است و نیازی به ستونهای اضافه یا جداول اضافه نیست ولی معایب این روش استفاده از کوئریهای پیچیدهتر جهت ارتباط و خواندن است.
استفاده از بانکهای اطلاعاتی NO SQL
در این بانکها دیگر درگیر تعداد ستونها و جنس آنها نیستیم و میتوانیم برای هر مقاله یا محصول، هر تعداد زبان و یا فیلد را که میخواهیم، در نظر بگیریم و اضافه کنیم. برای آشنایی بیشتر با این نوع بانکها و انواع آن، مقالات مربوط به nosql را در سایت دنبال کنید.
نکاتی که در یک سایت چند زبانه باید به آنها توجه کرد.
یک . زبان آن صفحه را معرفی کنید: این کار هم به موتورهای جست و جو برای ثبت سایت شما کمک میکند و هم برای معلولین که از ابزارهای صفحه خوان استفاده میکنند، کمک بزرگی است. در این روش، صفحه خوانها و دستگاههای خط بریل که زبان صفحه را تشخیص نمیدهند با خواندن کد زبان میتوانند زبان صفحه را تشخیص دهند. با استفاده از خط زیر میتوانید زبان اصلی صفحهی خود را تنظیم نمایید:
<html lang="en">
اگر از XHTML استفاده میکنید خاصیت زیر را فراموش نکنید. دریافت W3C Validation بدون آن امکان پذیر نخواهد بود.
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<blockquote lang=”fr”> <p>Le plus grand faible des hommes, c'est l'amour qu'ils ont de la vie.</p> </blockquote>
سه. لینک ها : اگر دارید در صفحهای لینک به جایی میدهید که متفاوت از زبان شماست، حتما باید زبان صفحه یا سایت مقصد را مشخص کنید. مثلا لینک زیر برای صفحهای است که از یک زبان غیر فرانسوی به یک صفحهی با زبان فرانسوی هدایت میشود:
<a href="" hreflang="fr">French</a>
همچنین اگر متن لینک شما هم به زبان فرانسوی باشد خیلی خوب میشود که آن را هم بیان کنید و از خاصیت lang و هم hreflang همزمان استفاده کنید:
<a href="" hreflang="fr">Francais</a>
پنج. انکودینگ صفحه را مشخص کنید: برای اینکه نحوهی رمزگذاری و رمزگشایی حروف و نمادها مشخص گردد، باید انکودینگ تنظیم شود و حتی برای بعضی از موتورهای جست و جو که ممکن است با وب سایت شما به مشکل بر بخورند. امروزه بیشتر از صفحات یونیکد استفاده میشود که سطح وسیعی از کاراکترها را پشتیبانی میکند.
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
HTML5
<meta charset="UTF-8">
شش. اندازهی فونت: موقعی که یک سایت چند زبانه را طراحی میکنید این نکته خیلی مهم هست که بدانید اندازه فونتهای زبان پیش فرض، برای باقی زبانها مناسب نیستند. به عنوان مثال ممکن است اندازه فونتی برای زبانهای انگلیسی، فرانسوی و آلمانی مناسب باشد ولی برای زبانهای فارسی و عربی و چینی و ... مناسب نباشد و خواندن آن سخت شود. به همین جهت یکی از راههای حل این مشکل استفاده از قالب css است که وابسته به خصوصیت lang ای است که شما برای صفحه و هر المان یا تگی که از این خصوصیت استفاده میکند، تعیین کردهاید.
:lang(en) { font-size: 85%; font-family: arial, verdana, sans-serif; } :lang(zh) { font-size: 125%; font-family: helvetica, verdana, sans-serif; }
خط زیر تعیین میکند که از استایل اول استفاده شود:
<html lang="en">
<html lang="zh">
<body class="english"> or <body class="chinese">
و استایل:
.english { font-size: 85%; font-family: arial, verdana, sans-serif; } .chinese { font-size: 125%; font-family: helvetica, verdana, sans-serif; }
هشت : زمان را نیز تغییر دهید: یکی از مواردی که در کمتر سایت چند زبانهای به چشم میخورد و به نظر بنده میتواند بسیار مهم باشد این است که time zone منطقهی هر زبان را بدانید. به عنوان مثال برای مقالهی خود، تاریخ ایجاد را به صورت UTC ذخیره کنید و سپس نمایش را بر اساس زبان یا حتی بهتر و دقیقتر از طریق IP کشور مربوطه به دست آورید. برای کاربران ثبت نام شده این تاریخ میتواند دقیقتر باشد همانند انجمنهای وی بولتین.
شیوههای تشخیص زبان سایت
یکی از راههای تشخیص زبان این است که موقعی که برای اولین بار کاربری به سایت مراجعه میکند، زبان مورد نظرش را سوال کنید و این اطلاعات را در یک کوکی بدون تاریخ انقضاء ذخیره کنید تا در دفعات بعدی آن را بررسی نمایید.
دومین راه، استفاده از IP کاربر مراجعه کننده است تا بر اساس آن زبان مورد نظر را انتخاب کنید.
در سومین شیوه که اغلب استفاده میشود، زبان سایت به طور پیش فرض بر روی یک زبان خاص که بهتر است انگلیسی باشد تنظیم شده است و سپس کاربر از طریق یک منو یا ابزارهای موجود در سایت، زبان سایت را تغییر دهد.
پی نوشت: فراموش نگردد که امکان تغییر زبان همیشه برای کاربر مهیا باشد و طوری نباشد که کاربر در آینده نتواند زبان سایت را تغییر دهد؛ حتی اگر تشخیص خودکار سایت برای زبان فعال باشد.
پلاگینها و ابزارهای مدیریت زبانپی نوشت: در روشهای بالا بهتر است همان مرتبهی اول اطلاعات را در یک کوکی ذخیره کنید تا مراحل پیگیری راحتتر و آسانتر شود.
دومین ابزار که بیشتر برای انتخاب کشور میباشد و من خودم در بخش مدیریتی سیستمها از آن استفاده میکنم، ابزار CountrySelector است. این پلاگین قابلیت جست و جو زبان را همزمان با تایپ کاربر نیر داراست. اسامی کشورها به صورت انگلیسی شروع شده و به زبان آن کشور در داخل پرانتز خاتمه مییابند و پرچم هر کشور نیز در کنار آن قرار دارد. کار کردن با آن بسیار راحت بوده و مستنداتش به طور کامل کار با آن را توضیح میدهد.
پلاگین بعدی International Telephone Input است که پیاده سازی پلاگین بالا میباشد. برای مواردی مفید است که شما نیاز دارید کد تلفنی کشوری را انتخاب کنید.
در مقالههای زیر که در سایت جاری است در مورد Globalization و به خصوص استفاده از ریسورسها مطالب خوبی بیان شده است:
قسمت بیست و دوم آموزش MVC که مبحث Globalization را دنبال میکند.
قسمت اول از شش قسمت مباحث Globalization که دنبالهی آن را میتوانید در مقالهی خودش دنبال کنید.
Html Encoding
Hello%20world%20,%20hi
<html>encoding</html>
<html>encoding</html>
کاراکتر | عبارت معادل | توضیحات |
> | > | |
< | < | |
" | " | |
' | ' | یا ;apos& به غیر از IE |
& | & |
- متد System.Security.SecurityElement.Escape: این متد بیشتر برای اعمال این انکدینگ در XML بهکار میرود. در این متد 5 کاراکتر اشاره شده در بالا به عبارات معادل انکد میشوند. البته برای کاراکتر ' از عبارت ;apos& استفاده میشود.
- متدهای موجود در System.Net.WebUtility: متدهای HtmlEncode و HtmlDecode موجود در این کلاس عملیات انکدینگ را انجام میدهند. این کلاس از داتنت 4 اضافه شده است.
- متدهای کلاس System.Web.HttpUtility: در این کلاس از متدهای موجود در کلاس System.Web.Util.HttpEncoder استفاده میشود. در پیادهسازی پیشفرض، متدهای این کلاس از متدهای موجود در کلاس WebUtility استفاده میکنند. البته میتوان با فراهم کردن یک Encoder سفارشی و تنظیم آن در فایل کانفیگ (خاصیت encoderType در قسمت HttpRuntime) این رفتار را تغییر داد. دلیل اصلی جابجایی مکان پیادهسازی این متدها از دات نت 4 به بعد نیز به همین دلیل است. (اطلاعات بیشتر ^ و ^).
- متدهای موجود در System.Web.HttpServerUtility: متدهای HtmlEncode و HtmlDecode موجود در این کلاس مستقیما از متدهای موجود در کلاس HttpUtility استفاده میکنند. خاصیت Server موجود در HttpContext یا در کلاس Page از نوع این کلاس است.
- متدهای موجود در کلاس System.Web.Security.AntiXss.AntiXssEncoder: این کلاس از دات نت 4.5 اضافه شده است. همانطور که از نام این کلاس بر میآید، از HttpEncoder مشتق شده است که در متدهای مرتبط با html encoding تغییراتی در آن اعمال شده است. متدهای این کلاس برای امنیت بیشتر به جای استفاده از Black List از یک White List استفاده میکنند.
public static unsafe void HtmlEncode(string value, TextWriter output) { int index = IndexOfHtmlEncodingChars(value, 0); if (index == -1) { output.Write(value); return; } int cch = value.Length - index; fixed (char* str = value) { char* pch = str; while (index-- > 0) { output.Write(*pch++); } while (cch-- > 0) { char ch = *pch++; if (ch <= '>') { switch (ch) { case '<': output.Write("<"); break; case '>': output.Write(">"); break; case '"': output.Write("""); break; case '\'': output.Write("'"); break; case '&': output.Write("&"); break; default: output.Write(ch); break; } } else if (ch >= 160 && ch < 256) { // The seemingly arbitrary 160 comes from RFC output.Write("&#"); output.Write(((int)ch).ToString(NumberFormatInfo.InvariantInfo)); output.Write(';'); } else { output.Write(ch); } } } } private static unsafe int IndexOfHtmlEncodingChars(string s, int startPos) { int cch = s.Length - startPos; fixed (char* str = s) { for (char* pch = &str[startPos]; cch > 0; pch++, cch--) { char ch = *pch; if (ch <= '>') { switch (ch) { case '<': case '>': case '"': case '\'': case '&': return s.Length - cch; } } else if (ch >= 160 && ch < 256) { return s.Length - cch; } } } return -1; }
<div id="log"></div> <script type="text/javascript"> var element = document.getElementById('log'); element.innerText = '<html> encoding </html>'; console.log(element.innerHTML); </script>
<html> encoding </html>
<div id="log"> </div> <script type="text/javascript"> var element = document.getElementById('log'); element.innerHTML = "<html> encoding </html>"; console.log(element.innerText); </script>
<html> encoding </html>
String.htmlEncode = function (s) { var el = document.createElement("div"); el.innerText = s || ''; return el.innerHTML; };
console.log(String.htmlEncode("<html>")); //result: <html>
String.prototype.htmlEncode = function () { var el = document.createElement("div"); el.innerText = this.toString(); return el.innerHTML; };
console.log("<html>".htmlEncode()); //result: <html>
String.htmlDecode = function (s) { var el = document.createElement("div"); el.innerHTML = s || ''; return el.innerText; };
String.prototype.htmlDecode = function () { var el = document.createElement("div"); el.innerHTML = this.toString(); return el.innerText; };
console.log(String.htmlDecode("<html>")); console.log("<html>".htmlDecode());
String.htmlEncode = function (s) { return $('<div/>').text(value).html(); }; String.prototype.htmlEncode = function () { return $('<div/>').text(this.toString()).html(); };
String.htmlDecode = function (s) { return $('<div/>').html(s).text(); }; String.prototype.htmlDecode = function () { return $('<div/>').html(this.toString()).text(); };
var value = "a \n b";
String.htmlEncode = function (s) { var el = document.createElement("div"); var txt = document.createTextNode(s); el.appendChild(txt); return el.innerHTML; };
String.htmlDecode(String.htmlEncode(myString)) === myString;
var myString = "<HTML>"; String.htmlDecode(String.htmlEncode(myString)) === myString; // result: true // -------------------------------------------------------------------------- myString = "<اچ تی ام ال>"; String.htmlDecode(String.htmlEncode(myString)) === myString; // result: true
myString = "a \r b"; String.htmlDecode(String.htmlEncode(myString)) === myString; // result: false
console.log(escape(String.htmlDecode('\r'))); // result: %0A : it is url encode of character '\n' console.log(escape(String.htmlDecode('\n'))); // result: %0A console.log(escape(String.htmlDecode('\r\n'))); // result: %0A
var el = document.createElement('div'); el.innerText = '\r'; console.log(escape(el.innerText)); // result: %0A el.innerHTML = '\r'; console.log(escape(el.innerHTML)); // result: %0A console.log(escape('\r')); // result: %0D
String.htmlEncode = function (text) { text = text || ''; var encoded = ''; for (var i = 0; i < text.length; i++) { var c = text[i]; switch (c) { case '<': encoded += '<'; break; case '>': encoded += '>'; break; case '&': encoded += '&'; break; case '"': encoded += '"'; break; case "'": encoded += '''; break; default: // the upper limit can be removed to support more chars... var code = c.charCodeAt(); if (code >= 160 & code < 256) encoded += '&#' + code + ';'; else encoded += c; } } return encoded; };
function escapeHTML() { return this.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } function unescapeHTML() { return this.stripTags().replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); }
var andExp = /&/g, htmlExp = [/(<|>|")/g, /(<|>|')/g, /(<|>|'|")/g], htmlCharMap = { '<': '<', '>': '>', "'": ''', '"': '"' }, htmlReplace = function (all, $1) { return htmlCharMap[$1]; }; $.extend({ // convert special html characters htmlspecialchars: function (string, quot) { return string.replace(andExp, '&').replace(htmlExp[quot || 0], htmlReplace); } });
$.htmlspecialchars("<div>");
this.escapeHTML = function (s) { this.str = this.s(s) .split('&').join('&') .split('<').join('<') .split('>').join('>'); return this; }; this.unescapeHTML = function (s) { this.str = this.stripTags(this.s(s)).str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); return this; };
- «پیاده سازی حذف منطقی در Entity framework» حذف منطقی، یکی از الگوهای بسیار پرکاربرد در برنامههای تجاری است. توسط آن بجای حذف فیزیکی اطلاعات، آنها را تنها به عنوان رکوردی حذف شده، «علامتگذاری» میکنیم. مزایای آن نیز به شرح زیر هستند:
- داشتن سابقهی حذف اطلاعات
- جلوگیری از cascade delete
- امکان بازیابی رکوردها و امکان ایجاد قسمتی به نام recycle bin در برنامه (شبیه به recycle bin در ویندوز که امکان بازیابی موارد حذف شده را میدهد)
- امکان داشتن رکوردهایی که در یک برنامه (به ظاهر) حذف شدهاند، اما هنوز در برنامهی دیگری در حال استفاده هستند.
- بالابردن میزان امنیت برنامه. فرض کنید سایت شما هک شده و شخصی، دسترسی به پنل مدیریتی و سطوح دسترسی مدیریتی برنامه را پیدا کردهاست. در این حالت حذف تمام رکوردهای سایت توسط او، تنها به معنای تغییر یک بیت، از یک به صفر است و بازگرداندن این درجه از خسارت، تنها با روشن کردن این بیت، برطرف میشود.
پیاده سازی حذف منطقی در EF Core شامل مراحل خاصی است که در این مطلب، جزئیات آنها را بررسی خواهیم کرد.
نیاز به تعریف دو خاصیت جدید در هر جدول
هر جدولی که قرار است soft delete به آن اعمال شود، باید دارای دو فیلد جدید bool IsDeleted و DateTime? DeletedAt باشد. میتوان این خواص را به هر موجودیتی به صورت دستی اضافه کرد و یا میتوان ابتدا یک کلاس پایهی abstract را برای آن ایجاد کرد:
using System; namespace EFCoreSoftDelete.Entities { public abstract class BaseEntity { public int Id { get; set; } public bool IsDeleted { set; get; } public DateTime? DeletedAt { set; get; } } }
using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Blog : BaseEntity { public string Name { set; get; } public virtual ICollection<Post> Posts { set; get; } } public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { builder.Property(blog => blog.Name).HasMaxLength(450).IsRequired(); builder.HasIndex(blog => blog.Name).IsUnique(); builder.HasData(new Blog { Id = 1, Name = "Blog 1" }); builder.HasData(new Blog { Id = 2, Name = "Blog 2" }); builder.HasData(new Blog { Id = 3, Name = "Blog 3" }); } } }
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Post : BaseEntity { public string Title { set; get; } public Blog Blog { set; get; } public int BlogId { set; get; } } public class PostConfiguration : IEntityTypeConfiguration<Post> { public void Configure(EntityTypeBuilder<Post> builder) { builder.Property(post => post.Title).HasMaxLength(450); builder.HasOne(post => post.Blog).WithMany(blog => blog.Posts).HasForeignKey(post => post.BlogId); builder.HasData(new Post { Id = 1, BlogId = 1, Title = "Post 1" }); builder.HasData(new Post { Id = 2, BlogId = 1, Title = "Post 2" }); builder.HasData(new Post { Id = 3, BlogId = 1, Title = "Post 3" }); builder.HasData(new Post { Id = 4, BlogId = 1, Title = "Post 4" }); builder.HasData(new Post { Id = 5, BlogId = 2, Title = "Post 5" }); } } }
حذف خودکار رکوردهایی که Soft Delete شدهاند، از نتیجهی کوئریها و گزارشات
تا اینجا فقط دو خاصیت ساده را به کلاسهای مدنظر خود اضافه کردهایم. پس از آن یا میتوان در هر جائی برای مثال شرط context.Blogs.Where(blog => !blog.IsDeleted) را به صورت دستی اعمال کرد و در گزارشات، رکوردهای حذف منطقی شده را نمایش نداد و یا از زمان ارائهی EF Core 2x میتوان برای آنها Query Filter تعریف کرد. برای مثال میتوان به تنظیمات موجودیت Blog و یا Post مراجعه نمود و با استفاده از متد HasQueryFilter، همان شرط blog => !blog.IsDeleted را به صورت سراسری به تمام کوئریهای مرتبط با این موجودیتها اعمال کرد:
public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { // ... builder.HasQueryFilter(blog => !blog.IsDeleted); } }
در این حالت کوئریهای نهایی به صورت خودکار دارای شرط زیر خواهند شد:
SELECT [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE [b].[IsDeleted] <> CAST(1 AS bit)
اعمال خودکار QueryFilter مخصوص Soft Delete به تمام موجودیتها
همانطور که عنوان شد، مزیت علامتگذاری موجودیتها با کلاس پایهی BaseEntity، امکان کوئری گرفتن از آنها است:
namespace EFCoreSoftDelete.DataLayer { public static class GlobalFiltersManager { public static void ApplySoftDeleteQueryFilters(this ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model .GetEntityTypes() .Where(eType => typeof(BaseEntity).IsAssignableFrom(eType.ClrType))) { entityType.addSoftDeleteQueryFilter(); } } private static void addSoftDeleteQueryFilter(this IMutableEntityType entityData) { var methodToCall = typeof(GlobalFiltersManager) .GetMethod(nameof(getSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static) .MakeGenericMethod(entityData.ClrType); var filter = methodToCall.Invoke(null, new object[] { }); entityData.SetQueryFilter((LambdaExpression)filter); } private static LambdaExpression getSoftDeleteFilter<TEntity>() where TEntity : BaseEntity { return (Expression<Func<TEntity, bool>>)(entity => !entity.IsDeleted); } } }
محل اعمال آن نیز در انتهای متد OnModelCreating است تا به صورت خودکار به تمام موجودیتهای موجود اعمال شود:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { //... protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly); modelBuilder.ApplySoftDeleteQueryFilters(); }
مشکل! هنوز هم حذف فیزیکی رخ میدهد!
تنظیمات فوق، تنها بر روی کوئریهای نوشته شده تاثیر دارند؛ اما هیچگونه تاثیری را بر روی متد Remove و سپس SaveChanges نداشته و در این حالت، هنوز هم حذف واقعی و فیزیکی رخ میدهد.
برای رفع این مشکل باید به EF Core گفت، هر چند دستور حذف صادر شده، اما آنرا تبدیل به دستور Update کن؛ یعنی فیلد IsDelete را به 1 و فیلد DeletedAt را با زمان جاری مقدار دهی کن:
namespace EFCoreSoftDelete.DataLayer { public static class AuditableEntitiesManager { public static void SetAuditableEntityOnBeforeSaveChanges(this ApplicationDbContext context) { var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries<BaseEntity>()) { switch (entry.State) { case EntityState.Added: //TODO: ... break; case EntityState.Modified: //TODO: ... break; case EntityState.Deleted: entry.State = EntityState.Unchanged; //NOTE: For soft-deletes to work with the original `Remove` method. entry.Entity.IsDeleted = true; entry.Entity.DeletedAt = now; break; } } } } }
var post1 = context.Posts.Find(1); if (post1 != null) { context.Remove(post1); context.SaveChanges(); }
Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:11:32' (Nullable = true), @p1='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Posts] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT;
محل اعمال متد SetAuditableEntityOnBeforeSaveChanges فوق، پیش از فراخوانی SaveChanges و به صورت زیر است:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { // ... public override int SaveChanges(bool acceptAllChangesOnSuccess) { ChangeTracker.DetectChanges(); beforeSaveTriggers(); ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again. var result = base.SaveChanges(acceptAllChangesOnSuccess); ChangeTracker.AutoDetectChangesEnabled = true; return result; } // ... private void beforeSaveTriggers() { setAuditProperties(); } private void setAuditProperties() { this.SetAuditableEntityOnBeforeSaveChanges(); } } }
حالت پیشفرض حذف رکوردها در EFCore به cascade delete تنظیم شدهاست. یعنی اگر blog با id=1 حذف شود، نه فقط این blog، بلکه تمام مطالب وابستهی به آن نیز حذف خواهند شد. اما در اینجا اگر این بلاگ را حذف کنیم:
ar blog1 = context.Blogs.FirstOrDefault(blog => blog.Id == 1); if (blog1 != null) { context.Remove(blog1); context.SaveChanges(); }
var blog1AndItsRelatedPosts = context.Blogs .Include(blog => blog.Posts) .FirstOrDefault(blog => blog.Id == 1); if (blog1AndItsRelatedPosts != null) { context.Remove(blog1AndItsRelatedPosts); context.SaveChanges(); }
SELECT [t].[Id], [t].[DeletedAt], [t].[IsDeleted], [t].[Name], [t0].[Id], [t0].[BlogId], [t0].[DeletedAt], [t0].[IsDeleted], [t0].[Title] FROM ( SELECT TOP(1) [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE ([b].[IsDeleted] <> CAST(1 AS bit)) AND ([b].[Id] = 1) ) AS [t] LEFT JOIN ( SELECT [p].[Id], [p].[BlogId], [p].[DeletedAt], [p].[IsDeleted], [p].[Title] FROM [Posts] AS [p] WHERE [p].[IsDeleted] <> CAST(1 AS bit) ) AS [t0] ON [t].[Id] = [t0].[BlogId] ORDER BY [t].[Id], [t0].[Id] Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:25:00' (Nullable = true), @p1='True', @p5='2', @p3='2020-09-17T05:25:00' (Nullable = true), @p4='True', @p8='3', @p6='2020-09-17T05:25:00' (Nullable = true), @p7='True', @p11='4', @p9='2020-09-17T05:25:00' (Nullable = true), @p10='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Blogs] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p3, [IsDeleted] = @p4 WHERE [Id] = @p5; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p6, [IsDeleted] = @p7 WHERE [Id] = @p8; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p9, [IsDeleted] = @p10 WHERE [Id] = @p11; SELECT @@ROWCOUNT;
یک نکته: مشکل حذف منطقی و رکوردهای منحصربفرد
فرض کنید در جدولی، فیلد نام کاربری را به عنوان یک فیلد منحصربفرد تعریف کردهاید و اکنون رکوردی در این بین، حذف منطقی شدهاست. مشکلی که در آینده بروز خواهد کرد، عدم امکان ثبت رکورد جدیدی با همان نام کاربری است که حذف منطقی شدهاست؛ چون یک unique index بر روی آن وجود دارد. در این حالت اگر از SQL Server استفاده میکنید، از قابلیتی به نام filtered indexes پشتیبانی میکند که در آن امکان تعریف یک شرط و predicate، در حین تعریف ایندکسها وجود دارد. در این حالت میتوان رکوردهای حذف منطقی شده را به ایندکس وارد نکرد.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: EFCoreSoftDelete.zip