public class FileUpload { public int FileUploadId { get; set; } public string FileName { get; set; } } public class Company { public int CompanyId { get; set; } public string Name { get; set; } public FileUpload Logo { get; set; } public int? LogoId { get; set; } public FileUpload Catalog { get; set; } public int? CatalogId { get; set; } } public class Ads { public int AdsId { get; set; } public string Name { get; set; } public FileUpload Picture { get; set; } public int? PictureId { get; set; } } public class TestContext : DbContext { public DbSet<Company> Companies { get; set; } public DbSet<Ads> Adses { get; set; } public DbSet<FileUpload> FileUploads { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Conventions.Remove<PluralizingTableNameConvention>(); modelBuilder.Entity<Ads>() .HasOptional(a => a.Picture) .WithMany() .HasForeignKey(a => a.PictureId) .WillCascadeOnDelete(); modelBuilder.Entity<Company>() .HasOptional(a => a.Logo) .WithMany() .HasForeignKey(a => a.LogoId) .WillCascadeOnDelete(); modelBuilder.Entity<Company>() .HasOptional(a => a.Catalog) .WithMany() .HasForeignKey(a => a.CatalogId) .WillCascadeOnDelete(); } }
- اول، ابزار Microsoft ASP.NET Scaffolding را از منوی Tools گزینه Extensions and Updates دریافت و نصب نمایید.
- دوم پروژه جدیدی از نوع Visual C# ASP.NET Web Forms Application با فریم ورک 4.5 ایجاد نمایید.
- از پنجره NuGet Package manager با دستور install کتابخانه ASP.NET Web Forms Scaffold Generator را دریافت نمایید
install-package Microsoft.AspNet.Scaffolding.WebForms -pre
- کلاس Person را مانند زیر در فولدر Models ایحاد نماییدویژگی ScaffoldColumn را برای ID، برابر false قرار دهید تا از ایجاد این ستون جلوگیری نمائید.
public class Person { [ScaffoldColumn(false)] public int ID { get; set; } public string FirstName { get; set; } public string LastName { get; set; } }
- پروژه را Build نمایید.
- بر روی پروژه راست کلیک و از گزینه Add، گزینه ...Scaffold را انتخاب نمایید.
- از پنجره Add Scaffold باز شده بر روی گزینه Add، کلیک کنید.
- پنجره
Add Web Forms Pages مانند زیر باز میشود که امکان انتخاب کلاس،Data Context و MasterPage فراهم میباشد.
- از گزینه Data Context class گزینه New Data Context را انتخاب نمایید. صفحات مورد نیاز را در فولدر Views/Person ایجاد مینمایید.
- کدهای تولید شده را میتوانید بازبینی نمایید پروژه را اجرا تا خروجی کار را مشاهده نمایید.
امکان نمونه سازی بدون قید و شرط کلاسها
تعریف کلاس Article1 را به صورت زیر درنظر بگیرید:
public class Article1 { public string Title { get; set; } public string? Subtitle { get; set; } public string Author { get; set; } public DateTime Published { get; set; } }
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net7.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>
Non-nullable property 'Title' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [CS11Tests]csharp(CS8618) Non-nullable property 'Author' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [CS11Tests]csharp(CS8618)
مدیریت کردن نحوهی نمونه سازی کلاسها، با وابستگی به سازندههای آن
یکی از روشهای مدیریت مشکلی که تا اینجا بررسی شد، تعریف سازندههای متعددی برای یک کلاس است؛ تا توسط آن بتوان مقدار دهی یک سری از خواص را اجباری کرد:
public class Article2 { public Article2(string title, string subtitle, string author, DateTime published) { Title = title; Subtitle = subtitle; Author = author; Published = published; } public Article2(string title, string author, DateTime published) { Title = title; Author = author; Published = published; } public string Title { get; set; } public string? Subtitle { get; set; } public string Author { get; set; } public DateTime Published { get; set; } }
- تعداد سطرهای تعریف این کلاس، به شدت افزایش یافتهاست.
- با اضافه شدن خواص بیشتری به کلاس، به تعاریف بیشتری نیاز خواهد بود.
- سازندهها کار خاصی را بجز نگاشت مقادیر ارائه شده، به خواص کلاس، انجام نمیدهند.
- نمونه سازی این کلاسها، شکل طولانی و غیرواضح زیر را پیدا میکند و زیبایی inline object initializers را ندارند:
Article2 article = new("C# 11 Required Keyword", "A new language feature", "Name", new DateTime(2022, 11, 12));
البته روش دیگر مدیریت یک چنین اخطارهایی، استفاده از مقدار ویژهی !default است که سبب محو اخطارهای یاد شده میشود؛ اما باز هم مقدار دهی آنرا الزامی نمیکند. فقط به این معنا است که قول میدهیم این خاصیت را در جای دیگری مقدار دهی کنیم و هیچگاه نال نباشد!
public string Title { get; set; } = default!;
مدیریت کردن نحوهی نمونه سازی کلاسها، بدون وابستگی به سازندههای آن در C# 11.0
C# 11 به همراه واژهی کلیدی جدیدی به نام required است تا دیگر نیازی نباشد همانند راه حل فوق، سازندههای متعددی را جهت اجبار به مقدار دهی خواص یک شیء، تعریف کنیم. در این حالت تعریف کلاس Article به صورت زیر خلاصه میشود و دیگر به همراه اخطارهای کامپایلر نمایش داده شده نیز نیست:
public class Article3 { public required string Title { get; set; } public string? Subtitle { get; set; } public required string Author { get; set; } public DateTime Published { get; set; } }
معرفی ویژگی جدید SetsRequiredMembers
کلاس Book زیر را که به همراه یک خاصیت required و دو سازندهاست، درنظر بگیرید:
public class Book { public Book() => Name = string.Empty; public Book(string name) => Name = name; public required string Name { get; set; } }
Book book = new("Book's Name");
Required member 'Book.Name' must be set in the object initializer or attribute constructor. [CS11Tests]csharp(CS9035)
public class Book { [SetsRequiredMembers] public Book() => Name = string.Empty; [SetsRequiredMembers] public Book(string name) => Name = name; public required string Name { get; set; } }
محدودیتهای کار با خواص required
- واژهی کلیدی required را میتوان تنها به خواص و فیلدهای نوعهای class, record, record struct اعمال کرد. امکان اعمال این واژهی کلیدی به اجزای یک اینترفیس وجود ندارد.
- میدان دید اعضای required باید حداقل در حد نوعهای دربرگیرندهی آنها باشند. برای مثال اگر کلاسی public است، نمیتوان در آن یک فیلد required با میدان دید protected را تعریف کرد.
- نوعهای مشتق شدهی از یک نوع پایه، نمیتوانند اعضای required آنرا مخفی کنند و اگر قصد بازنویسی آنرا دارند، باید حتما واژهی کلیدی required را لحاظ کنند.
- اگر سازندهای به سازندهی دیگری از طریق ذکر ()base و یا ()this زنجیر شده باشد نیز باید ویژگی SetsRequiredMembers مرتبط را تکرار کند.
- تمام تعاریف بومی سازی مورد نیاز برنامه در یک تک فایل SharedResource.fa.resx قرار میگیرند. این فایل نیز در یک اسمبلی مستقل از برنامهی اصلی اضافه میشود.
- با استفاده از تزریق سرویس IStringLocalizer میتوان به کلیدهای فایل SharedResource.fa.resx در هر قسمتی از برنامهی Web API دسترسی یافت.
- در این بین اگر کلیدی یافت نشد، خطایی با ذکر دقیق جزئیات منبع جستجو شده، لاگ میشود.
- کلیدهای بومی سازی data annotations نیز قابل دریافت از فایل SharedResource.fa.resx میباشند.
در ادامه روش پیاده سازی یک چنین امکاناتی را بررسی میکنیم.
قرار دادن فایل منبع اشتراکی در اسمبلی ExternalResources
پس از ایجاد پروژهی ابتدایی Web API به نام Core3xSharedResource.WebApi، یک اسمبلی جدید را برای مثال به نام Core3xSharedResource.ExternalResources تعریف کرده و در داخل آن پوشهی جدید Resources را تعریف میکنیم. به این پوشه، فایل منبع جدیدی را به نام SharedResource.fa.resx اضافه میکنیم. در کنار آن باید یک کلاس خالی به نام SharedResource.cs نیز وجود داشته باشد.
کار با ین فایل (و یا فایلهای دیگری مانند SharedResource.en.resx) همانند تمام فایلهای منبع استاندارد است و نکتهی خاصی را به همراه ندارد.
معرفی فایل منبع اشتراکی به سرویسهای بومی سازی برنامه
پس از ایجاد و تکمیل فایل منبع اشتراکی، برای معرفی آن به برنامه، ابتدا کلاس جدید LocalizationConfig را تعریف کرده و در آن متد جدید AddCustomLocalization را به صورت زیر معرفی میکنیم:
public static class LocalizationConfig { public static IMvcBuilder AddCustomLocalization(this IMvcBuilder mvcBuilder, IServiceCollection services) { mvcBuilder.AddDataAnnotationsLocalization(options => { const string resourcesPath = "Resources"; string baseName = $"{resourcesPath}.{nameof(SharedResource)}"; var location = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName).Name; options.DataAnnotationLocalizerProvider = (type, factory) => { // to use `SharedResource.fa.resx` file return factory.Create(baseName, location); }; }); services.AddLocalization(); services.AddScoped<IStringLocalizer>(provider => provider.GetRequiredService<IStringLocalizer<SharedResource>>()); services.AddScoped<ISharedResourceService, SharedResourceService>(); return mvcBuilder; } }
- سپس با استفاده از متد AddLocalization، سرویسهای پایهی بومی سازی ASP.NET Core به برنامه اضافه میشوند. برای مثال پس از این تعریف اگر در هر جائی از برنامه سرویس <IStringLocalizer<SharedResource را تزریق کنید، میتوان به مداخل فایل منبع اشتراکی، دسترسی یافت.
- در ادامه امکان تزریق سرویس غیرجنریک IStringLocalizer را نیز میسر کردهایم که تعاریف خودش را از همان سرویس توکار <IStringLocalizer<SharedResource دریافت میکند. مزیت اینکار، فراهم شدن امکانات بومی سازی، برای مثال در کتابخانههایی مانند Fluent Validation است که دقیقا از سرویس غیرجنریک IStringLocalizer برای دریافت منابع استفاده میکنند.
- در آخر تعریف یک سرویس سفارشی را نیز مشاهده میکنید که در ادامهی بحث تکمیل خواهد شد.
هدف از متد AddCustomLocalization فوق، خلوت کردن فایل startup برنامه است. این متد به صورت زیر مورد استفاده قرار میگیرد:
public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddHttpContextAccessor(); services.AddControllers().AddCustomLocalization(services); }
پس از آن نیاز است میانافزار بومی سازی را نیز فعال کرد. متد UseCustomRequestLocalization زیر، اینکار را انجام میدهد:
public static class LocalizationConfig { public static IApplicationBuilder UseCustomRequestLocalization(this IApplicationBuilder app) { var requestLocalizationOptions = new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture(new CultureInfo("fa-IR")), SupportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("fa-IR") }, SupportedUICultures = new[] { new CultureInfo("en-US"), new CultureInfo("fa-IR") } }; app.UseRequestLocalization(requestLocalizationOptions); return app; } }
public class Startup { public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); app.UseCustomRequestLocalization(); app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } }
تعریف مدل برنامه به همراه ویژگیهای بومی سازی شده
در اینجا تعریف RegisterModel را مشاهده میکنید که ErrorMessageهای آن هرچند به ظاهر یک رشتهی معمولی هستند، اما در عمل از فایل منبع اشتراکی خوانده میشوند:
using System.ComponentModel.DataAnnotations; namespace Core3xSharedResource.Models.Account { public class RegisterModel { [Required(ErrorMessage = "Please enter an email address")] // -->> from the shared resources [EmailAddress(ErrorMessage = "Please enter a valid email address")] // -->> from the shared resources public string Email { get; set; } } }
فایل resx ما دارای یک چنین کلیدهایی است:
<?xml version="1.0" encoding="utf-8"?> <root> <data name="<b>Hello</b><i> {0}</i>" xml:space="preserve"> <value><b>سلام</b><i> {0}</i></value> </data> <data name="About Title" xml:space="preserve"> <value>درباره</value> </data> <data name="DNT" xml:space="preserve"> <value>.NET Tips</value> </data> <data name="SiteName" xml:space="preserve"> <value>DNT</value> </data> <data name="Please enter an email address" xml:space="preserve"> <value>لطفا ایمیلی را وارد کنید</value> </data> <data name="Please enter a valid email address" xml:space="preserve"> <value>لطفا ایمیل معتبری را وارد کنید</value> </data> </root>
آزمایش برنامه
اکنون برنامهی Web API، برای آزمایش آمادهاست. برای مثال در کنترلر زیر، سرویس عمومی IStringLocalizer به سازندهی کلاس تزریق شدهاست و سپس قصد بازگشت مقدار کلید «About Title» را دارد. همچنین خطاهای بومی شدهی مدل برنامه را نیز بررسی میکنیم:
using Core3xSharedResource.Models.Account; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; namespace Core3xSharedResource.WebApi.Controllers { [ApiController] [Route("[controller]")] public class NormalIStringLocalizerController : ControllerBase { private readonly IStringLocalizer _localizer; public NormalIStringLocalizerController(IStringLocalizer localizer) { _localizer = localizer; } [HttpGet] public ActionResult<string> Get() { var localizedString = _localizer["About Title"]; if (localizedString.ResourceNotFound) { return NotFound($"The localization resource with ID:`{localizedString.Name}` not found. SearchedLocation: `{localizedString.SearchedLocation}`."); } return localizedString.Value; } [HttpPost] public ActionResult<RegisterModel> Post(RegisterModel model) { return model; } } }
حالت get را در تصویر فوق مشاهده میکنید. در Web API برای تنظیم زبان مورد استفاده میتوان از هدری به نام Accept-Language استفاده کرد که برای مثال در اینجا به fa تنظیم شدهاست و نتیجهی آن مراجعه به فایل SharedResource.fa.resx خواهد بود. اگر en-us وارد شود، نیاز خواهد بود تا فایل منبع اشتراکی دیگری را تعریف کنید. البته اگر این هدر تنظیم نشود، با توجه به تنظیمات متد UseCustomRequestLocalization، مقدار پیشفرض آن همان fa-IR خواهد بود.
حالت post را نیز در تصویر زیر میتوان مشاهده کرد:
در اینجا چون ایمیل وارد نشده، هر دو خطای تنظیم شدهی در مدل برنامه را دریافت کردهایم و این خطاها نیز فارسی هستند. به این معنا که بومی سازی data annotations نیز به درستی کار میکند.
تعریف یک سرویس عمومی برای محصور سازی قابلیتهای بومی سازی، در برنامههای Web API
در ادامه تعریف سرویس SharedResourceService را مشاهده میکنید که ثبت آنرا پیشتر انجام دادیم:
using System; using System.Collections.Generic; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Http; namespace Core3xSharedResource.Services { public interface ISharedResourceService { string this[string index] { get; } IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures); string GetString(string name, params object[] arguments); string GetString(string name); } public class SharedResourceService : ISharedResourceService { private readonly IStringLocalizer _sharedLocalizer; private readonly ILogger<SharedResourceService> _logger; private readonly IHttpContextAccessor _httpContextAccessor; public SharedResourceService( IStringLocalizer sharedHtmlLocalizer, IHttpContextAccessor httpContextAccessor, ILogger<SharedResourceService> logger ) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _sharedLocalizer = sharedHtmlLocalizer ?? throw new ArgumentNullException(nameof(sharedHtmlLocalizer)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) { return _sharedLocalizer.GetAllStrings(includeParentCultures); } public string this[string index] => GetString(index); public string GetString(string name, params object[] arguments) { var result = _sharedLocalizer.GetString(name, arguments); logError(name, result); return result; } private void logError(string name, LocalizedString result) { if (result.ResourceNotFound) { var acceptLanguage = _httpContextAccessor?.HttpContext?.Request?.Headers["Accept-Language"]; _logger.LogError($"The localization resource with Accept-Language:`{acceptLanguage}` & ID:`{name}` not found. SearchedLocation: `{result.SearchedLocation}`."); } } public string GetString(string name) { var result = _sharedLocalizer.GetString(name); logError(name, result); return result; } } }
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Core3xSharedResource.zip
private static void disposedContext() { using (var context = new MyContext()) { Debug.WriteLine("Posts count: " + context.BlogPosts.Count()); } } private static void nonDisposedContext() { var context = new MyContext(); Debug.WriteLine("Posts count: " + context.BlogPosts.Count()); }
سؤال: در یک برنامهی بزرگ چطور میتوان لیست Contextهای Dispose نشده را یافت؟
در EF 6 با تعریف یک IDbConnectionInterceptor سفارشی میتوان به متدهای باز، بسته و dispose شدن یک Connection دسترسی یافت. اگر Context ایی dispose نشده باشد، اتصال آن نیز dispose نخواهد شد.
using System.Data; using System.Data.Common; using System.Data.Entity.Infrastructure.Interception; namespace EFNonDisposedContext.Core { public class DatabaseInterceptor : IDbConnectionInterceptor { public void Closed(DbConnection connection, DbConnectionInterceptionContext interceptionContext) { Connections.AddOrUpdate(connection, ConnectionStatus.Closed); } public void Disposed(DbConnection connection, DbConnectionInterceptionContext interceptionContext) { Connections.AddOrUpdate(connection, ConnectionStatus.Disposed); } public void Opened(DbConnection connection, DbConnectionInterceptionContext interceptionContext) { Connections.AddOrUpdate(connection, ConnectionStatus.Opened); } // the rest of the IDbConnectionInterceptor methods ... } }
مشکل مهم! در زمان فراخوانی متد Disposed، دقیقا کدام DbConnection باز شده، رها شدهاست؟
پاسخ به این سؤال را در مطلب «ایجاد خواص الحاقی» میتوانید مطالعه کنید. با استفاده از یک ConditionalWeakTable به هر کدام از اشیاء DbConnection یک Id را انتساب خواهیم داد و پس از آن به سادگی میتوان وضعیت این Id را ردگیری کرد.
برای این منظور، لیستی از ConnectionInfo را تشکیل خواهیم داد:
public enum ConnectionStatus { None, Opened, Closed, Disposed } public class ConnectionInfo { public string ConnectionId { set; get; } public string StackTrace { set; get; } public ConnectionStatus Status { set; get; } public override string ToString() { return string.Format("{0}:{1} [{2}]",ConnectionId, Status, StackTrace); } }
StackTrace توسط نکتهی مطلب «کدام سلسله متدها، متد جاری را فراخوانی کردهاند؟ » تهیه میشود.
Status نیز وضعیت جاری اتصال است که بر اساس متدهای فراخوانی شده در پیاده سازی IDbConnectionInterceptor مشخص میگردد.
در پایان کار برنامه فقط باید یک گزارش تهیه کنیم از لیست ConnectionInfoهایی که Status آنها مساوی Disposed نیست. این موارد با توجه به مشخص بودن Stack trace هر کدام، دقیقا محل متدی را که در آن context مورد استفاده dispose نشدهاست، مشخص میکنند.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید
EFNonDisposedContext.zip
1- چگونه Areaهای استاندارد را تبدیل به یک افزونهی مجزا و منتقل شدهی به یک اسمبلی دیگر کنیم.
2- چگونه ساختار پایهای را جهت تامین نیازهای هر افزونه جهت تزریق وابستگیها تا ثبت مسیریابیها و امثال آن تدارک ببینیم.
3- چگونه فایلهای CSS ، JS و همچنین تصاویر ثابت هر افزونه را داخل اسمبلی آن قرار دهیم تا دیگر نیازی به ارائهی مجزای آنها نباشد.
4- چگونه Entity Framework Code-First را با این طراحی یکپارچه کرده و از آن جهت یافتن خودکار مدلها و موجودیتهای خاص هر افزونه استفاده کنیم؛ به همراه مباحث Migrations خودکار و همچنین پیاده سازی الگوی واحد کار.
در مطلب جاری، موارد اول و دوم بررسی خواهند شد. پیشنیازهای آن مطالب ذیل هستند:
الف) منظور از یک Area چیست؟
ب) توزیع پروژههای ASP.NET MVC بدون ارائه فایلهای View آن
ج) آشنایی با تزریق وابستگیها در ASP.NET MVC و همچنین اصول طراحی یک سیستم افزونه پذیر به کمک StructureMap
د) آشنایی با رخدادهای Build
تبدیل یک Area به یک افزونهی مستقل
روشهای زیادی برای خارج کردن Areaهای استاندارد ASP.NET MVC از یک پروژه و قرار دادن آنها در اسمبلیهای دیگر وجود دارند؛ اما در حال حاضر تنها روشی که نگهداری میشود و همچنین اعضای آن همان اعضای تیم نیوگت و ASP.NET MVC هستند، همان روش استفاده از Razor Generator است.
بنابراین ساختار ابتدایی پروژهی افزونه پذیر ما به صورت ذیل خواهد بود:
1) ابتدا افزونهی Razor Generator را نصب کنید.
2) سپس یک پروژهی معمولی ASP.NET MVC را آغاز کنید. در این سری نام MvcPluginMasterApp برای آن در نظر گرفته شدهاست.
3) در ادامه یک پروژهی معمولی دیگر ASP.NET MVC را نیز به پروژهی جاری اضافه کنید. برای مثال نام آن در اینجا MvcPluginMasterApp.Plugin1 تنظیم شدهاست.
4) به پروژهی MvcPluginMasterApp.Plugin1 یک Area جدید و معمولی را به نام NewsArea اضافه کنید.
5) از پروژهی افزونه، تمام پوشههای غیر Area را حذف کنید. پوشههای Controllers و Models و Views حذف خواهند شد. همچنین فایل global.asax آنرا نیز حذف کنید. هر افزونه، کنترلرها و Viewهای خود را از طریق Area مرتبط دریافت میکند و در این حالت دیگر نیازی به پوشههای Controllers و Models و Views واقع شده در ریشهی اصلی پروژهی افزونه نیست.
6) در ادامه کنسول پاور شل نیوگت را باز کرده و دستور ذیل را صادر کنید:
PM> Install-Package RazorGenerator.Mvc
همانطور که در تصویر نیز مشخص شدهاست، برای اجرای دستور نصب RazorGenerator.Mvc نیاز است هربار پروژهی پیش فرض را تغییر دهید.
7) اکنون پس از نصب RazorGenerator.Mvc، نوبت به اجرای آن بر روی هر دو پروژهی اصلی و افزونه است:
PM> Enable-RazorGenerator
همچنین هربار که View جدیدی اضافه میشود نیز باید اینکار را تکرار کنید یا اینکه مطابق شکل زیر، به خواص View جدید مراجعه کرده و Custom tool آنرا به صورت دستی به RazorGenerator تنظیم نمائید. دستور Enable-RazorGenerator اینکار را به صورت خودکار انجام میدهد.
تا اینجا موفق شدیم Viewهای افزونه را داخل فایل dll آن مدفون کنیم. به این ترتیب با کپی کردن افزونه به پوشهی bin پروژهی اصلی، دیگر نیازی به ارائهی فایلهای View آن نیست و تمام اطلاعات کنترلرها، مدلها و Viewها به صورت یکجا از فایل dll افزونهی ارائه شده خوانده میشوند.
کپی کردن خودکار افزونه به پوشهی Bin پروژهی اصلی
پس از اینکه ساختار اصلی کار شکل گرفت، هربار پس از کامپایل افزونه (یا افزونهها)، نیاز است فایلهای پوشهی bin آنرا به پوشهی bin پروژهی اصلی کپی کنیم (پروژهی اصلی در این حالت هیچ ارجاع مستقیمی را به افزونهی جدید نخواهد داشت). برای خودکار سازی این کار، به خواص پروژهی افزونه مراجعه کرده و قسمت Build events آنرا به نحو ذیل تنظیم کنید:
در اینجا دستور ذیل در قسمت Post-build event نوشته شده است:
Copy "$(ProjectDir)$(OutDir)$(TargetName).*" "$(SolutionDir)MvcPluginMasterApp\bin\"
تنظیم فضاهای نام کلیه مسیریابیهای پروژه
در همین حالت اگر پروژه را اجرا کنید، موتور ASP.NET MVC به صورت خودکار اطلاعات افزونهی کپی شده به پوشهی bin را دریافت و به Application domain جاری اعمال میکند؛ برای اینکار نیازی به کد نویسی اضافهتری نیست و خودکار است. برای آزمایش آن فقط کافی است یک break point را داخل کلاس RazorGeneratorMvcStart افزونه قرار دهید.
اما ... پس از اجرا، بلافاصله پیام تداخل فضاهای نام را دریافت میکنید. خطاهای حاصل عنوان میکند که در App domain جاری، دو کنترلر Home وجود دارند؛ یکی در پروژهی اصلی و دیگری در پروژهی افزونه و مشخص نیست که مسیریابیها باید به کدامیک ختم شوند.
برای رفع این مشکل، به فایل NewsAreaAreaRegistration.cs پروژهی افزونه مراجعه کرده و مسیریابی آنرا به نحو ذیل تکمیل کنید تا فضای نام اختصاصی این Area صریحا مشخص گردد.
using System.Web.Mvc; namespace MvcPluginMasterApp.Plugin1.Areas.NewsArea { public class NewsAreaAreaRegistration : AreaRegistration { public override string AreaName { get { return "NewsArea"; } } public override void RegisterArea(AreaRegistrationContext context) { context.MapRoute( "NewsArea_default", "NewsArea/{controller}/{action}/{id}", // تکمیل نام کنترلر پیش فرض new { controller = "Home", action = "Index", id = UrlParameter.Optional }, // مشخص کردن فضای نام مرتبط جهت جلوگیری از تداخل با سایر قسمتهای برنامه namespaces: new[] { string.Format("{0}.Controllers", this.GetType().Namespace) } ); } } }
using System.Web.Mvc; using System.Web.Routing; namespace MvcPluginMasterApp { public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }, // مشخص کردن فضای نام مرتبط جهت جلوگیری از تداخل با سایر قسمتهای برنامه namespaces: new[] { string.Format("{0}.Controllers", typeof(RouteConfig).Namespace) } ); } } }
طراحی قرارداد پایه افزونهها
تا اینجا با نحوهی تشکیل ساختار هر پروژهی افزونه آشنا شدیم. اما هر افزونه در آینده نیاز به مواردی مانند منوی اختصاصی در منوی اصلی سایت، تنظیمات مسیریابی اختصاصی، تنظیمات EF و امثال آن نیز خواهد داشت. به همین منظور، یک پروژهی class library جدید را به نام MvcPluginMasterApp.PluginsBase آغاز کنید.
سپس قرار داد IPlugin را به نحو ذیل به آن اضافه نمائید:
using System; using System.Reflection; using System.Web.Optimization; using System.Web.Routing; using StructureMap; namespace MvcPluginMasterApp.PluginsBase { public interface IPlugin { EfBootstrapper GetEfBootstrapper(); MenuItem GetMenuItem(RequestContext requestContext); void RegisterBundles(BundleCollection bundles); void RegisterRoutes(RouteCollection routes); void RegisterServices(IContainer container); } public class EfBootstrapper { /// <summary> /// Assemblies containing EntityTypeConfiguration classes. /// </summary> public Assembly[] ConfigurationsAssemblies { get; set; } /// <summary> /// Domain classes. /// </summary> public Type[] DomainEntities { get; set; } /// <summary> /// Custom Seed method. /// </summary> //public Action<IUnitOfWork> DatabaseSeeder { get; set; } } public class MenuItem { public string Name { set; get; } public string Url { set; get; } } }
PM> install-package EntityFramework PM> install-package Microsoft.AspNet.Web.Optimization PM> install-package structuremap.web
توضیحات قرار داد IPlugin
از این پس هر افزونه باید دارای کلاسی باشد که از اینترفیس IPlugin مشتق میشود. برای مثال فعلا کلاس ذیل را به افزونهی پروژه اضافه نمائید:
using System.Web.Mvc; using System.Web.Optimization; using System.Web.Routing; using MvcPluginMasterApp.PluginsBase; using StructureMap; namespace MvcPluginMasterApp.Plugin1 { public class Plugin1 : IPlugin { public EfBootstrapper GetEfBootstrapper() { return null; } public MenuItem GetMenuItem(RequestContext requestContext) { return new MenuItem { Name = "Plugin 1", Url = new UrlHelper(requestContext).Action("Index", "Home", new { area = "NewsArea" }) }; } public void RegisterBundles(BundleCollection bundles) { //todo: ... } public void RegisterRoutes(RouteCollection routes) { //todo: add custom routes. } public void RegisterServices(IContainer container) { // todo: add custom services. container.Configure(cfg => { //cfg.For<INewsService>().Use<EfNewsService>(); }); } } }
برای اینکه هر افزونه در منوی اصلی ظاهر شود، نیاز به یک نام، به همراه آدرسی به صفحهی اصلی آن خواهد داشت. به همین جهت در متد GetMenuItem نحوهی ساخت آدرسی را به اکشن متد Index کنترلر Home واقع در Areaایی به نام NewsArea، مشاهده میکنید.
بارگذاری و تشخیص خودکار افزونهها
پس از اینکه هر افزونه دارای کلاسی مشتق شده از قرارداد IPlugin شد، نیاز است آنها را به صورت خودکار یافته و سپس پردازش کنیم. اینکار را به کتابخانهی StructureMap واگذار خواهیم کرد. برای این منظور پروژهی جدیدی را به نام MvcPluginMasterApp.IoCConfig آغاز کرده و سپس تنظیمات آنرا به نحو ذیل تغییر دهید:
using System; using System.IO; using System.Threading; using System.Web; using MvcPluginMasterApp.PluginsBase; using StructureMap; using StructureMap.Graph; namespace MvcPluginMasterApp.IoCConfig { public static class SmObjectFactory { private static readonly Lazy<Container> _containerBuilder = new Lazy<Container>(defaultContainer, LazyThreadSafetyMode.ExecutionAndPublication); public static IContainer Container { get { return _containerBuilder.Value; } } private static Container defaultContainer() { return new Container(cfg => { cfg.Scan(scanner => { scanner.AssembliesFromPath( path: Path.Combine(HttpRuntime.AppDomainAppPath, "bin"), // یک اسمبلی نباید دوبار بارگذاری شود assemblyFilter: assembly => { return !assembly.FullName.Equals(typeof(IPlugin).Assembly.FullName); }); scanner.WithDefaultConventions(); //Connects 'IName' interface to 'Name' class automatically. scanner.AddAllTypesOf<IPlugin>().NameBy(item => item.FullName); }); }); } } }
PM> install-package EntityFramework PM> install-package structuremap.web
کاری که در کلاس SmObjectFactory انجام شده، بسیار ساده است. مسیر پوشهی Bin پروژهی اصلی به structuremap معرفی شدهاست. سپس به آن گفتهایم که تنها اسمبلیهایی را که دارای اینترفیس IPlugin هستند، به صورت خودکار بارگذاری کن. در ادامه تمام نوعهای IPlugin را نیز به صورت خودکار یافته و در مخزن تنظیمات خود، اضافه کن.
تامین نیازهای مسیریابی و Bundling هر افزونه به صورت خودکار
در ادامه به پروژهی اصلی مراجعه کرده و در پوشهی App_Start آن کلاس ذیل را اضافه کنید:
using System.Linq; using System.Web.Optimization; using System.Web.Routing; using MvcPluginMasterApp; using MvcPluginMasterApp.IoCConfig; using MvcPluginMasterApp.PluginsBase; [assembly: WebActivatorEx.PostApplicationStartMethod(typeof(PluginsStart), "Start")] namespace MvcPluginMasterApp { public static class PluginsStart { public static void Start() { var plugins = SmObjectFactory.Container.GetAllInstances<IPlugin>().ToList(); foreach (var plugin in plugins) { plugin.RegisterServices(SmObjectFactory.Container); plugin.RegisterRoutes(RouteTable.Routes); plugin.RegisterBundles(BundleTable.Bundles); } } } }
دراینجا با استفاده از کتابخانهای به نام WebActivatorEx (که باز هم توسط نویسندگان اصلی Razor Generator تهیه شدهاست)، یک متد PostApplicationStartMethod سفارشی را تعریف کردهایم.
مزیت استفاده از اینکار این است که فایل Global.asax.cs برنامه شلوغ نخواهد شد. در غیر اینصورت باید تمام این کدها را در انتهای متد Application_Start قرار میدادیم.
در اینجا با استفاده از structuremap، تمام افزونههای موجود به صورت خودکار بررسی شده و سپس پیشنیازهای مسیریابی و Bundling و همچنین تنظیمات IoC Container مورد نیاز آنها به هر افزونه به صورت مستقل، تزریق خواهد شد.
اضافه کردن منوهای خودکار افزونهها به پروژهی اصلی
پس از اینکه کار پردازش اولیهی IPluginها به پایان رسید، اکنون نوبت به نمایش آدرس اختصاصی هر افزونه در منوی اصلی سایت است. برای این منظور فایل جدیدی را به نام PluginsMenu.cshtml_، در پوشهی shared پروژهی اصلی اضافه کنید؛ با این محتوا:
@using MvcPluginMasterApp.IoCConfig @using MvcPluginMasterApp.PluginsBase @{ var plugins = SmObjectFactory.Container.GetAllInstances<IPlugin>().ToList(); } @foreach (var plugin in plugins) { var menuItem = plugin.GetMenuItem(this.Request.RequestContext); <li> <a href="@menuItem.Url">@menuItem.Name</a> </li> }
سپس به فایل Layout.cshtml_ پروژهی اصلی مراجعه و توسط فراخوانی Html.RenderPartial، آنرا در بین سایر آیتمهای منوی اصلی اضافه میکنیم:
<div class="navbar navbar-inverse navbar-fixed-top"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> @Html.ActionLink("MvcPlugin Master App", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" }) </div> <div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li>@Html.ActionLink("Master App/Home", "Index", "Home", new {area = ""}, null)</li> @{ Html.RenderPartial("_PluginsMenu"); } </ul> </div> </div> </div>
بنابراین به صورت خلاصه
1) هر افزونه، یک پروژهی کامل ASP.NET MVC است که پوشههای ریشهی اصلی آن حذف شدهاند و اطلاعات آن توسط یک Area جدید تامین میشوند.
2) تنظیم فضای نام مسیریابیهای تمام پروژهها را فراموش نکنید. در غیر اینصورت شاهد تداخل پردازش کنترلرهای هم نام خواهید بود.
3) جهت سهولت کار، میتوان فایلهای bin هر افزونه را توسط رخداد post-build، به پوشهی bin پروژهی اصلی کپی کرد.
4) Viewهای هر افزونه توسط Razor Generator در فایل dll آن مدفون خواهند شد.
5) هر افزونه باید دارای کلاسی باشد که اینترفیس IPlugin را پیاده سازی میکند. از این اینترفیس برای ثبت اطلاعات هر افزونه یا دریافت اطلاعات سفارشی از آن کمک میگیریم.
6) با استفاده از استراکچرمپ و قرارداد IPlugin، منوهای هر افزونه را به صورت خودکار یافته و سپس به فایل layout اصلی اضافه میکنیم.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید:
MvcPluginMasterApp-Part1.zip
مدیریت بهینهی سشن فکتوری
ساخت یک شیء SessionFactory بسیار پر هزینه و زمانبر است. به همین جهت لازم است که این شیء یکبار حین آغاز برنامه ایجاد شده و سپس در پایان کار برنامه تخریب شود. انجام اینکار در برنامههای معمولی ویندوزی (WinForms ،WPF و ...)، ساده است اما در محیط Stateless وب و برنامههای ASP.Net ، نیاز به راه حلی ویژه وجود خواهد داشت و تمرکز اصلی این مقاله حول مدیریت صحیح سشن فکتوری در برنامههای ASP.Net است.
برای پیاده سازی شیء سشن فکتوری به صورتی که یکبار در طول برنامه ایجاد شود و بارها مورد استفاده قرار گیرد باید از یکی از الگوهای معروف طراحی برنامه نویسی شیء گرا به نام Singleton Pattern استفاده کرد. پیاده سازی نمونهی thread safe آن که در برنامههای ذاتا چند ریسمانی وب و همچنین برنامههای معمولی ویندوزی میتواند مورد استفاده قرار گیرد، در آدرس ذیل قابل مشاهده است:
از پنجمین روش ذکر شده در این مقاله جهت ایجاد یک lazy, lock-free, thread-safe singleton استفاده خواهیم کرد.
بررسی مدل برنامه
در این مدل ساده ما یک یا چند پارکینگ داریم که در هر پارکینگ یک یا چند خودرو میتوانند پارک شوند.
یک برنامه ASP.Net را آغاز کرده و ارجاعاتی را به اسمبلیهای زیر به آن اضافه نمائید:
FluentNHibernate.dll
NHibernate.dll
NHibernate.ByteCode.Castle.dll
NHibernate.Linq.dll
و همچنین ارجاعی به اسمبلی استاندارد System.Data.Services.dll دات نت فریم ورک سه و نیم
تصویر نهایی پروژه ما به شکل زیر خواهد بود:
پروژه ما دارای یک پوشه domain ، تعریف کننده موجودیتهای برنامه جهت تهیه نگاشتهای لازم از روی آنها است. سپس یک پوشه جدید را به نام NHSessionManager به آن جهت ایجاد یک Http module مدیریت کننده سشنهای NHibernate در برنامه اضافه خواهیم کرد.
ساختار دومین برنامه (مطابق کلاس دیاگرام فوق):
namespace NHSample3.Domain
{
public class Car
{
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual string Color { get; set; }
}
}
using System.Collections.Generic;
namespace NHSample3.Domain
{
public class Parking
{
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual string Location { get; set; }
public virtual IList<Car> Cars { get; set; }
public Parking()
{
Cars = new List<Car>();
}
}
}
در این قسمت قصد داریم Http Module ایی را جهت مدیریت سشنهای NHibernate ایجاد نمائیم.
در ابتدا کلاس Config را در پوشه مدیریت سشن NHibernate با محتویات زیر ایجاد کنید:
using FluentNHibernate.Automapping;
using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;
using NHibernate.Tool.hbm2ddl;
namespace NHSessionManager
{
public class Config
{
public static FluentConfiguration GetConfig()
{
return
Fluently.Configure()
.Database(
MsSqlConfiguration
.MsSql2008
.ConnectionString(x => x.FromConnectionStringWithKey("DbConnectionString"))
)
.ExposeConfiguration(
x => x.SetProperty("current_session_context_class", "managed_web")
)
.Mappings(
m => m.AutoMappings.Add(
new AutoPersistenceModel()
.Where(x => x.Namespace.EndsWith("Domain"))
.AddEntityAssembly(typeof(NHSample3.Domain.Car).Assembly))
);
}
public static void CreateDb()
{
bool script = false;//آیا خروجی در کنسول هم نمایش داده شود
bool export = true;//آیا بر روی دیتابیس هم اجرا شود
bool dropTables = false;//آیا جداول موجود دراپ شوند
new SchemaExport(GetConfig().BuildConfiguration()).Execute(script, export, dropTables);
}
}
}
دو نکته جدید در متد GetConfig وجود دارد:
الف) استفاده از متد FromConnectionStringWithKey ، بجای تعریف مستقیم کانکشن استرینگ در متد مذکور که روشی است توصیه شده. به این صورت فایل وب کانفیگ ما باید دارای تعریف کلید مشخص شده در متد GetConfig به نام DbConnectionString باشد:
<connectionStrings>
<!--NHSessionManager-->
<add name="DbConnectionString"
connectionString="Data Source=(local);Initial Catalog=HelloNHibernate;Integrated Security = true" />
</connectionStrings>
در اینجا به AutoMapper خواهیم گفت که قصد داریم از امکانات مدیریت سشن مخصوص وب فریم ورک NHibernate استفاده کنیم. فریم ورک NHibernate دارای کلاسی است به نام NHibernate.Context.ManagedWebSessionContext که جهت مدیریت سشنهای خود در پروژههای وب ASP.Net پیش بینی کرده است و از این متد در Http module ایی که ایجاد خواهیم کرد جهت ردگیری سشن جاری آن کمک خواهیم گرفت.
اگر متد CreateDb را فراخوانی کنیم، جداول نگاشت شده به کلاسهای پوشه دومین برنامه، به صورت خودکار ایجاد خواهند شد که دیتابیس دیاگرام آن به صورت زیر میباشد:
سپس کلاس SingletonCore را جهت تهیه تنها و تنها یک وهله از شیء سشن فکتوری در کل برنامه ایجاد خواهیم کرد (همانطور که عنوان شده، ایده پیاده سازی این کلاس thread safe ، از مقاله معرفی شده در ابتدای بحث گرفته شده است):
using NHibernate;
namespace NHSessionManager
{
/// <summary>
/// lazy, lock-free, thread-safe singleton
/// </summary>
public class SingletonCore
{
private readonly ISessionFactory _sessionFactory;
SingletonCore()
{
_sessionFactory = Config.GetConfig().BuildSessionFactory();
}
public static SingletonCore Instance
{
get
{
return Nested.instance;
}
}
public static ISession GetCurrentSession()
{
return Instance._sessionFactory.GetCurrentSession();
}
public static ISessionFactory SessionFactory
{
get { return Instance._sessionFactory; }
}
class Nested
{
// Explicit static constructor to tell C# compiler
// not to mark type as beforefieldinit
static Nested()
{
}
internal static readonly SingletonCore instance = new SingletonCore();
}
}
}
using System;
using System.Web;
using NHibernate;
using NHibernate.Context;
namespace NHSessionManager
{
public class SessionModule : IHttpModule
{
public void Dispose()
{ }
public void Init(HttpApplication context)
{
if (context == null)
throw new ArgumentNullException("context");
context.BeginRequest += Application_BeginRequest;
context.EndRequest += Application_EndRequest;
}
private void Application_BeginRequest(object sender, EventArgs e)
{
ISession session = SingletonCore.SessionFactory.OpenSession();
ManagedWebSessionContext.Bind(HttpContext.Current, session);
session.BeginTransaction();
}
private void Application_EndRequest(object sender, EventArgs e)
{
ISession session = ManagedWebSessionContext.Unbind(
HttpContext.Current, SingletonCore.SessionFactory);
if (session == null) return;
try
{
if (session.Transaction != null &&
!session.Transaction.WasCommitted &&
!session.Transaction.WasRolledBack)
{
session.Transaction.Commit();
}
else
{
session.Flush();
}
}
catch (Exception)
{
session.Transaction.Rollback();
}
finally
{
if (session != null && session.IsOpen)
{
session.Close();
session.Dispose();
}
}
}
}
}
در متد Application_BeginRequest ، در ابتدای هر درخواست یک سشن جدید ایجاد و به مدیریت سشن وب NHibernate بایند میشود، همچنین یک تراکنش نیز آغاز میگردد. سپس در پایان درخواست، این انقیاد فسخ شده و تراکنش کامل میشود، همچنین کار پاکسازی اشیاء نیز صورت خواهد گرفت.
با توجه به این موارد، دیگر نیازی به ذکر using جهت dispose کردن سشن جاری در کدهای ما نخواهد بود، زیرا در پایان هر درخواست اینکار به صورت خودکار صورت میگیرد. همچنین نیازی به ذکر تراکنش نیز نمیباشد، چون مدیریت آنرا خودکار کردهایم.
جهت استفاده از این Http module تهیه شده باید چند سطر زیر را به وب کانفیگ برنامه اضافه کرد:
<httpModules>
<!--NHSessionManager-->
<add name="SessionModule" type="NHSessionManager.SessionModule"/>
</httpModules>
اکنون مثالی از نحوهی استفاده از امکانات فراهم شده فوق به صورت زیر میتواند باشد:
ابتدا کلاس ParkingContext را جهت مدیریت مطلوبتر LINQ to NHibernate تشکیل میدهیم.
using System.Linq;
using NHibernate;
using NHibernate.Linq;
using NHSample3.Domain;
namespace NHSample3
{
public class ParkingContext : NHibernateContext
{
public ParkingContext(ISession session)
: base(session)
{ }
public IOrderedQueryable<Car> Cars
{
get { return Session.Linq<Car>(); }
}
public IOrderedQueryable<Parking> Parkings
{
get { return Session.Linq<Parking>(); }
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using NHibernate;
using NHSample3.Domain;
using NHSessionManager;
namespace NHSample3
{
public partial class _Default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
//ایجاد دیتابیس در صورت نیاز
//Config.CreateDb();
//ثبت یک سری رکورد در دیتابیس
ISession session = SingletonCore.GetCurrentSession();
Car car1 = new Car() { Name = "رنو", Color = "مشکلی" };
session.Save(car1);
Car car2 = new Car() { Name = "پژو", Color = "سفید" };
session.Save(car2);
Parking parking1 = new Parking()
{
Location = "آدرس پارکینگ مورد نظر",
Name = "پارکینگ یک",
Cars = new List<Car> { car1, car2 }
};
session.Save(parking1);
//نمایش حاصل در یک گرید ویوو
ParkingContext db = new ParkingContext(session);
var query = from x in db.Cars select new { CarName = x.Name, CarColor = x.Color };
GridView1.DataSource = query.ToList();
GridView1.DataBind();
}
}
}
در برنامههای ویندوزی مانند WinForms ، WPF و غیره، تا زمانیکه یک فرم باز باشد، کل فرم و اشیاء مرتبط با آن به یکباره تخریب نخواهند شد، اما در یک برنامه ASP.Net جهت حفظ منابع سرور در یک محیط چند کاربره، پس از پایان نمایش یک صفحه وب، اثری از آثار اشیاء تعریف شده در کدهای آن صفحه در سرور وجود نداشته و همگی بلافاصله تخریب میشوند. به همین جهت بحثهای ویژه state management در ASP.Net در اینباره مطرح است و مدیریت ویژهای باید روی آن صورت گیرد که در قسمت قبل مطرح شد.
از بحث فوق، تنها استفاده از کلاسهای Config و SingletonCore ، جهت استفاده و مدیریت بهینهی سشن فکتوری در برنامههای ویندوزی کفایت میکنند.
دریافت سورس برنامه قسمت هفتم
ادامه دارد ....
- وقتی دیتایی داریم که به تکرار از آن در برنامه استفاده میکنیم.
- وقتی بعد از گرفتن دیتایی از دیتابیس، محاسباتی بر روی آن انجام میدهیم و پاسخ نهایی محاسبه را به کاربر نمایش میدهیم، میتوانیم یکبار پاسخ را کش کنیم تا از محاسبهی هر بارهی آن جلوگیری شود.
- سخت افزاری که برای کش استفاده میکنیم یعنی Ram، بسیار گرانتر از دیتابیس برای ما تمام میشود؛ چرا که محدود است.
- اگر همه دیتاهارا کش کنید، عمل سرچ میان آن زمان بیشتری خواهد برد.
این روش تا زمانیکه برنامهی ما برای اجرا شدن، تنها از یک سرور استفاده کند، بهترین انتخاب خواهد بود؛ چرا که به دلیل نزدیک بودن، سریعترین بازخورد را نیز به درخواستها ارائه میدهد.
اما شرایطی را فرض کنید که برنامه از چندین سرور برای اجرا شدن استفاده میکند و به طبع هر سرور درخواستهای خودش را داراست که ما باید برای هر یک بصورت جداگانهای یک کش In-Memory را در حافظه Ram هرکدام ایجاد کنیم.
فرض کنید دیتای ما 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 باشد. بخشی از دیتا در Server 1 کش میشود (1 , 3 , 5 , 9) و بخشی دیگر در Server 2 کش خواهد شد (2 , 4 , 6 ,7 , 8 , 10).
در اینجا مشکلات و ضعف هایی به وجود خواهد آمد :
- برای مثال اگر Server 1 به هر دلیلی از بین برود یا Down شود، اطلاعات کش درون آن نیز پاک خواهد شد و بعد از راه اندازی باید همه آن را دوباره از دیتابیس بخواند.
- هر کدام از سرورها کشهای جدایی دارند و باهم Sync نیستند و امکان وجود یک دادهی حیاتی در یکی و عدم وجود آن در دیگری، بالاست. فرض کنید برنامه برای هر درخواست، نیاز به اطلاعات دسترسی کاربری را دارد. دسترسیهای کاربر، در Server 1 کش شده، اما در Server 2 موجود نیست. در Server 2 به دلیل عدم وجود این کش، برنامه برای درخواستهای معمول خود و چک کردن دسترسی کاربر یا باید هربار به دیتابیس درخواستی را ارسال کند که این برخلاف خواسته ماست و یا باید دیتای مربوط به دسترسیهای کاربر را بعد از یکبار درخواست، از دیتابیس در خودش کش کند که اینهم دوباره کاری به حساب میاید و دوبار کش کردن یک دیتا، امر مطلوبی نخواهد بود.
روش هایی وجود دارد که بتوان از سیستم Local Caching در حالت چند سروری هم استفاده کرد و این مشکلات را از بین برد، اما روش استاندارد در حالت چند سروری، استفاده از Distributed Cacheها است.
روش دوم : Distributed Caching
در این روش برنامهی ما برای اجرا شدن از چندین سرور شبکه شده به هم، در حال استفاده هست و Cache برنامه، توسط سرورها به اشتراک گذاشته شده.
در این حالت سرورهای ما از یک کش عمومی استفاده میکنند که مزایای آن شامل :
■ درخواستها به چندین سرور مختلف از هم ارسال شده، اما دیتای کش بصورت منسجم در هریک وجود خواهد داشت.
■ با خراب شدن یا Down شدن یک سرور، کش موجود در سرورهای دیگر پاک نمیشود و کماکان قابل استفاده است.
■ به حافظه Ram یک سرور محدود نیست و مشکلات زیادی همچون کمبود سخت افزاری و محدودیتهای حافظهی Ram را تا حد معقولی کاهش میدهد.
طریقه استفاده از Cache در Asp.Net Core :
- بر خلاف ASP.NET web forms و ASP.NET MVC در نسخههای Core به بعد، Cache بصورت از پیش ثبت شده، وجود ندارد. کش در Asp.Net Core با فراخوانی سرویسهای مربوطهی آن قابل استفاده است و نیاز است قبل از استفاده، سرویس آن را در کلاس Startup برنامه فراخوانی کنید.
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddMemoryCache(); }
- اینترفیس IMemoryCache از سیستم تزریق وابستگیها در Core استفاده میکند و برای استفاده از اینترفیس آن، پس از اضافه کردن MemoryCache به Startup ، باید در کنترلر، عمل تزریق وابستگی (DI) را انجام دهید؛ سپس متدهای مورد نیاز برای کش، در دسترس خواهد بود.
public class HomeController : Controller { private readonly IMemoryCache _cache; public HomeController(IMemoryCache cache) { _cache = cache; } .... }
- برای ذخیرهی کش میتوانید از متد Set موجود در این اینترفیس استفاده کنید.
public IActionResult Set() { _cache.Set("CacheKey", data , TimeSpan.FromDays(1)); return View(); }
در پارامتر اول این متد (CacheKey)، یک کلید، برای اطلاعاتی که میخواهیم کش کنیم قرار میدهیم. دقت کنید که این کلید، شناسهی دیتای شماست و باید طوری آن را در نظر گرفت که با صدا زدن این کلید از سرویس کش، همان دیتای مورد نظر را برگشت دهد (هر Object دیتا، باید کلید Unique خود را داشته باشد).
در پارامتر دوم، دیتای مورد نظر را که میخواهیم کش کنیم، به متد میدهیم و در پارامتر سوم نیز زمان اعتبار و تاریخ انقضای دیتای کش شده را وارد میکنیم؛ به این معنا که دیتای کش شده، بعد از مدت زمان گفته شده، از حافظه کش(Ram) حذف شود و برای دسترسی دوباره و کش کردن دوباره اطلاعات، نیاز به خواندن مجدد از دیتابیس باشد.
- برای دسترسی به اطلاعات کش شده میتوانید از متد Get استفاده کنید.
public IActionResult Get() { string data = _cache.Get("CacheKey"); return View(data); }
تنها پارامتر ورودی این متد، کلید از قبل نسبت داده شده به اطلاعات کش هست که با استفاده از یکسان بودن کلید در ورودی این متد و کلید Set شده از قبل در حافظه Ram، دیتا مربوط به آن را برگشت میدهد.
- متد TryGetValue برای بررسی وجود یا عدم وجود یک کلید در حافظه کش هست و یک Boolean را خروجی میدهد.
public IActionResult Set() { DateTime data; // Look for cache key. if (!_cache.TryGetValue( "CacheKey" , out data)) { // Key not in cache, so get data. data= DateTime.Now; // Save data in cache and set the relative expiration time to one day _cache.Set( "CacheKey" , data, TimeSpan.FromDays(1)); } return View(data); }
این متد ابتدا بررسی میکند که کلیدی با نام "CacheKey" وجود دارد یا خیر؟ در صورت عدم وجود، آن را میسازد و دیتای مورد نظر را به آن نسبت میدهد.
- با استفاده از متد GetOrCreate میتوانید کار متدهای Get و Set را باهم انجام دهید و در یک متد، وجود یا عدم وجود کش را بررسی و در صورت وجود، مقداری را return و در صورت عدم وجود، ابتدا ایجاد کش و بعد return مقدار کش شده را انجام دهید.
public IActionResult GetOrCreate() { var data = _cache.GetOrCreate( "CacheKey" , entry => entry.SlidingExpiration = TimeSpan.FromSeconds(3); return View(data); }); return View(data); }
- برای مدیریت حافظهی Ram شما باید یک Expiration Time را برای کشهای خود مشخص کنید؛ تا هم حافظه Ram را حجیم نکنید و هم در هر بازهی زمانی، دیتای بروز را از دیتابیس بخوانید. برای این کار optionهای متفاوتی از جمله absolute expiration و sliding expiration وجود دارند.
در اینجا absolute expiration به این معنا است که یک زمان قطعی را برای منقضی شدن کشها مشخص میکند؛ به عبارتی میگوییم کش با کلید فلان، در تاریخ و ساعت فلان حذف شود. اما در sliding expiration یک بازه زمانی برای منقضی شدن کشها مشخص میکنیم؛ یعنی میگوییم بعد از گذشت فلان دقیقه از ایجاد کش، آن را حذف کن و اگر در طی این مدت مجددا خوانده شد، طول مدت زمان آن تمدید خواهد شد.
این تنظیمات را میتوانید در قالب یک option زمان Set کردن یک کش، به آن بدهید.
MemoryCacheEntryOptions options = new MemoryCacheEntryOptions(); options.AbsoluteExpiration = DateTime.Now.AddMinutes(1); options.SlidingExpiration = TimeSpan.FromMinutes(1); _cache.Set("CacheKey", data, options );
در مثال بالا هردو option اضافه شده یک کار را انجام میدهند؛ با این تفاوت که absolute expiration تاریخ now را گرفته و یک دقیقه بعد را به آن اضافه کرده و تاریخ انقضای کش را با آن تاریخ set میکند. اما sliding expiration از حالا بمدت یک دقیقه اعتبار دارد.
- یکی از روشهای مدیریت حافظه Ram در کشها این است که برای حذف شدن کشها از حافظه، اولویت بندیهایی را تعریف کنید. اولویتها در چهار سطح قابل دسترسی است:
- NeverRemove = 3
- High = 2
- Normal = 1
- Low = 0
این اولویت بندیها زمانی کاربرد خواهند داشت که حافظه اختصاصی Ram، برای کشها پر شده باشد و در این حالت سیستم کشینگ بصورت خودمختار، کشهای با الویت پایین را از حافظه حذف میکند و کشهای با الویت بیشتر، در حافظه باقی میمانند. این با شماست که الویت را برای دیتاهای خود تعیین کنید؛ پس باید با دقت و فکر شده این کار را انجام دهید.
MemoryCacheEntryOptions options = new MemoryCacheEntryOptions(); // Low / Normal / High / NeverRemove options.Priority = CacheItemPriority.High; cache.Set("CacheKey", data, options);
به این صورت میتوانید الویتهای متفاوت را در قالب option به کشهای خود اختصاص دهید.
در این مقاله سعی شد مفاهیم اولیه Cache، طوری گفته شود، تا برای افرادی که میخواهند به تازگی این سیستم را بیاموزند و در پروژههای خود استفاده کنند، کاربردی باشد و درک نسبی را نسبت به مزایا و محدودیتهای این سیستم بدست آورند.
در قسمت دوم همین مقاله بطور تخصصیتر به این مبحث میپردازیم و یک پکیج آماده را معرفی میکنیم که خیلی راحتتر و اصولیتر کش را برای ما پیاده سازی میکند.
public class EventItem { public int Id { get; set; } public int? ParentId { get; set; } public virtual EventItem Parent { get; set; } public virtual EventItem Alternative { get; set; } }
public class EventItemConfiguration : IEntityTypeConfiguration<EventItem> { public void Configure(EntityTypeBuilder<EventItem> builder) { builder.HasIndex(e => e.ParentId); builder.HasOne(e => e.Parent) .WithOne(e => e.Alternative) .HasForeignKey<EventItem>(e => e.ParentId); } }
در این قسمت قصد داریم یک فرم Ajaxایی را در ASP.NET MVC به همراه تمام مسایل اعتبارسنجی، پردازش اطلاعات و غیره را به کمک Twitter Bootstrap و jQuery Ajax پیاده سازی کنیم.
تهیه افزونه jquery.bootstrap-modal-ajax-form.js
از این جهت که مباحث مرتبط با نمایش و پردازش فرمهای مودال Ajaxایی به کمک Twitter Bootstrap اندکی نکته دار و طولانی هستند، بهتر است این موارد را به شکل یک افزونه، کپسوله کنیم. کدهای کامل افزونه jquery.bootstrap-modal-ajax-form.js را در ادامه ملاحظه میکنید:
// <![CDATA[ (function ($) { $.bootstrapModalAjaxForm = function (options) { var defaults = { renderModalPartialViewUrl: null, renderModalPartialViewData: null, postUrl: '/', loginUrl: '/login', beforePostHandler: null, completeHandler: null, errorHandler: null }; var options = $.extend(defaults, options); var validateForm = function (form) { //فعال سازی دستی اعتبار سنجی جیکوئری var val = form.validate(); val.form(); return val.valid(); }; var enableBootstrapStyleValidation = function () { $.validator.setDefaults({ highlight: function (element, errorClass, validClass) { if (element.type === 'radio') { this.findByName(element.name).addClass(errorClass).removeClass(validClass); } else { $(element).addClass(errorClass).removeClass(validClass); $(element).closest('.control-group').removeClass('success').addClass('error'); } $(element).trigger('highlited'); }, unhighlight: function (element, errorClass, validClass) { if (element.type === 'radio') { this.findByName(element.name).removeClass(errorClass).addClass(validClass); } else { $(element).removeClass(errorClass).addClass(validClass); $(element).closest('.control-group').removeClass('error').addClass('success'); } $(element).trigger('unhighlited'); } }); } var enablePostbackValidation = function () { $('form').each(function () { $(this).find('div.control-group').each(function () { if ($(this).find('span.field-validation-error').length > 0) { $(this).addClass('error'); } }); }); } var processAjaxForm = function (dialog) { $('form', dialog).submit(function (e) { e.preventDefault(); if (!validateForm($(this))) { //اگر فرم اعتبار سنجی نشده، اطلاعات آن ارسال نشود return false; } //در اینجا میتوان مثلا دکمهای را غیرفعال کرد if (options.beforePostHandler) options.beforePostHandler(); //اطلاعات نباید کش شوند $.ajaxSetup({ cache: false }); $.ajax({ url: options.postUrl, type: "POST", data: $(this).serialize(), success: function (result) { if (result.success) { $('#dialogDiv').modal('hide'); if (options.completeHandler) options.completeHandler(); } else { $('#dialogContent').html(result); if (options.errorHandler) options.errorHandler(); } } }); return false; }); }; var mainContainer = "<div id='dialogDiv' class='modal hide fade in'><div id='dialogContent'></div></div>"; enableBootstrapStyleValidation(); //اعمال نکات خاص بوت استرپ جهت اعتبارسنجی یکپارچه با آن $.ajaxSetup({ cache: false }); $.ajax({ type: "POST", url: options.renderModalPartialViewUrl, data: options.renderModalPartialViewData, contentType: "application/json; charset=utf-8", dataType: "json", complete: function (xhr, status) { var data = xhr.responseText; var data = xhr.responseText; if (xhr.status == 403) { window.location = options.loginUrl; //در حالت لاگین نبودن شخص اجرا میشود } else if (status === 'error' || !data) { if (options.errorHandler) options.errorHandler(); } else { var dialogContainer = "#dialogDiv"; $(dialogContainer).remove(); $(mainContainer).appendTo('body'); $('#dialogContent').html(data); // دریافت پویای اطلاعات مودال دیالوگ $.validator.unobtrusive.parse("#dialogContent"); // فعال سازی اعتبارسنجی فرمی که با ایجکس بارگذاری شده enablePostbackValidation(); // و سپس نمایش آن به صورت مودال $('#dialogDiv').modal({ backdrop: 'static', //با کلیک کاربر روی صفحه، صفحه مودال بسته نمیشود keyboard: true }, 'show'); // تحت نظر قرار دادن این فرم اضافه شده processAjaxForm('#dialogContent'); } } }); }; })(jQuery); // ]]>
- توابع enableBootstrapStyleValidation و enablePostbackValidation در مطلب «اعمال کلاسهای ویژه اعتبارسنجی Twitter bootstrap به فرمهای ASP.NET MVC» بررسی شدند.
- این افزونه با توجه به مقدار renderModalPartialViewUrl، یک partial view را از برنامه ASP.NET MVC درخواست میکند.
- سپس این partial view را به صورت خودکار به صفحه اضافه کرده و آنرا به صورت modal نمایش میدهد.
- پس از افزودن فرم Ajaxایی دریافتی، مسایل اعتبارسنجی را به آن اعمال کرده و سپس دکمه submit آنرا تحت کنترل قرار میدهد.
- در زمان submit، ابتدا بررسی میکند که آیا فرم معتبر است و اعتبارسنجی آن بدون مشکل است؟ اگر اینچنین است، اطلاعات فرم را به آدرس postUrl به صورت Ajaxایی ارسال میکند.
کدهای مدل برنامه
using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Mvc4TwitterBootStrapTest.Models { public class User { public int Id { set; get; } [DisplayName("نام")] [Required(ErrorMessage="لطفا نام را تکمیل کنید")] public string Name { set; get; } [DisplayName("نام خانوادگی")] [Required(ErrorMessage = "لطفا نام خانوادگی را تکمیل کنید")] public string LastName { set; get; } } }
کدهای کنترلر برنامه
using System.Web.Mvc; using Mvc4TwitterBootStrapTest.Models; namespace Mvc4TwitterBootStrapTest.Controllers { public class ModalFormAjaxController : Controller { [HttpGet] public ActionResult Index() { return View(); //نمایش صفحه اولیه } [HttpPost] //برای این حالت امنتر است //[AjaxOnly] public ActionResult RenderModalPartialView() { //رندر پارشال ویوو صفحه مودال به همراه اطلاعات مورد نیاز آن return PartialView(viewName: "_ModalPartialView", model: new User { Name = "", LastName = "" }); } [HttpPost] //[AjaxOnly] public ActionResult Index(User user) //ذخیره سازی اطلاعات { if (this.ModelState.IsValid) { //todo: SaveChanges; return Json(new { success = true }); } this.ModelState.AddModelError("", "خطایی رخ داده است"); return PartialView("_ModalPartialView", user); } } }
الف) متد Index حالت HttpGet که صفحه ابتدایی را نمایش خواهد داد.
ب) متد RenderModalPartialView یک partial view اضافه شده به برنامه به نام _ModalPartialView.cshtml را بازگشت میدهد. این partial view در حقیقت همان فرمی است که قرار است به صورت مودال نمایش داده شود و پردازش آن نیز Ajaxایی باشد.
ج) متد Index حالت HttpPost که نهایتا اطلاعات فرم مودال را دریافت خواهد کرد. اگر پردازش موفقیت آمیز بود، نیاز است همانند کدهای فوق return Json صورت گیرد. در غیراینصورت مجددا همان partial view را بازگشت دهید.
کدهای Index.cshtml
@{ ViewBag.Title = "Index"; var renderModalPartialViewUrl = Url.Action("RenderModalPartialView", "ModalFormAjax"); var postDataUrl = Url.Action("Index", "ModalFormAjax"); } <h2> Index</h2> <a href="#" class="btn btn-primary" id="btnCreate">ثبت اطلاعات</a> @section JavaScript { <script type="text/javascript"> $(function () { $('#btnCreate').click(function (e) { e.preventDefault(); //میخواهیم لینک به صورت معمول عمل نکند $.bootstrapModalAjaxForm({ postUrl: '@postDataUrl', renderModalPartialViewUrl: '@renderModalPartialViewUrl', renderModalPartialViewData: {}, loginUrl: '/login', beforePostHandler: function () { }, completeHandler: function () { // Refresh: برای حالتیکه نیاز به به روز رسانی کامل صفحه زیرین باشد // location.reload(); }, errorHandler: function () { } }); }); }); </script> }
- در اینجا یک لینک ساده در صفحه قرار گرفته و به کمک کلاس btn مجموعه bootstrap به شکل یک دکمه مزین شده است.
- در ادامه نحوه استفاده از افزونهای را که در ابتدای بحث طراحی کردیم، ملاحظه میکنید. کار با آن بسیار ساده است و تنها باید مسیرهای ارسال اطلاعات نهایی به سرور یا postDataUrl و مسیر دریافت partial view رندر شده یا renderModalPartialViewUrl به آن معرفی شود. سایر مسایل آن خودکار است.
کدهای _ModalPartialView.cshtml یا همان فرم مودال برنامه
@model Mvc4TwitterBootStrapTest.Models.User <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"> ×</button> <h5> افزودن کاربر جدید</h5> </div> @using (Html.BeginForm("Index", " ModalFormAjax", FormMethod.Post, new { @class = "modal-form" })) { <div class="modal-body"> @Html.ValidationSummary(true, null, new { @class = "alert alert-error alert-block" }) <fieldset class="form-horizontal"> <legend>مشخصات کاربر</legend> <div class="control-group"> @Html.LabelFor(model => model.Name, new { @class = "control-label" }) <div class="controls"> @Html.EditorFor(model => model.Name) @Html.ValidationMessageFor(model => model.Name, null, new { @class = "help-inline" }) </div> </div> <div class="control-group"> @Html.LabelFor(model => model.LastName, new { @class = "control-label" }) <div class="controls"> @Html.EditorFor(model => model.LastName) @Html.ValidationMessageFor(model => model.LastName, null, new { @class = "help-inline" }) </div> </div> </fieldset> </div> <div class="modal-footer"> <button class="btn btn-primary" type="submit"> ارسال</button> <button class="btn" data-dismiss="modal" aria-hidden="true"> انصراف</button> </div> }
حاصل نهایی این مبحث را در دو شکل ذیل ملاحظه میکنید. صفحه index نمایش دهنده یک دکمه و در ادامه باز شدن یک فرم مودال، پس از کلیک بر روی دکمه ثبت اطلاعات.