public void ConfigureServices(IServiceCollection services) { services.Configure<PasswordHasherOptions>(options => options.IterationCount = 100_000);
using System.Data.Entity.Core.Metadata.Edm;
public class SoftDeleteAttribute : Attribute { public string ColumnName { get; set; } public SoftDeleteAttribute(string column) { ColumnName = column; } public static string GetSoftDeleteColumnName(EdmType type) { MetadataProperty column = type.MetadataProperties.Where(x => x.Name.EndsWith("customannotation:SoftDeleteColumnName")).SingleOrDefault(); return column == null ? null : (string)column.Value; } }
سؤال: متادیتای "customannotation:SoftDeleteColumnName" از کجا آمد؟ برای پاسخ به این سوال کافیست ادامهی مطلب را کامل مطالعه نمایید.
[SoftDelete("IsDeleted")] public class TblUser { [Key] public int TblUserID { get; set; } [MaxLength(30)] public string Name { get; set; } public bool IsDeleted { get; set; } }
protected override void OnModelCreating(DbModelBuilder modelBuilder) { var Conv = new AttributeToTableAnnotationConvention<SoftDeleteAttribute, string>( "SoftDeleteColumnName", (type, attribute) => attribute.Single().ColumnName); modelBuilder.Conventions.Add(Conv); }
در پنجره باز شده به قسمت سوم یعنی <StorageModels> مراجعه نمایید و بایستی گزینه زیر را مشاهده نمایید .
<EntityType Name="TblUser" customannotation:SoftDeleteColumnName="IsDeleted">
تا اینجای کار ما توانستیم یک Annotation جدید را به Ef اضافه نماییم .
در مرحله بعد بایستی به Ef دستور دهیم که در تولید Query بر روی این Entity، این مورد را نیز لحاظ کند.
برای این کار کلاسی را ایجاد مینماییم که از اینترفیس IDbCommandTreeInterceptor ارث بری مینماید. مانند کد زیر :
public class SoftDeleteInterceptor : IDbCommandTreeInterceptor { public void TreeCreated(DbCommandTreeInterceptionContext interceptionContext) { if (interceptionContext.OriginalResult.DataSpace == System.Data.Entity.Core.Metadata.Edm.DataSpace.SSpace) { var QueryCommand = interceptionContext.Result as DbQueryCommandTree; if (QueryCommand != null) { var newQuery = QueryCommand.Query.Accept(new SoftDeleteQueryVisitor()); interceptionContext.Result = new DbQueryCommandTree(QueryCommand.MetadataWorkspace, QueryCommand.DataSpace, newQuery); } } } }
در ابتدا تشخیص داده میشود که نوع خروجی Query آیا از نوع Storage Model است . ( برای توضیحات بیشتر ) سپس پرس و جوی تولید شده را با استفاده از الگوی visitor تغییر داده و Query جدید را تولید نموده و در انتها Query جدیدی را به جای Query قبلی جایگزین مینماییم.
در اینجا ما نیاز به داشتن کلاس SoftDeleteQueryVisitor برای تغییر دادن Query و اضافه نمودن IsDeleted <>1 به Query میباشیم.
یک کلاس دیگری با نام SoftDeleteQueryVisitor به شکل زیر به برنامه اضافه مینماییم.
public class SoftDeleteQueryVisitor : DefaultExpressionVisitor { public override DbExpression Visit(DbScanExpression expression) { var column = SoftDeleteAttribute.GetSoftDeleteColumnName(expression.Target.ElementType); if (column!=null) { var Binding = DbExpressionBuilder.Bind(expression); return DbExpressionBuilder.Filter(Binding, DbExpressionBuilder.NotEqual(DbExpressionBuilder.Property(DbExpressionBuilder.Variable(Binding.VariableType, Binding.VariableName), column), DbExpression.FromBoolean(true))); } else { return base.Visit(expression); } } }
در نهایت برای اینکه EF تشخیص دهد که یکچنین Interceptor ایی وجود دارد، بایستی در کلاس DbContextConfig، کلاس SoftDeleteInterceptor را اضافه نماییم؛ همانند کد زیر:
public class DbContextConfig : DbConfiguration { public DbContextConfig() { AddInterceptor(new SoftDeleteInterceptor()); } }
تا اینجا در تمام Queryهای تولید شده بر روی Entity که با خاصیت SoftDelete مزین شده است، مقدار IsDeleted <> 1 را به صورت اتوماتیک اعمال مینماید. حتی به صورت هوشمند چنانچه این موجودیت در یک Join استفاده شده باشد این شرط را قبل از Join به Query تولید شده اضافه مینماید.
در مقاله بعدی در مورد تغییر کد Remove به کد Update توضیح داده خواهد شد.
برای مطالعه بیشتر
Entity Framework: Building Applications with Entity Framework 6
- درگاهها (اجباری)
- HttpContext (اجباری)
- پایگاه داده (اجباری)
- پیامها (اختیاری)
- وارد کردن تنظیمات به صورت ثابت (استاتیک)
- تنظیم به صورت داینامیک (برای مثال استفاده از یک منبع، مانند پایگاه داده وب سایت شما)
- تنظیم توسط اینترفیس مایکروسافت IConfiguration
- تنظیم برای پروژههایی که از تزریق وابستگیهای مایکروسافت استفاده میکنند (مانند اپلیکیشنهای ASP.NET CORE ).
- تنظیم برای پروژههایی که از تزریق وابستگیهای مایکروسافت استفاده نمیکنند (مانند Autofac) و یا اینکه اصلا از هیچ تزریق وابستگیای استفاده نمیکنند.
using Parbad.Builder; public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddParbad() // .configurations // .configurations // .configurations }
using Parbad.Builder; public class Startup { public void Configuration(IAppBuilder app) { ParbadBuilder.CreateDefaultBuilder() // .configurations // .configurations // .configurations } }
به همین دلیل قبل از انجام هر گونه عملیات پرداخت، ابتدا باید تنظیمات درگاههای مورد استفاده خود را انجام دهید.
services.AddParbad() .ConfigureGateways(gateways => { gateways .AddMellat() .WithOptions(options => { options.TerminalId = 123; options.UserName = "MyId"; options.UserPassword = "MyPassword"; }); });
public class MellatOptionsProvider : IParbadOptionsProvider<MellatGatewayOptions> { private readonly IMySettingsService _settingsService; public MellatOptionsProvider(IMySettingsService settingsService) { _settingsService = settingsService; } public void Provide(MellatGatewayOptions options) { var settings = _settingsService.GetSettings(); options.TerminalId = settings.TerminalId; options.UserName = settings.UserName; options.UserPassword = settings.UserPassword; } }
services.AddParbad() .ConfigureGateways(gateways => { gateways .AddMellat() .WithOptionsProvider<MellatOptionsProvider>(ServiceLifetime.Transient); });
services.AddParbad() .ConfigureGateways(gateways => { gateways .AddMellat() .WithConfiguration(IConfiguration.GetSection("Mellat"); });
و محتوای فایل JSON:
"Mellat": { "TerminalId": 123, "UserName": "MyUsername", "UserPassword": "MyPassword" }
ParbadBuilder.CreateDefaultBuilder() .ConfigureHttpContext(builder => builder.UseOwinFromCurrentHttpContext());
services.AddParbad() .ConfigureHttpContext(builder => builder.UseDefaultAspNetCore());
services.AddParbad() .ConfigureStorage(builder => builder.UseParbadSqlServer("ConnectionString"));
services.AddParbad() .ConfigureStorage(builder => builder.UseInMemoryDatabase("MyMemoryName"));
services.AddParbad() .ConfigureMessages(options => { options.PaymentSucceed = "Payment was successful."; options.PaymentFailed = "Payment was not successful."; // other messages... });
ساختار موجودیت تنظیمات برنامه
تنظیمات برنامه با هر قالبی که تهیه شوند، دست آخر به صورت یک <Dictionary<string,string در برنامه پردازش شده و قابل دسترسی میشوند. بنابراین موجودیت معادل این Dictionary را به صورت زیر تعریف میکنیم:
namespace DbConfig.Web.DomainClasses { public class ConfigurationValue { public int Id { get; set; } public string Key { get; set; } public string Value { get; set; } } }
ساختار Context برنامه و مقدار دهی اولیهی آن
پس از تعریف موجودیت تنظیمات برنامه، آنرا به صورت زیر به Context برنامه معرفی میکنیم:
public class MyAppContext : DbContext, IUnitOfWork { public MyAppContext(DbContextOptions options) : base(options) { } public virtual DbSet<ConfigurationValue> Configurations { set; get; }
protected override void OnModelCreating(ModelBuilder builder) { // it should be placed here, otherwise it will rewrite the following settings! base.OnModelCreating(builder); // Custom application mappings builder.Entity<ConfigurationValue>(entity => { entity.Property(e => e.Key).HasMaxLength(450).IsRequired(); entity.HasIndex(e => e.Key).IsUnique(); entity.Property(e => e.Value).IsRequired(); entity.HasData(new ConfigurationValue { Id = 1, Key = "key-1", Value = "value_from_ef_1" }); entity.HasData(new ConfigurationValue { Id = 2, Key = "key-2", Value = "value_from_ef_2" }); }); }
انواع و اقسام تامین کنندههای تنظیمات برنامه در پروژههای ASP.NET Core، در حقیقت یک پیاده سازی سفارشی از اینترفیس IConfigurationSource هستند. به همین جهت در ادامه یک نمونهی مبتنی بر EF Core آن را تهیه میکنیم:
public class EFConfigurationSource : IConfigurationSource { private readonly IServiceProvider _serviceProvider; public EFConfigurationSource(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public IConfigurationProvider Build(IConfigurationBuilder builder) { return new EFConfigurationProvider(_serviceProvider); } }
public class EFConfigurationProvider : ConfigurationProvider { private readonly IServiceProvider _serviceProvider; public EFConfigurationProvider(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; ensureDatabaseIsCreated(); } public override void Load() { using (var scope = _serviceProvider.CreateScope()) { var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); this.Data?.Clear(); this.Data = uow.Set<ConfigurationValue>() .AsNoTracking() .ToList() .ToDictionary(c => c.Key, c => c.Value); } } private void ensureDatabaseIsCreated() { using (var scope = _serviceProvider.CreateScope()) { var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); uow.Migrate(); } } }
در اینجا فراخوانی متد ensureDatabaseIsCreated را نیز مشاهده میکنید. کلاس EFConfigurationProvider در آغاز برنامه و پیش از هر عمل دیگری وهله سازی شده و سپس متد Load آن فراخوانی میشود. به همین جهت نیاز است یا پیشتر، بانک اطلاعاتی را توسط دستورات Migration ایجاد کرده باشید و یا متد ensureDatabaseIsCreated، اطلاعات Migration موجود را به بانک اطلاعاتی برنامه اعمال میکند.
معرفی EFConfigurationSource به برنامه
جهت معرفی سادهتر EFConfigurationSource تهیه شده، ابتدا یک متد الحاقی را بر اساس آن تهیه میکنیم:
public static class EFExtensions { public static IConfigurationBuilder AddEFConfig(this IConfigurationBuilder builder, IServiceProvider serviceProvider) { return builder.Add(new EFConfigurationSource(serviceProvider)); } }
namespace DbConfig.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddScoped<IUnitOfWork, MyAppContext>(); services.AddScoped<IConfigurationValuesService, ConfigurationValuesService>(); var connectionString = Configuration.GetConnectionString("SqlServerConnection") .Replace("|DataDirectory|", Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "app_data")); services.AddDbContext<MyAppContext>(options => { options.UseSqlServer( connectionString, dbOptions => { var minutes = (int)TimeSpan.FromMinutes(3).TotalSeconds; dbOptions.CommandTimeout(minutes); dbOptions.EnableRetryOnFailure(); }); }); var serviceProvider = services.BuildServiceProvider(); var configuration = new ConfigurationBuilder() .AddConfiguration(Configuration) // Adds all of the existing configurations .AddEFConfig(serviceProvider) .Build(); services.AddSingleton<IConfigurationRoot>(sp => configuration); // Replace services.AddSingleton<IConfiguration>(sp => configuration); // Replace
همچنین روش دسترسی به serviceProvider مورد نیاز AddEFConfig، توسط متد services.BuildServiceProvider نیز در کدهای فوق مشخص است. به همین جهت مجبور شدیم این تعریف را در اینجا قرار دهیم و گرنه میشد از کلاس Program و یا حتی سازندهی کلاس Startup نیز استفاده کرد. مشکل این دو مکان عدم دسترسی به سرویس IUnitOfWork و سایر تنظیمات برنامه است.
آزمایش برنامه
اگر به قسمت «ساختار Context برنامه و مقدار دهی اولیهی آن» مطلب جاری دقت کرده باشید، دو کلید پیشفرض در اینجا ثبت شدهاند. به همین جهت در ادامه با تزریق سرویس IConfiguration به سازندهی یک کنترلر، سعی در خواندن مقادیر آنها خواهیم کرد:
namespace DbConfig.Web.Controllers { public class HomeController : Controller { private readonly IConfiguration _configuration; public HomeController(IConfiguration configuration) { _configuration = configuration; } public IActionResult Index() { return Json( new { key1 = _configuration["key-1"], key2 = _configuration["key-2"] }); }
به روز رسانی بانک اطلاعاتی برنامه و بارگذاری مجدد اطلاعات IConfiguration
فرض کنید توسط سرویسی، اطلاعات جدول ConfigurationValue را تغییر دادهاید. نکتهی مهم اینجا است که اینکار سبب فراخوانی مجدد متد Load کلاس EFConfigurationProvider نخواهد شد و عملا این تغییرات در سراسر برنامه توسط تزریق اینترفیس IConfiguration قابل دسترسی نخواهند بود (مگر اینکه برنامه مجددا ریاستارت شود). نکتهی به روز رسانی این اطلاعات به صورت زیر است:
public class ConfigurationValuesService : IConfigurationValuesService { private readonly IConfiguration _configuration; public ConfigurationValuesService(IConfiguration configuration) { _configuration = configuration; } private void reloadEFConfigurationProvider() { ((IConfigurationRoot)_configuration).Reload(); }
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: EFCoreDbConfig.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
فرض کنید اطلاعات حضور و غیاب کارمندان را به نحو زیر در اختیار دارید:
namespace PdfReportSamples.Models { public class UserWorkedHours { public int Id { set; get; } public string Name { set; get; } public int DayNumber { set; get; } public int Month { set; get; } public int Year { set; get; } public string Description { set; get; } } }
private static List<UserWorkedHours> createUsersWorkedHours() { var usersWorkedHours = new List<UserWorkedHours>(); for (int i = 1; i < 11; i++) { for (int j = 1; j < 28; j++) { usersWorkedHours.Add(new UserWorkedHours { Id = i, Name = "کارمند " + i, Year = 1391, // سال و ماه بر اساس نوع تقویم انتخابی مشخص میشود Month = i, DayNumber = j, Description = i % 2 == 0 ? "05:00" : "08:00" }); } } return usersWorkedHours; }
سلولی که قرار است قالب MonthCalendar را نمایش دهد نیاز به شیءایی از نوع PdfRpt.Calendar.CalendarData دارد که به نحو زیر تعریف شده است:
using System.Collections.Generic; namespace PdfRpt.Calendar { public class CalendarData { public int Month { set; get; } public int Year { set; get; } public IList<DayInfo> MonthDaysInfo { set; get; } } }
namespace PdfRpt.Calendar { public class DayInfo { public int DayNumber { set; get; } public int Month { set; get; } public int Year { set; get; } public string Description { set; get; } public bool ShowDescriptionInFooter { set; get; } } }
اکنون نیاز است تا اطلاعات منبع داده خود را به CalendarData نگاشت کنیم تا بتوان از آن در قالب سلول جدید MonthCalendar استفاده کرد. انجام اینکار با استفاده از امکانات LINQ به نحو زیر است:
public static IList<UserMonthCalendar> CreateDataSource() { var usersWorkedHours = createUsersWorkedHours(); // Mapping a list of normal Users WorkedHours to a list of Users + CalendarData return usersWorkedHours .GroupBy(x => new { Id = x.Id, Name = x.Name }) .Select( x => new UserMonthCalendar { Id = x.Key.Id, Name = x.Key.Name, // Calendar's cell data type should be PdfRpt.Calendar.CalendarData MonthCalendarData = new CalendarData { Year = x.First().Year, Month = x.First().Month, MonthDaysInfo = x.ToList().Select(y => new DayInfo { Description = y.Description, ShowDescriptionInFooter = false, DayNumber = y.DayNumber }).ToList() } }).ToList(); }
using PdfRpt.Calendar; namespace PdfReportSamples.Models { public class UserMonthCalendar { public int Id { set; get; } public string Name { set; get; } // Calendar's cell data type should be CalendarData public CalendarData MonthCalendarData { set; get; } } }
برای نمایش این اطلاعات توسط PdfReport، دو ستون اول یاد شده نکته خاصی ندارند، اما نحوه تعریف ستون تقویم ماهیانه آن به صورت زیر خواهد بود:
columns.AddColumn(column => { // Calendar's cell data type should be PdfRpt.Calendar.CalendarData column.PropertyName<UserMonthCalendar>(x => x.MonthCalendarData); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(3); column.Width(3); column.HeaderCell("تقویم ماهیانه"); column.ColumnItemsTemplate(itemsTemplate => { itemsTemplate.MonthCalendar(new CalendarAttributes { CalendarType = CalendarType.PersianCalendar, UseLongDayNamesOfWeek = true, Padding = 3, DescriptionHorizontalAlignment = HorizontalAlignment.Center, SplitRows = true, CellsCustomizer = info => { if (info.Year == 1391 && info.Month == 1 && info.DayNumber == 1) { info.NumberCell.BackgroundColor = new BaseColor(System.Drawing.Color.LimeGreen); var phrase = info.NumberCell.Phrase; foreach (var chunk in phrase.Chunks) chunk.Font.Color = new BaseColor(System.Drawing.Color.Yellow); } } }); }); });
(JSON (JavaScript Object Notation یک راه مناسب برای نگهداری اطلاعات است و از لحاظ ساختاری شباهت زیادی به XML، رقیب قدیمی خود دارد.
وب سرویس و آجاکس برای انتقال اطلاعات از این روش استفاده میکنند و بعضی از پایگاههای داده مانند RavenDB بر مبنای این تکنولوژی پایه گذاری شده اند.
هیچ چیزی نمیتواند مثل یک مثال؛ خوانایی ، سادگی و کم حجم بودن این روش را نشان دهد :
اگر یک شئ با ساختار زیر در سی شارپ داشته باشید :
class Customer { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } }
ساختار JSON متناظر با آن ( در صورت این که مقدار دهی شده باشد ) به صورت زیر است:
{ "Id":1, "FirstName":"John", "LastName":"Doe" }
و در یک مثال پیچیدهتر :
class Customer { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public Car Car { get; set; } public IEnumerable<Location> Locations { get; set; } } class Location { public int Id { get; set; } public string Address { get; set; } public int Zip { get; set; } } class Car { public int Id { get; set; } public string Model { get; set; } }
{ "Id":1, "FirstName":"John", "LastName":"Doe", "Car": { "Id":1, "Model":"Nissan GT-R" }, "Locations":[ { "Id":1, "Address":"30 Mortensen Avenue, Salinas", "Zip":93905 }, { "Id":2, "Address":"65 West Alisal Street, #210, Salinas", "Zip":95812 } ] }
ساختار JSON را مجموعه ای از ( نام - مقدار ) تشکیل میدهد. ساختار مشابه آن در زبان سی شارپ KeyValuePair است.
مشاهده این تصاویر، بهترین درک را از ساختار JSON به شما میدهد.
Json.net یکی از بهترین کتابخانه هایی است که برای کار با این تکنولوژی در net. ارائه شده است. بهترین روش اضافه نمودن آن به پروژه NuGet است.برای این کار دستور زیر را در Package Manager Console وارد کنید.
PM> Install-Package Newtonsoft.Json
با استفاده از کد زیر میتوانید یک Object را به فرمت JSON تبدیل کنید.
var customer = new Customer { Id = 1, FirstName = "John", LastName = "Doe", Car = new Car { Id = 1, Model = "Nissan GT-R" }, Locations = new[] { new Location { Id = 1, Address = "30 Mortensen Avenue, Salinas", Zip = 93905 }, new Location { Id = 2, Address = "65 West Alisal Street, #210, Salinas", Zip = 95812 }, } };
var data = Newtonsoft.Json.JsonConvert.SerializeObject(customer);
خروجی تابع SerializeObject رشته ای است که محتوی آن را در چهارمین بلاک کد که در بالاتر آمده است، میتوانید مشاهده کنید.
برای Deserialize کردن (Cast اطلاعات با فرمت JSON به کلاس موردنظر) از روش زیر بهره میگیریم :
var customer = Newtonsoft.Json.JsonConvert.DeserializeObject<Customer>(data);
آشنایی با این تکنولوژی، پیش درآمدی برای چشیدن طعم NoSQL و معرفی کارآمدترین روشهای آن است که در آینده خواهیم آموخت...
خوشحال میشوم اگر نظرات شما را در باره این موضوع بدانم.
EF Code First #8
using System.Collections.Generic; namespace EF_Sample04.Models { public class Employee { public int Id { set; get; } public string FirstName { get; set; } public string LastName { get; set; } public int? ManagerID { get; set; } public virtual Employee Manager { get; set; } } }
using EF_Sample04.Models; using System.Data.Entity.ModelConfiguration; namespace EF_Sample04.Mappings { public class EmployeeConfig : EntityTypeConfiguration<Employee> { public EmployeeConfig() { this.HasOptional(x => x.Manager) .WithMany() .HasForeignKey(x => x.ManagerID) .WillCascadeOnDelete(false); } } }
نیازهای یک ورودی تاریخ سازگار با EditForm
- باید قابلیت استفادهی مجدد را داشته باشد. یعنی باید به صورت یک کامپوننت مجزا و یا به صورت یک کتابخانهی مجزا ارائه شود.
- باید با سیستم اعتبارسنجی EditForm یکپارچه باشد.
- باید جنریک باشد. یعنی باید بتوان در صورت نیاز DateTime ، DateTimeOffset و DateOnly و نمونههای nullable آنهارا توسط این کامپوننت دریافت کرد و ورودی و خروجی آن رشتهای نباشد.
نیاز به ارثبری از <InputBase<T جهت ارائهی کامپوننتهایی سازگار با EditForm
تقریبا تمام کامپوننتهای استاندارد EditForm ارائه شدهی توسط Blazor، از کامپوننت پایهای به نام <InputBase<T مشتق میشوند. این کلاس، یک کلاس abstract است که قابلیتهای بیشتری را نسبت به یک input سادهی HTML ای مانند اعتبارسنجی سازگار با EditForm ارائه میدهد. به همین جهت توصیه میشود تا اگر خواستید یک کامپوننت ورودی را برای استفادهی در Blazor و EditForm آن طراحی کنید، با ارثبری از این کلاس شروع کنید و صرفا کار را با یک input ساده، شروع نکنید.
برای استفادهی از آن، ابتدای کامپوننت Blazor ما به این صورت شروع خواهد شد:
@typeparam T @inherits InputBase<T>
protected override bool TryParseValueFromString( string? value, [MaybeNullWhen(false)] out T result, [NotNullWhen(false)] out string? validationErrorMessage) { // ... } protected override string FormatValueAsString(T? value) { // ... }
ایجاد یک کتابخانهی جدید برای محصور سازی DatePicker جاوااسکریپتی
چون قصد استفادهی مجدد از این کامپوننت جدید را در پروژههای مختلف داریم، بهتر است آنرا تبدیل به یک «کتابخانهی Blazor» کنیم. به همین جهت کتابخانهی فرضی BlazorPersianJavaScriptDatePicker.Lib را در اینجا ایجاد کردهایم.
در ابتدا دو فایل PersianDatePicker.js و PersianDatePicker.css موجود و مدنظر را در پوشههای js و css پوشهی wwwroot این کتابخانه کپی میکنیم. بنابراین استفاده کنندهی از آن، مانند پروژهی blazor wasm جدیدی به نام BlazorPersianJavaScriptDatePicker، باید ارجاعاتی را به آنها به صورت زیر اضافه کند:
<link href="_content/BlazorPersianJavaScriptDatePicker.Lib/css/PersianDatePicker.css" rel="stylesheet"/> <script src="_content/BlazorPersianJavaScriptDatePicker.Lib/js/PersianDatePicker.js?v=1"></script>
@using BlazorPersianJavaScriptDatePicker.Lib
شروع به پیاده سازی کامپوننت PersianDatePicker
در ادامه کامپوننت جدید PersianDatePicker.razor را به پروژهی کتابخانه اضافه میکنیم. قسمت razor آن به صورت زیر است:
@typeparam T @inherits InputBase<T> <div> <span style="cursor:pointer" onclick="PersianDatePicker.Show(document.getElementById('@ElementId'), '@Today')"> 📅 </span> <input @attributes="@AdditionalAttributes" type="text" dir="ltr" @ref="ElementReference" name="@ElementId" id="@ElementId" autocapitalize="off" autocorrect="off" autocomplete="off" value="@EnteredValue" @oninput="OnInput"/> @if (ValueExpression is not null) { <ValidationMessage For="@ValueExpression"/> } </div>
در اینجا با کلیک بر روی دکمهی 📅، کار فراخوانی متد PersianDatePicker.Show مربوط به datePicker جاوا اسکریپتی صورت میگیرد. همچنین هر طراحی را که در اینجا ارائه دهیم، قالب UI پیشفرض InputBase را بازنویسی میکند.
نیاز به دریافت تاریخ تنظیم شدهی توسط کدهای جاوااسکریپتی در کامپوننت Blazor
کتابخانههای جاوااسکریپتی با مقداردهی مستقیم textbox.value سبب تغییر مقدار آن میشوند. نکتهی مهم اینجا است که نه فقط Blazor این تغییرات را ردیابی نمیکند، بلکه اگر با استفاده از متد استاندارد جاوااسکریپتی addEventListener به تغییرات این input گوش فرا دهیم، هیچ رخدادی را مشاهده نخواهیم کرد. به همین جهت نیاز است اندکی کدهای PersianDatePicker.js را تغییر دهیم (و این مورد جهت تمام کتابخانههای مشابه یکسان است):
function setValue(date) { _textBox.value = date; // NOTE: To notify the addEventListener('change', fn) _textBox.dispatchEvent(new Event('change')); _textBox.focus(); hide(); try { _textBox.onchange(); }catch(ex) {} }
window.activateDatePicker = { enableDatePicker: function (element, objectReference) { element.addEventListener('change', function (evt) { objectReference.invokeMethodAsync("OnInputFieldChanged", this.value); }); } };
بنابراین این فایل جدید نیز باید به index.html مصرف کننده اضافه شود:
<script src="_content/BlazorPersianJavaScriptDatePicker.Lib/js/activateDatePicker.js?v=1"></script>
فعالسازی DatePicker در اولین بار نمایش کامپوننت Blazor
تا اینجا زیرساخت دریافت مقدار تنظیمی توسط کاربر را در کامپوننت Blazor فراهم کردیم. اکنون نوبت به استفادهی از آن است:
public partial class PersianDatePicker<T> : IDisposable { private bool _isDisposed; private DotNetObjectReference<PersianDatePicker<T>>? _objectReference; private string ElementId { get; } = Guid.NewGuid().ToString("N"); private ElementReference? ElementReference { set; get; } private string Today { get; } = DateTime.Now.ToShortPersianDateString(); [Inject] private IJSRuntime JsRuntime { set; get; } = default!; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _objectReference = DotNetObjectReference.Create(this); await JsRuntime.InvokeVoidAsync("activateDatePicker.enableDatePicker", ElementReference, _objectReference); EnteredValue = CurrentValueAsString; StateHasChanged(); } } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (!_isDisposed) { try { _objectReference?.Dispose(); } finally { _isDisposed = true; } } } }
- همچنین چون نمیخواهیم متد OnInputFieldChanged را به صورت static تعریف کنیم، نیاز است تا یک DotNetObjectReference را ایجاد و به متد enableDatePicker ارسال کرد تا توسط آن بتوان به یک instance method کلاس جاری دسترسی یافت و به سادگی مقادیر کامپوننت را تغییر داد:
[JSInvokable] public void OnInputFieldChanged(string? value)
نیاز به تبدیل T به تاریخ رشتهای و برعکس
زیر ساخت تبدیلات جنریک تاریخ میلادی به شمسی در کتابخانهی « DNTPersianUtils.Core » پیشبینی شدهاست و فقط کافی است از آن استفاده کنیم. با وجود این زیرساخت، تهیهی کامپوننتهای جنریک تاریخ شمسی بسیار ساده میشود:
public partial class PersianDatePicker<T> : IDisposable { private string? _enteredValue; private string? EnteredValue { set => _enteredValue = value; get => UsePersianNumbers ? _enteredValue.ToPersianNumbers() : _enteredValue; } [Parameter] public bool UsePersianNumbers { set; get; } [Parameter] public string ParsingErrorMessage { get; set; } = "لطفا در ورودی {0} تاریخ شمسی معتبری را وارد نمائید."; [Parameter] public int BeginningOfCentury { set; get; } = 1400; private void OnInput(ChangeEventArgs e) { SetCurrentValue(e.Value as string); } private void SetCurrentValue(string? value) { EnteredValue = value; CurrentValueAsString = value; } [JSInvokable] public void OnInputFieldChanged(string? value) { SetCurrentValue(value); } protected override void OnInitialized() { base.OnInitialized(); SanityCheck(); } protected override bool TryParseValueFromString( string? value, [MaybeNullWhen(false)] out T result, [NotNullWhen(false)] out string? validationErrorMessage) { validationErrorMessage = string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayName); if (!value.TryParsePersianDateToDateTimeOrDateTimeOffset(out result, BeginningOfCentury)) { return false; } if (result is null) { throw new InvalidOperationException(validationErrorMessage); } validationErrorMessage = null; return true; } protected override string FormatValueAsString(T? value) { return !string.IsNullOrWhiteSpace(EnteredValue) ? EnteredValue : value.FormatDateToShortPersianDate(); } private void SanityCheck() { if (!Value.IsDateTimeOrDateTimeOffsetType()) { throw new InvalidOperationException( "The `Value` type is not a supported `date` type. DateTime, DateTime?, DateTimeOffset and DateTimeOffset? are supported."); } } // ... }
- InputBase به همراه یک خاصیت عمومی دوطرفهی Value است که امکان تعریفی مانند bind-Value@ را میسر میکند.
- این Value به همراه یک خاصیت متناظر رشتهای به نام CurrentValueAsString نیز هست که در اینجا از آن استفاده میکنیم و کار با آن، بایندینگ دوطرفه و همچنین اعتبارسنجی خودکار و فعالسازی متدهای بازنویسی شدهی InputBase را میسر میکند.
- پیاده سازی متدهای بازنویسی شدهی جنریک TryParseValueFromString و FormatValueAsString، با استفاده از دو متد TryParsePersianDateToDateTimeOrDateTimeOffset و FormatDateToShortPersianDate کتابخانهی « DNTPersianUtils.Core » انجام شدهاند و اصل کار تهیهی یک کامپوننت جنریک تاریخ شمسی را انجام میدهند.
استفادهی از کامپوننت Blazor تهیه شده
یک کامپوننت تاریخ شمسی باید بتواند تمام حالات و نوعهای زیر را پوشش دهد که به لطف جنریک بودن کامپوننت تهیه شده، این امر میسر است:
using System.ComponentModel.DataAnnotations; namespace BlazorPersianJavaScriptDatePicker.ViewModels; public class InputPersianDateViewModel { [Required] public string Name { set; get; } = default!; [Required] public DateTime BirthDayGregorian { set; get; } = DateTime.Now.AddYears(-40); public DateTime? LoginAt { set; get; } = DateTime.Now.AddMinutes(-2); [Required] public DateTimeOffset LogoutAt { set; get; } public DateTimeOffset? RegisterAt { set; get; } = DateTimeOffset.Now.AddMinutes(-10); }
<EditForm Model="Model" OnValidSubmit="DoSave"> <DataAnnotationsValidator/> <div> <label>تاریخ تولد</label> <div> <PersianDatePicker @bind-Value="Model.BirthDayGregorian" UsePersianNumbers="false" /> </div> </div> <button type="submit">ارسال</button> </EditForm>
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: BlazorPersianJavaScriptDatePicker.zip