- اول، ابزار 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 ایجاد مینمایید.
- کدهای تولید شده را میتوانید بازبینی نمایید پروژه را اجرا تا خروجی کار را مشاهده نمایید.
الگوی طراحی Factory Method به همراه مثال
عناوین :
· تعریف Factory Method
· دیاگرام UML
· شرکت کنندگان در UML
· مثالی از Factory Pattern در #C
تعریف الگوی Factory Method :
این الگو پیچیدگی ایجاد اشیاء برای استفاده کننده را پنهان میکند. ما با این الگو میتوانیم بدون اینکه کلاس دقیق یک شیئ را مشخص کنیم آن را ایجاد و از آن استفاده کنیم. کلاینت ( استفاده کننده ) معمولا شیئ واقعی را ایجاد نمیکند بلکه با یک واسط و یا کلاس انتزاعی (Abstract) در ارتباط است و کل مسئولیت ایجاد کلاس واقعی را به Factory Method میسپارد. کلاس Factory Method میتواند استاتیک باشد . کلاینت معمولا اطلاعاتی را به متدی استاتیک از این کلاس میفرستد و این متد بر اساس آن اطلاعات تصمیم میگیرید که کدام یک از پیاده سازیها را برای کلاینت برگرداند.
از مزایای این الگو این است که اگر در نحوه ایجاد اشیاء تغییری رخ دهد هیچ نیازی به تغییر در کد کلاینتها نخواهد بود. در این الگو اصل DIP از اصول پنجگانه SOLID به خوبی رعایت میشود چون که مسئولیت ایجاد زیرکلاسها از دوش کلاینت برداشته میشود.
دیاگرام UML :
در شکل زیر دیاگرام UML الگوی Factory Method را مشاهده میکنید.
شرکت کنندگان در این الگو به شرح زیل هستند :
- Iproduct یک واسط است که هر کلاینت از آن استفاده میکند. در اینجا کلاینت استفاده کننده نهایی است مثلا میتواند متد main یا هر متدی در کلاسی خارج از این الگو باشد. ما میتوانیم پیاده سازیهای مختلفی بر حسب نیاز از واسط Iproduct ایجاد کنیم.
- ConcreteProduct یک پیاده سازی از واسط Iproduct است ، برای این کار بایستی کلاس پیاده سازی (ConcreteProduct) از این واسط (IProduct) مشتق شود.
- Icreator واسطیست که Factory Method را تعریف میکند. پیاده ساز این واسط بر اساس اطلاعاتی دریافتی کلاس صحیح را ایجاد میکند. این اطلاعات از طریق پارامتر برایش ارسال میشوند.همانطور که گفتیم این عملیات بر عهده پیاده ساز این واسط است و ما در این نمودار این وظیفه را فقط بر عهده ConcreteCreator گذاشته ایم که از واسط Icreator مشتق شده است.
پیاده سازی UMLفوق به صورت زیر است:
در ابتدا کلاس واسط IProduct تعریف شده است.
interface IProduct { // در اینجا برحسب نیاز فیلدها و یا امضای متدها قرار میگیرند }
در این مرحله ما پند پیاده سازی از IProduct انجام میدهیم.
class ConcreteProductA : IProduct { // A پیاده سازی } class ConcreteProductB : IProduct { // B پیاده سازی }
abstract class Creator { // این متد بر اساس نوع ورودی انتخاب مناسب را انجام و باز میگرداند public abstract IProduct FactoryMethod(string type); }
class ConcreteCreator : Creator { public override IProduct FactoryMethod(string type) { switch (type) { case "A": return new ConcreteProductA(); case "B": return new ConcreteProductB(); default: throw new ArgumentException("Invalid type", "type"); } } }
برای روشنتر شدن موضوع ، یک مثال کاملتر ارائه داده میشود. در شکل زیر طراحی این برنامه نشان داده شده است.
کد برنامه به شرح زیل است :
خروجی اجرای برنامه فوق به شکل زیر است :using System; namespace FactoryMethodPatternRealWordConsolApp { internal class Program { private static void Main(string[] args) { VehicleFactory factory = new ConcreteVehicleFactory(); IFactory scooter = factory.GetVehicle("Scooter"); scooter.Drive(10); IFactory bike = factory.GetVehicle("Bike"); bike.Drive(20); Console.ReadKey(); } } public interface IFactory { void Drive(int miles); } public class Scooter : IFactory { public void Drive(int miles) { Console.WriteLine("Drive the Scooter : " + miles.ToString() + "km"); } } public class Bike : IFactory { public void Drive(int miles) { Console.WriteLine("Drive the Bike : " + miles.ToString() + "km"); } } public abstract class VehicleFactory { public abstract IFactory GetVehicle(string Vehicle); } public class ConcreteVehicleFactory : VehicleFactory { public override IFactory GetVehicle(string Vehicle) { switch (Vehicle) { case "Scooter": return new Scooter(); case "Bike": return new Bike(); default: throw new ApplicationException(string.Format("Vehicle '{0}' cannot be created", Vehicle)); } } } }
فایل این برنامه ضمیمه شده است، از لینک مقابل دانلود کنید FactoryMethodPatternRealWordConsolApp.zip
در مقالات بعدی مثالهای کاربردیتر و جامعتری از این الگو و الگوهای مرتبط ارائه خواهم کرد...
{ "type": "https://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "instance": "/account/12345/msgs/abc", "status": 403, }
ProblemDetails بر اساس RFC7807 طراحی شدهاست
RFC7807، قالب استانداردی را برای ارائهی خطاهای HTTP APIها تعریف میکند تا نیازی به وجود تعاریف متعددی در این زمینه نباشد و خروجی آن قابل پیشبینی و قابل بررسی توسط تمام کلاینتهای یک API باشد. کلاس ProblemDetails در ASP.NET Core نیز بر همین اساس طراحی شدهاست.
این RFC دو فرمت خروجی را بر اساس مقدار مشخص شدهی در هدر Content-Type بازگشت داده شده، مجاز میداند:
- JSON: “application/problem+json” media type
- XML: “application/problem+xml” media type
که با توجه به این هدر ارسالی، اگر از یک کلاینت از نوع HttpClient استفاده کنیم، میتوان بر اساس مقدار ویژهی «application/problem+json» تشخیص داد که خروجی API دریافتی، به همراه خطا است و نحوهی پردازش آن به صورت زیر خواهد بود:
var mediaType = response.Content.Headers.ContentType?.MediaType; if (mediaType != null && mediaType.Equals("application/problem+json", StringComparison.InvariantCultureIgnoreCase)) { var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(null, ct) ?? new ProblemDetails(); // ... }
- type: یک رشتهاست که به آدرس مستندات HTML ای مرتبط با خطای بازگشت داده شده، اشاره میکند.
- title: رشتهای است که خلاصهی خطای رخداده را بیان میکند.
- detail: رشتهای است که توضیحات بیشتری را در مورد خطای رخداده، بیان میکند.
- instance: رشتهای است که به آدرس محل بروز خطا اشاره میکند.
- status: عددی است که بیانگر HTTP status code بازگشتی از سمت سرور است.
البته اگر ویژگی ApiController بر روی کنترلرهای خود استفاده نمیکنید، میتوانید این خروجی را به صورت زیر هم با استفاده از return Problem، تولید کنید:
[HttpPost("/sales/products/{sku}/availableForSale")] public async Task<IActionResult> AvailableForSale([FromRoute] string sku) { return Problem( "Product is already Available For Sale.", "/sales/products/1/availableForSale", 400, "Cannot set product as available.", "http://example.com/problems/already-available"); }
امکان بسط این خروجی، با افزودن اعضای سفارشی نیز پیشبینی شدهاست. یک نمونهی متداول و پرکاربرد آن، بازگشت خطاهای مرتبط با اعتبارسنجی اطلاعات رسیدهاست:
HTTP/1.1 400 Bad Request Content-Type: application/problem+json Content-Language: en { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "User": [ "The user name is not verified." ] } }
جهت افزودن اعضای سفارشی دیگری به شیء ProblemDetails میتوان به صورت زیر عمل کرد:
namespace WebApplication.Controllers { [ApiController] [Route("[controller]")] public class DemoController : ControllerBase { [HttpPost] public ActionResult Post() { var problemDetails = new ProblemDetails { Detail = "The request parameters failed to validate.", Instance = null, Status = 400, Title = "Validation Error", Type = "https://example.net/validation-error", }; problemDetails.Extensions.Add("invalidParams", new List<ValidationProblemDetailsParam>() { new("name", "Cannot be blank."), new("age", "Must be great or equals to 18.") }); return new ObjectResult(problemDetails) { StatusCode = 400 }; } } public class ValidationProblemDetailsParam { public ValidationProblemDetailsParam(string name, string reason) { Name = name; Reason = reason; } public string Name { get; set; } public string Reason { get; set; } } }
معرفی سرویس جدید ProblemDetails در دات نت 7
در دات نت 7 میتوان سرویسهای جدید ProblemDetails را به نحو زیر به برنامه اضافه کرد:
services.AddProblemDetails();
الف) با اضافه کردن میانافزار مدیریت خطاها
app.UseExceptionHandler();
ب) با افزودن میانافزار StatusCodePages
app.UseStatusCodePages();
ج) با افزودن میانافزار صفحهی استثناءهای توسعه دهندهها
app.UseDeveloperExceptionPage();
امکان بازگشت سادهتر یک ProblemDetails سفارشی در دات نت 7
برای سفارشی سازی خروجی ProblemDetails، علاوه بر راهحلی که پیشتر در این مطلب مطرح شد، میتوان در دات نت 7 از روش تکمیلی ذیل نیز استفاده کرد:
builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = ctx => ctx.ProblemDetails.Extensions.Add("MachineName", Environment.MachineName));
الف) تعریف یک ErrorFeature سفارشی
public class MyErrorFeature { public ErrorType Error { get; set; } } public enum ErrorType { ArgumentException }
ب) تنظیم مقدار ErrorFeature سفارشی در اکشن متدها
[HttpGet("{value}")] public IActionResult MyErrorTest(int value) { if (value <= 0) { var errorType = new MyErrorFeature { Error = ErrorType.ArgumentException }; HttpContext.Features.Set(errorType); return BadRequest(); } return Ok(value); }
ج) واکنش نشان دادن به دریافت ErrorFeature سفارشی
services.AddProblemDetails(options => options.CustomizeProblemDetails = ctx => { var MyErrorFeature = ctx.HttpContext.Features.Get<MyErrorFeature>(); if (MyErrorFeature is not null) { (string Title, string Detail, string Type) details = MyErrorFeature.Error switch { ErrorType.ArgumentException => ( nameof(ArgumentException), "This is an argument-exception.", "https://www.rfc-editor.org/rfc/rfc7231#section-6.5.1" ), _ => ( nameof(Exception), "default-exception", "https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1" ) }; ctx.ProblemDetails.Title = details.Title; ctx.ProblemDetails.Detail = details.Detail; ctx.ProblemDetails.Type = details.Type; } } );
امکان تبدیل سادهتر اطلاعات استثناءهای سفارشی به یک ProblemDetails سفارشی در دات نت 7
بجای استفاده از تنظیمات services.AddProblemDetails جهت بازنویسی مقدار شیء ProblemDetails بازگشتی، میتوان جزئیات میانافزار app.UseExceptionHandler را نیز سفارشی سازی کرد و به بروز استثناءهای خاصی واکنش نشان داد. برای مثال فرض کنید یک استثنای سفارشی را به صورت زیر طراحی کردهاید:
public class MyCustomException : Exception { public MyCustomException( string message, HttpStatusCode statusCode = HttpStatusCode.BadRequest ) : base(message) { StatusCode = statusCode; } public HttpStatusCode StatusCode { get; } }
[HttpGet("{value}")] public IActionResult MyErrorTest(int value) { if (value <= 0) { throw new MyCustomException("The value should be positive!"); } return Ok(value); }
app.UseExceptionHandler(exceptionHandlerApp => { exceptionHandlerApp.Run(async context => { context.Response.ContentType = "application/problem+json"; if (context.RequestServices.GetService<IProblemDetailsService>() is { } problemDetailsService) { var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>(); var exceptionType = exceptionHandlerFeature?.Error; if (exceptionType is not null) { (string Title, string Detail, string Type, int StatusCode) details = exceptionType switch { MyCustomException MyCustomException => ( exceptionType.GetType().Name, exceptionType.Message, "https://www.rfc-editor.org/rfc/rfc7231#section-6.5.1", context.Response.StatusCode = (int)MyCustomException.StatusCode ), _ => ( exceptionType.GetType().Name, exceptionType.Message, "https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1", context.Response.StatusCode = StatusCodes.Status500InternalServerError ) }; await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = context, ProblemDetails = { Title = details.Title, Detail = details.Detail, Type = details.Type, Status = details.StatusCode } }); } } }); });
ساختار موجودیت تنظیمات برنامه
تنظیمات برنامه با هر قالبی که تهیه شوند، دست آخر به صورت یک <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
میخواهیم از یک لیست در گزارش خود استفاده کنیم؛ بطور مثال وقتی در LINQ از دستور ToList استفاده میکنیم و میخواهیم آنرا بصورت مستقیم به Stimul بفرستیم. فرض بر این است که شما DLLهای Stimul را به پروژه اضافه کرده اید و آماده گزارشگیری هستید.
مثلا مدلی در Entity FrameWork با نام base_CenterType
public class base_CenterType { public int ID { get; set; } public string Title { get; set; } public string Dsc { get; set; } }
و متدی بصورت ذیل:
public IList<base_CenterType> GetAll() { return _base_CenterType.ToList(); }
طراحی گزارش برای این لیست به این صورت است:
1- اضافه کردن StiWebReport به فرم به نام StiWebReport1
2- با کلیک بر روی فلش سمت راست و بالای StiWebReport1 و انتخاب Design Report، وارد قسمت طراحی میشویم:
3- با راست کلیک بر روی Business Object و انتخاب New Business Object پنجره مربوطه باز میشود:
4- بعد از زدن OK پنجره زیر باز خواهد شد که باید در کادر Name نام Business Object را انتخاب کنیم که برای خوانایی بهتر است همان نام کلاس را برای آن انتخاب کنیم. چون Category نداریم پس باید کادر آن خالی بماند.
در قسمت Columns باید ستونهای هم نام و هم نوع با خواص کلاس base_CenterType را ایجاد کنیم.
و نهایتا Business Objectی به نام base_CenterType با سه ستون ایجاد خواهد شد.
حال میتوانید ستونهای مورد نظر را در گزارش بکار ببرید.
با فرض اینکه گزارش را طراحی کرده و آنرا در ریشه درایو C ذخیره کردهاید، از قطعه کد زیر برای ارسال لیست به گزارش و نمایش آن استفاده میکنیم.
StiReport mainreport = new StiReport(); mainreport.RegBusinessObject("base_CenterType", base_CenterTypeService.GetAll()); mainreport.Load("C:\\StiWebReport2.mrt"); mainreport.Show();
Please open an issue in the library repository to alert its author and ask them to package the library using the Angular Package Format (https://goo.gl/jB3GVv).
مراحل ایجاد یک پروژهی «کتابخانه» توسط Angular CLI 6.0
مرحلهی اول ایجاد یک پروژهی کتابخانه، مانند قبل، توسط دستور ng new و ایجاد یک پروژهی دلخواه جدید است:
ng new my-lib-test
پس از ایجاد پروژهی my-lib-test توسط دستور فوق و وارد شدن به پوشهی اصلی آن توسط خط فرمان، میتوان با اجرای دستور زیر، پروژههای دیگری را به پروژهی جاری افزود:
ng generate application my-app-name
ng generate library my-lib
همچنین یک پوشهی جدید به نام projects نیز ایجاد شده و پروژهی my-lib داخل آن قرار گرفتهاست.
فایل جدید public_api.ts
پس از ایجاد کتابخانهی جدید «my-lib»، فایل جدیدی به نام projects\my-lib\src\public_api.ts نیز به آن اضافه شدهاست:
با این محتوا:
/* * Public API Surface of my-lib */ export * from './lib/my-lib.service'; export * from './lib/my-lib.component'; export * from './lib/my-lib.module';
برای مثال اگر فایل جدید projects\my-lib\src\lib\my-lib.models.ts را به این کتابخانه اضافه کنیم که شامل تعدادی مدل و اینترفیس قابل دسترسی توسط استفاده کنندگان باشد، باید یک سطر زیر را به انتهای فایل public_api.ts اضافه کنیم:
export * from './lib/my-lib.models';
این پروژهی کتابخانه حتی به همراه فایلهای package.json, tsconfig.json, tslint.json مخصوص به خود نیز میباشد تا بتوان آنها را صرفا جهت این پروژه سفارشی سازی کرد.
ساختار my-lib.service پیشفرض یک پروژهی کتابخانه
اگر به فایل projects\my-lib\src\lib\my-lib.service.ts دقت کنیم:
import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class MyLibService { constructor() { } }
شاید بپرسید چرا؟ هدف اصلی از آن، بهبود فرآیند tree-shaking یا حذف کدهای مرده و استفاده نشدهاست. ممکن است سرویسی را تعریف کنید، اما در برنامه استفاده نشود. این حالت خصوصا در پروژههای کتابخانههای ثالث ممکن است زیاد رخ دهد. به همین جهت با ارائهی این قابلیت، امکان حذف سادهتر سرویسهایی که در برنامه استفاده نشدهاند از خروجی نهایی کامپایل شده، وجود خواهد داشت.
چگونه به پروژهی کتابخانهی جدید، یک کامپوننت جدید را اضافه کنیم؟
تمام دستورات Angular CLI، در اینجا نیز کار میکنند. تنها تفاوت آنها، ذکر صریح نام پروژهی مورد استفاده است:
ng generate component show-data --project=my-lib
البته در اینجا باید فایل my-lib.module.ts را اندکی ویرایش کرد و ShowDataComponent را به قسمت exports نیز افزود:
@NgModule({ imports: [ CommonModule, HttpClientModule ], declarations: [MyLibComponent, ShowDataComponent], exports: [MyLibComponent, ShowDataComponent] }) export class MyLibModule { }
همچنین قسمت imports آن نیز به صورت پیشفرض خالی است. اگر نیاز است با ngIf کار کنید، باید CommonModule را در اینجا قید کنید و اگر نیاز است تبادلات HTTP وجود داشته باشد، ذکر HttpClientModule نیز ضروری است.
مرحلهی ساخت پروژه
پیش از استفادهی از این پروژهی کتابخانه، باید آنرا build کرد:
ng build my-lib
پس از اجرای این دستور، خروجی ذیل مشاهده میشود:
Building Angular Package Building entry point 'my-lib' Rendering Stylesheets Rendering Templates Compiling TypeScript sources through ngc Downleveling ESM2015 sources through tsc Bundling to FESM2015 Bundling to FESM5 Bundling to UMD Minifying UMD bundle Remap source maps Relocating source maps Copying declaration files Writing package metadata Removing scripts section in package.json as it's considered a potential security vulnerability. Built my-lib Built Angular Package! - from: D:\my-lib-test\projects\my-lib - to: D:\my-lib-test\dist\my-lib
استفادهی از کتابخانهی تولید شده
پس از پایان موفقیت آمیز مرحلهی Build، اکنون نوبت به استفادهی از این کتابخانه است. استفادهی از آن نیز همانند تمام کتابخانهها و وابستگیهای ثالثی است که تا پیش از این از آنها استفاده کردهایم. برای مثال ماژول آنرا در قسمت imports مربوط به NgModule کلاس AppModule معرفی میکنیم. برای این منظور به فایل src\app\app.module.ts مراجعه کرده و MyLibModule را به نحو ذیل اضافه میکنیم:
import { MyLibModule } from "my-lib"; @NgModule({ imports: [ BrowserModule, MyLibModule ] }) export class AppModule { }
اما سؤال اینجا است که آیا این پوشه پس از build، داخل پوشهی node_modules نیز کپی شدهاست؟ پاسخ آن خیر است و برای مدیریت خودکار آن، به صورت زیر عمل شدهاست:
اگر به فایل tsconfig.json اصلی و واقع در ریشهی workspace دقت کنید، پس از اجرای دستور «ng generate library my-lib»، قسمت paths آن نیز به صورت خودکار ویرایش شدهاست:
{ "compilerOptions": { "paths": { "my-lib": [ "dist/my-lib" ] } } }
برای نمونه اگر شارهگر ماوس را بر روی my-lib قرار دهید، به درستی مسیر خوانده شدن آن، تشخیص داده میشود.
به این ترتیب مسیر این import، چه در این پروژهی محلی و چه برای کسانیکه پوشهی dist/my-lib را به صورت یک بستهی npm جدید دریافت کردهاند، یکی خواهد بود.
در ادامه اگر به فایل app.component.html مراجعه کرده و selector کامپوننت show-data را به آن اضافه کنیم:
<lib-show-data></lib-show-data>
توزیع کتابخانهی ایجاد شده برای عموم
برای اینکه این کتابخانهی تولیدی را در اختیار عموم، در سایت npm قرار دهیم، ابتدا باید کتابخانه را در حالت production build تولید و سپس آنرا publish کرد:
ng build my-lib --prod cd dist/my-lib npm publish
البته دستور آخر نیاز به ایجاد یک اکانت در سایت npm و وارد شدن به آنرا دارد. جزئیات بیشتر آن در اینجا.
public class OrderItem { public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal Discount { get; set; } } public class InvoiceItemGenerator { private readonly OrderItem _orderItem; public InvoiceItemGenerator(OrderItem orderItem) { _orderItem = orderItem; } public dynamic Generate() { dynamic invoiceItem = new ExpandoObject(); invoiceItem.Amount = _orderItem.Quantity * _orderItem.UnitPrice - _orderItem.Discount; return invoiceItem; } }
public class OrderItem { public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal Discount { get; set; } public decimal GetFinalAmount() { return Quantity * UnitPrice - Discount; } } public class InvoiceItemGenerator { private readonly OrderItem _orderItem; public InvoiceItemGenerator(OrderItem orderItem) { _orderItem = orderItem; } public dynamic Generate() { dynamic invoiceItem = new ExpandoObject(); invoiceItem.Amount = _orderItem.GetFinalAmount(); return invoiceItem; } }
جمع بندی
- برخلاف بسیاری از طراحیهای موجود، بر فراز هر موجودیت اصلی (منظور AggregateRoot) باید یک DTO که از این پس با عنوان Model از آنها یاد خواهیم کرد، تعریف شود.
- هیچ تراکنشی برای موجودیتهای فرعی یا همان Detailها نخواهیم داشت. این موجودیتها در تراکنش موجودیت اصلی مرتبط به آن مدیریت خواهند شد.
- هر Commandای که قرار است مرتبط با یک موجودیت اصلی در سیستم انجام پذیرد، باید از منطق تجاری آن موجودیت عبور کند و نباید با دور زدن منطق تجاری، از طرق مختلف تغییراتی بر آن موجودیت اعمال شود. (موضوع مهمی که در ادامه مطلب جاری تشریح خواهد شد)
- ویوهای مختلفی از یک موجودیت میتوان انتظار داشت که ویو پیشفرض آن در CrudService تدارک دیده شده است. برای سایر موارد نیاز است در سرویس مرتبط، متدهای Read مختلفی را پیادهسازی کنید.
- با اعمال اصل CQS، متدهای ثبت و ویرایش در کلاس سرویس پایه CRUD، بعد از انجام عملیات مربوطه، Id و RowVersion مدل ورودی و هچنین Id و TrackingState موجودیتهای فرعی وابسته، مقداردهی خواهند شد و نیاز به انجام یک Query دیگر و بازگشت آن به عنوان خروجی متدها نبوده است. به همین دلیل خروجی این متدها صرفا Result ای میباشد که نشان از امکان Failure بودن انجام آنها میباشد که با اصل مذکور در تضاد نمیباشد.
- ورودی متدهای Read شما که در اکثر موارد نیاز به مهیا کردن خروجی صفحهبندی شده دارند، باید از نوع PagedQueryModel و یا اگر همچنین نیاز به جستجوی پویا براساس فیلدهایی موجود در ReadModel مرتبط دارید، باید از نوع FilteredPagedQueryModel باشد. متدهای الحاقی برای اعمال خودکار این صفحهبندی و جستجوی پویا در نظر گرفته شده است. همچنین خروجی آنها در اکثر موارد از نوع IPagedQueryResult خواهد بود. اگر نیاز است تا جستجوی خاصی داشته باشید که خصوصیتی متناظر با آن فیلد در مدل Read وجود ندارد، لازم است تا از این QueryModelهای مطرح شده، ارثبری کرده و خصوصیت اضافی مدنظر خود را تعریف کنید. بدیهی است که اعمال جستجوی این موارد خاص به عهده توسعه دهنده میباشد.
- عملیات ثبت، ویرایش و حذف، برای کار بر روی لیستی از وهلههای Model، طراحی شدهاند. این موضوع در بسیاری از دومینها قابلیت مورد توجهی میباشد.
- رخداد متناظر با عملیات CUD مرتبط با هر موجودیت اصلی، به عنوان یکسری نقاط قابل گسترش (Extensibility Point) در اختیار سایر بخشهای سیستم میباشد. این رخدادها درون تراکنش جاری Raise خواهند شد؛ از این جهت امکان اعمال یکسری Rule جدید از سمت سایر موءلفههای سیستم موجود میباشد.
- برخلاف بسیاری از طراحیهای موجود، قصد ایجاد لایه انتزاعی برفراز EF Core به منظور رسیدن به Persistence Ignorance را ندارم. بنابراین امروز بسته DNTFrameworkCore.EntityFramework آن آماده میباشد. اگر توسعه دهندهای قصد یکپارچه کردن این زیرساخت را با سایر ORMها یا Micro ORMها داشته باشد، میتواند Pull Request خود را ارسال کند.
- خبر خوب اینکه هیچ وابستگی به AutoMapper به منظور نگاشت مابین موجودیتها و مدلهای متناظر آنها، در این زیرساخت وجود ندارد. با پیاده سازی متدهای MapToModel و MapToEntity میتوانید از کتابخانه Mapper مورد نظر خودتان استفاده کنید؛ یا به صورت دستی این کار را انجام دهید. بعد از چند سال استفاده از AutoMapper، این روزها خیلی اعتقادی به استفاده از آن ندارم.
- هیچ وابستگی به FluentValidation به منظور اعتبارسنجی ورودی متدها یا پیادهسازی قواعد تجاری، در این زیرساخت وجود ندارد. شما امکان استفاده از Attributeهای اعتبارسنجی توکار، پیاده سازی IValidatableObject توسط مدل یا در موارد خاص به منظور پیاده سازی قواعد تجاری پیچیده، پیاده سازی IModelValidator را دارید. با این حال برای یکپارچگی با این کتابخانه محبوب، میتوانید بسته نیوگت DNTFrameworkCore.FluentValidation را نصب کرده و استفاده کنید.
- با اعمال الگوی Template Method در پیاده سازی سرویس CRUD پایه، از طریق تعدادی متد با پیشوندهای Before و After متناظر با عملیات CUD میتوانید در فرآیند انجام آنها نیز دخالت داشته باشید؛ به عنوان مثال: BeforeEditAsync یا AfterCreateAsync
- باتوجه به اینکه در فرآیند انجام متدهای CUD، یکسری Event هم Raise خواهند شد و همچنین در خیلی از موراد شاید نیاز به فراخوانی SaveChange مرتبط با UnitOfWork جاری باشد، لذا مطمئنترین راه حل برای این قضیه و حفظ ثبات سیستم، همان استفاده از تراکنش محیطی میباشد. از این جهت متدهای مذکور با TransactionAttribute نیز تزئین شدهاند که برای فعال سازی این مکانیزم نیاز است تا TransactionInterceptor مربوطه را به سیستم معرفی کنید.
- ValidationInterceptor موجود در زیرساخت، در صورتیکه خروجی متد از نوع Result باشد، خطاهای ممکن را در قالب یک شی Result بازگشت خواهد داد؛ در غیر این صورت یک استثنای ValidationException پرتاب میشود که این مورد هم توسط GlobalExceptionFilter مدیریت خواهند شد و در قالب یک BadRequest به کلاینت ارسال خواهد شد.
- در سناریوهای Master-Detail، قرارداد این است که Detailها به همراه Master متناظر واکشی خواهند شد و در زمان ثبت و یا ویرایش هم همه آنها به همراه Master متناظر خود به سرور ارسال خواهند شد.
PM> Install-Package DNTFrameworkCore -Version 1.0.0 PM> Install-Package DNTFrameworkCore.EntityFramework -Version 1.0.0
[LocalizationResource(Name = "SharedResource", Location = "DNTFrameworkCore.TestAPI")] public class BlogModel : MasterModel<int>, IValidatableObject { public string Title { get; set; } [MaxLength(50, ErrorMessage = "Maximum length is 50")] public string Url { get; set; } public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (Title == "BlogTitle") { yield return new ValidationResult("IValidatableObject Message", new[] {nameof(Title)}); } } }
public class BlogValidator : FluentModelValidator<BlogModel> { public BlogValidator(IMessageLocalizer localizer) { RuleFor(b => b.Title).NotEmpty() .WithMessage(localizer["Blog.Fields.Title.Required"]); } }
public class BlogValidator : ModelValidator<BlogkModel> { public override IEnumerable<ModelValidationResult> Validate(BlogModel model) { yield return new ModelValidationResult(nameof(BlogkModel.Title), "Validation from IModelValidator"); } }
public interface IBlogService : ICrudService<int, BlogModel> { }
public class BlogService : CrudService<Blog, int, BlogModel>, IBlogService { public BlogService(CrudServiceDependency dependency) : base(dependency) { } protected override IQueryable<BlogModel> BuildReadQuery(FilteredPagedQueryModel model) { return EntitySet.AsNoTracking().Select(b => new BlogModel {Id = b.Id, RowVersion = b.RowVersion, Url = b.Url, Title = b.Title}); } protected override Blog MapToEntity(BlogModel model) { return new Blog { Id = model.Id, RowVersion = model.RowVersion, Url = model.Url, Title = model.Title, NormalizedTitle = model.Title.ToUpperInvariant() //todo: normalize based on your requirement }; } protected override BlogModel MapToModel(Blog entity) { return new BlogModel { Id = entity.Id, RowVersion = entity.RowVersion, Url = entity.Url, Title = entity.Title }; } }
[LocalizationResource(Name = "SharedResource", Location = "DNTFrameworkCore.TestAPI")] public class TaskModel : MasterModel<int>, IValidatableObject { public string Title { get; set; } [MaxLength(50, ErrorMessage = "Validation from DataAnnotations")] public string Number { get; set; } public string Description { get; set; } public TaskState State { get; set; } = TaskState.Todo; public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (Title == "IValidatableObject") { yield return new ValidationResult("Validation from IValidatableObject"); } } }
public class TaskReadModel : MasterModel<int> { public string Title { get; set; } public string Number { get; set; } public TaskState State { get; set; } = TaskState.Todo; public DateTimeOffset CreationDateTime { get; set; } public string CreatorUserDisplayName { get; set; } }
public class TaskValidator : ModelValidator<TaskModel> { public override IEnumerable<ModelValidationResult> Validate(TaskModel model) { if (!Enum.IsDefined(typeof(TaskState), model.State)) { yield return new ModelValidationResult(nameof(TaskModel.State), "Validation from IModelValidator"); } } }
public interface ITaskService : ICrudService<int, TaskReadModel, TaskModel, TaskFilteredPagedQueryModel> { }
public class TaskFilteredPagedQueryModel : FilteredPagedQueryModel { public TaskState? State { get; set; } }
پیاده سازی واسط ITaskService با استفاده از AutoMapper
public class TaskService : CrudService<Task, int, TaskReadModel, TaskModel, TaskFilteredPagedQueryModel>, ITaskService { private readonly IMapper _mapper; public TaskService(CrudServiceDependency dependency, IMapper mapper) : base(dependency) { _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); } protected override IQueryable<TaskReadModel> BuildReadQuery(TaskFilteredPagedQueryModel model) { return EntitySet.AsNoTracking() .WhereIf(model.State.HasValue, t => t.State == model.State) .ProjectTo<TaskReadModel>(_mapper.ConfigurationProvider); } protected override Task MapToEntity(TaskModel model) { return _mapper.Map<Task>(model); } protected override TaskModel MapToModel(Task entity) { return _mapper.Map<TaskModel>(entity); } }
به عنوان مثال در کلاس بالا برای نگاشت مابین مدل و موجودیت، از واسط IMapper کتابخانه AutoMapper استفاده شدهاست و همچنین عملیات جستجوی سفارشی در همان متد BuildReadQuery برای تولید کوئری متد Read پیشفرض، قابل ملاحظه میباشد.
مثال سوم: پیادهسازی سرویس یک موجودیت اصلی به همراه تعدادی موجودیت فرعی وابسته (سناریوهای Master-Detail)
گام اول: طراحی Modelهای متناظر
public class UserModel : MasterModel { public string UserName { get; set; } public string DisplayName { get; set; } public string Password { get; set; } public bool IsActive { get; set; } public ICollection<UserRoleModel> Roles { get; set; } = new HashSet<UserRoleModel>(); public ICollection<PermissionModel> Permissions { get; set; } = new HashSet<PermissionModel>(); public ICollection<PermissionModel> IgnoredPermissions { get; set; } = new HashSet<PermissionModel>(); }
مدل بالا متناظر است با موجودیت کاربر سیستم، که به یکسری گروه کاربری متصل میباشد و همچنین دارای یکسری دسترسی مستقیم بوده و یا یکسری دسترسی از او گرفته شدهاست. مدلهای Detail نیز از قرارداد خاصی پیروی خواهند کرد که در ادامه مشاهده خواهیم کرد.
public class PermissionModel : DetailModel<int> { public string Name { get; set; } }
به عنوان مثال PermissionModel بالا از DetailModel جنریکای ارثبری کرده است که دارای Id و TrackingState نیز میباشد.
public class UserRoleModel : DetailModel<int> { public long RoleId { get; set; } }
شاید در نگاه اول برای گروههای کاربری یک کاربر کافی بود تا یک لیست ساده از long را از کلاینت دریافت کنیم. در این صورت نیاز است تا برای تمام موجودیتهای سیستم که چنین شرایط مشابهی را دارند، عملیات ثبت، ویرایش و حذف متناظر با تک تک Detailها را دستی مدیریت کنید. روش فعلی خصوصا برای سناریوهای منفصل به مانند پروژههای تحت وب، پیشنهاد میشود.
گام دوم: پیاده سازی اعتبارسنج مستقل
public class UserValidator : FluentModelValidator<UserModel> { private readonly IUnitOfWork _uow; public UserValidator(IUnitOfWork uow, IMessageLocalizer localizer) { _uow = uow ?? throw new ArgumentNullException(nameof(uow)); RuleFor(m => m.DisplayName).NotEmpty() .WithMessage(localizer["User.Fields.DisplayName.Required"]) .MinimumLength(3) .WithMessage(localizer["User.Fields.DisplayName.MinimumLength"]) .MaximumLength(User.MaxDisplayNameLength) .WithMessage(localizer["User.Fields.DisplayName.MaximumLength"]) .Matches(@"^[\u0600-\u06FF,\u0590-\u05FF,0-9\s]*$") .WithMessage(localizer["User.Fields.DisplayName.RegularExpression"]) .DependentRules(() => { RuleFor(m => m).Must(model => !CheckDuplicateDisplayName(model.DisplayName, model.Id)) .WithMessage(localizer["User.Fields.DisplayName.Unique"]) .OverridePropertyName(nameof(UserModel.DisplayName)); }); RuleFor(m => m.UserName).NotEmpty() .WithMessage(localizer["User.Fields.UserName.Required"]) .MinimumLength(3) .WithMessage(localizer["User.Fields.UserName.MinimumLength"]) .MaximumLength(User.MaxUserNameLength) .WithMessage(localizer["User.Fields.UserName.MaximumLength"]) .Matches("^[a-zA-Z0-9_]*$") .WithMessage(localizer["User.Fields.UserName.RegularExpression"]) .DependentRules(() => { RuleFor(m => m).Must(model => !CheckDuplicateUserName(model.UserName, model.Id)) .WithMessage(localizer["User.Fields.UserName.Unique"]) .OverridePropertyName(nameof(UserModel.UserName)); }); RuleFor(m => m.Password).NotEmpty() .WithMessage(localizer["User.Fields.Password.Required"]) .When(m => m.IsNew, ApplyConditionTo.CurrentValidator) .MinimumLength(6) .WithMessage(localizer["User.Fields.Password.MinimumLength"]) .MaximumLength(User.MaxPasswordLength) .WithMessage(localizer["User.Fields.Password.MaximumLength"]); RuleFor(m => m).Must(model => !CheckDuplicateRoles(model)) .WithMessage(localizer["User.Fields.Roles.Unique"]) .When(m => m.Roles != null && m.Roles.Any(r => !r.IsDeleted)); } private bool CheckDuplicateUserName(string userName, long id) { var normalizedUserName = userName.ToUpperInvariant(); return _uow.Set<User>().Any(u => u.NormalizedUserName == normalizedUserName && u.Id != id); } private bool CheckDuplicateDisplayName(string displayName, long id) { var normalizedDisplayName = displayName.NormalizePersianTitle(); return _uow.Set<User>().Any(u => u.NormalizedDisplayName == normalizedDisplayName && u.Id != id); } private bool CheckDuplicateRoles(UserModel model) { var roles = model.Roles.Where(a => !a.IsDeleted); return roles.GroupBy(r => r.RoleId).Any(r => r.Count() > 1); } }
به عنوان مثال در این اعتبارسنج بالا، قواعدی از جمله بررسی تکراری بودن نامکاربری و از این دست اعتبارسنجیها نیز انجام شده است. نکته حائز اهمیت آن متد CheckDuplicateRoles میباشد:
private bool CheckDuplicateRoles(UserModel model) { var roles = model.Roles.Where(a => !a.IsDeleted); return roles.GroupBy(r => r.RoleId).Any(r => r.Count() > 1); }
با توجه به «نکته مهم» ابتدای بحث، model.Roles، شامل تمام گروههای کاربری متصل شده به کاربر میباشند که در این لیست برخی از آنها با TrackingState.Deleted، برخی دیگر با TrackingState.Added و ... علامتگذاری شدهاند. لذا برای بررسی یکتایی و عدم تکرار در این سناریوها نیاز به اجری پرسوجویی بر روی دیتابیس نمیباشد. بدین منظور، با اعمال یک شرط، گروههای حذف شده را از بررسی خارج کردهایم؛ چرا که آنها بعد از عبور از منطق تجاری، حذف خواهند شد.
گام سوم: پیادهسازی سرویس متناظر
public interface IUserService : ICrudService<long, UserReadModel, UserModel> { }
public class UserService : CrudService<User, long, UserReadModel, UserModel>, IUserService { private readonly IUserManager _manager; public UserService(CrudServiceDependency dependency, IUserManager manager) : base(dependency) { _manager = manager ?? throw new ArgumentNullException(nameof(manager)); } protected override IQueryable<User> BuildFindQuery() { return base.BuildFindQuery() .Include(u => u.Roles) .Include(u => u.Permissions); } protected override IQueryable<UserReadModel> BuildReadQuery(FilteredPagedQueryModel model) { return EntitySet.AsNoTracking().Select(u => new UserReadModel { Id = u.Id, RowVersion = u.RowVersion, IsActive = u.IsActive, UserName = u.UserName, DisplayName = u.DisplayName, LastLoggedInDateTime = u.LastLoggedInDateTime }); } protected override User MapToEntity(UserModel model) { return new User { Id = model.Id, RowVersion = model.RowVersion, IsActive = model.IsActive, DisplayName = model.DisplayName, UserName = model.UserName, NormalizedUserName = model.UserName.ToUpperInvariant(), NormalizedDisplayName = model.DisplayName.NormalizePersianTitle(), Roles = model.Roles.Select(r => new UserRole {Id = r.Id, RoleId = r.RoleId, TrackingState = r.TrackingState}).ToList(), Permissions = model.Permissions.Select(p => new UserPermission { Id = p.Id, TrackingState = p.TrackingState, IsGranted = true, Name = p.Name }).Union(model.IgnoredPermissions.Select(p => new UserPermission { Id = p.Id, TrackingState = p.TrackingState, IsGranted = false, Name = p.Name })).ToList() }; } protected override UserModel MapToModel(User entity) { return new UserModel { Id = entity.Id, RowVersion = entity.RowVersion, IsActive = entity.IsActive, DisplayName = entity.DisplayName, UserName = entity.UserName, Roles = entity.Roles.Select(r => new UserRoleModel {Id = r.Id, RoleId = r.RoleId, TrackingState = r.TrackingState}).ToList(), Permissions = entity.Permissions.Where(p => p.IsGranted).Select(p => new PermissionModel { Id = p.Id, TrackingState = p.TrackingState, Name = p.Name }).ToList(), IgnoredPermissions = entity.Permissions.Where(p => !p.IsGranted).Select(p => new PermissionModel { Id = p.Id, TrackingState = p.TrackingState, Name = p.Name }).ToList() }; } protected override Task BeforeSaveAsync(IReadOnlyList<User> entities, List<UserModel> models) { ApplyPasswordHash(entities, models); ApplySerialNumber(entities, models); return base.BeforeSaveAsync(entities, models); } private void ApplySerialNumber(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.IsActive || !model.Password.IsEmpty() || model.Roles.Any(a => a.IsNew || a.IsDeleted) || model.IgnoredPermissions.Any(p => p.IsDeleted || p.IsNew) || model.Permissions.Any(p => p.IsDeleted || p.IsNew)) { entity.SerialNumber = _manager.NewSerialNumber(); } else { //prevent include SerialNumber in update query UnitOfWork.Entry(entity).Property(a => a.SerialNumber).IsModified = false; } } } private void ApplyPasswordHash(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.Password.IsEmpty()) { entity.PasswordHash = _manager.HashPassword(model.Password); } else { //prevent include PasswordHash in update query UnitOfWork.Entry(entity).Property(a => a.PasswordHash).IsModified = false; } } } }
در سناریوهای Master-Detail نیاز است متد دیگری تحت عنوان BuildFindQuery را نیز بازنویسی کنید. این متد برای بقیه حالات نیاز به بازنویسی نداشت؛ چرا که یک تک موجودیت واکشی میشد و خبری از موجودیتهای Detail نبود. در اینجا لازم است تا روش تولید کوئری FindAsyn رو بازنویسی کنیم تا جزئیات دیگری را نیز واکشی کنیم. به عنوان مثال در اینجا Roles و Permissions کاربر نیز Include شدهاند.
نکته: بازنویسی BuildFindQuery را شاید بتوان با روشهای دیگری هم مانند تزئین موجودیتهای وابسته با یک DetailOfAttribute و مشخص کردن نوع موجودیت اصلی، نیز جایگزین کرد.
متدهای MapToModel و MapToEntity هم به مانند قبل پیادهسازی شدهاند. موضوع دیگری که در برخی از سناریوها پیش خواهد آمد، مربوط است به خصوصیتی که در زمان ثبت ضروری میباشد، ولی در زمان ویرایش اگر مقدار داشت باید با اطلاعات موجود در دیتابیس جایگزین شود؛ مانند Password و SerialNumber در موجودیت کاربر. برای این حالت میتوان از متد BeforeSaveAsync بهره برد؛ به عنوان مثال برای SerialNumber:
private void ApplySerialNumber(IEnumerable<User> entities, IReadOnlyList<UserModel> models) { var i = 0; foreach (var entity in entities) { var model = models[i++]; if (model.IsNew || !model.IsActive || !model.Password.IsEmpty() || model.Roles.Any(a => a.IsNew || a.IsDeleted) || model.IgnoredPermissions.Any(p => p.IsDeleted || p.IsNew) || model.Permissions.Any(p => p.IsDeleted || p.IsNew)) { entity.SerialNumber = _manager.NewSerialNumber(); } else { //prevent include SerialNumber in update query UnitOfWork.Entry(entity).Property(a => a.SerialNumber).IsModified = false; } } }
در اینجا ابتدا بررسی شدهاست که اگر کاربر، جدید میباشد، غیرفعال شده است، کلمه عبور او تغییر داده شده است و یا تغییراتی در دسترسیها و گروههای کاربری او وجود دارد، یک SerialNumber جدید ایجاد کند. در غیر این صورت با توجه به اینکه برای عملیات ویرایش، به صورت منفصل عمل میکنیم، نیاز است تا به شکل بالا، از قید این فیلد در کوئری ویرایش، جلوگیری کنیم.
نکته: متد BeforeSaveAsync دقیقا بعد از ردیابی شدن وهلههای موجودیت توسط Context برنامه و دقیقا قبل از UnitOfWork.SaveChange فراخوانی خواهد شد.
افزودن فیلد آپلود تصاویر، به فرم ثبت اطلاعات یک اتاق
در ادامه به کامپوننت Pages\HotelRoom\HotelRoomUpsert.razor که تا این قسمت آنرا تکمیل کردهایم مراجعه کرده و فیلد جدید InputFile را ذیل قسمت ثبت توضیحات، اضافه میکنیم:
<div class="form-group"> <InputFile OnChange="HandleImageUpload" multiple></InputFile> </div> @code { private async Task HandleImageUpload(InputFileChangeEventArgs args) { } }
- در این کامپوننت، رویداد OnChange، پس از تغییر مجموعهی فایلهای اضافه شدهی به آن، فراخوانی میشود و آرگومانی از نوع InputFileChangeEventArgs را دریافت میکند.
افزودن لیست فایلهای انتخابی به HotelRoomDTO
تا اینجا اگر به BlazorServer.Models\HotelRoomDTO.cs مراجعه کنیم (کلاسی که مدل UI فرم ثبت اطلاعات اتاق را فراهم میکند)، امکان افزودن لیست تصاویر انتخابی به آن وجود ندارد. به همین جهت در این کلاس، تغییر زیر را اعمال میکنیم:
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace BlazorServer.Models { public class HotelRoomDTO { // ... public virtual ICollection<HotelRoomImageDTO> HotelRoomImages { get; set; } = new List<HotelRoomImageDTO>(); } }
تکمیل متد رویدادگردان HandleImageUpload
در ادامه، لیست فایلهای انتخاب شدهی توسط کاربر را دریافت کرده و آنها را آپلود میکنیم:
@inject IHotelRoomService HotelRoomService @inject NavigationManager NavigationManager @inject IJSRuntime JsRuntime @inject IFileUploadService FileUploadService @inject IWebHostEnvironment WebHostEnvironment @code { // ... private async Task HandleImageUpload(InputFileChangeEventArgs args) { var files = args.GetMultipleFiles(maximumFileCount: 5); if (args.FileCount == 0 || files.Count == 0) { return; } var allowedExtensions = new List<string> { ".jpg", ".png", ".jpeg" }; if(!files.Any(file => allowedExtensions.Contains(Path.GetExtension(file.Name), StringComparer.OrdinalIgnoreCase))) { await JsRuntime.ToastrError("Please select .jpg/.jpeg/.png files only."); return; } foreach (var file in files) { var uploadedImageUrl = await FileUploadService.UploadFileAsync(file, WebHostEnvironment.WebRootPath, "Uploads"); HotelRoomModel.HotelRoomImages.Add(new HotelRoomImageDTO { RoomImageUrl = uploadedImageUrl }); } } }
@using Microsoft.AspNetCore.Hosting @using System.Linq @using System.IO
- متد ()args.GetMultipleFiles، امکان دسترسی به فایلهای انتخابی توسط کاربر را میسر میکند که خروجی آن از نوع <IReadOnlyList<IBrowserFile است. در قسمت قبل، سرویس آپلود فایلهایی را که تکمیل کردیم، امکان آپلود یک IBrowserFile را به سرور میسر میکند. اگر متد ()GetMultipleFiles را بدون پارامتری فراخوانی کنیم، حداکثر 10 فایل را قبول میکند و اگر تعداد بیشتری انتخاب شده باشد، یک استثناء را صادر خواهد کرد.
- سپس بر اساس پسوند فایلهای دریافتی، آنها را صرفا به فایلهای تصویری محدود کردهایم.
- در آخر، لیست فایلهای دریافتی را یکی یکی به سرور آپلود کرده و Url دسترسی به آنها را به لیست HotelRoomImages اضافه میکنیم. فایلهای آپلود شده در پوشهی BlazorServer.App\wwwroot\Uploads قابل مشاهده هستند.
نمایش فایلهای انتخاب شدهی توسط کاربر
در ادامه میخواهیم پس از آپلود فایلها، آنها را در ذیل کامپوننت InputFile نمایش دهیم. برای اینکار در ابتدا به فایل wwwroot\css\site.css مراجعه کرده و شیوه نامهی نمایش تصاویر و عناوین آنها را اضافه میکنیم:
.room-image { display: block; width: 100%; height: 150px; background-size: cover !important; border: 3px solid green; position: relative; } .room-image-title { position: absolute; top: 0; right: 0; background-color: green; color: white; padding: 0px 6px; display: inline-block; }
<div class="form-group"> <InputFile OnChange="HandleImageUpload" multiple></InputFile> <div class="row"> @if (HotelRoomModel.HotelRoomImages.Count > 0) { var serial = 1; foreach (var roomImage in HotelRoomModel.HotelRoomImages) { <div class="col-md-2 mt-3"> <div class="room-image" style="background: url('@roomImage.RoomImageUrl') 50% 50%; "> <span class="room-image-title">@serial</span> </div> <button type="button" class="btn btn-outline-danger btn-block mt-4">Delete</button> </div> serial++; } } </div> </div>
ذخیره سازی اطلاعات تصاویر آپلودی یک اتاق در بانک اطلاعاتی
تا اینجا موفق شدیم تصاویر انتخابی کاربر را آپلود کرده و همچنین لیست آنها را نیز نمایش دهیم. در ادامه نیاز است تا این اطلاعات را در بانک اطلاعاتی ثبت کنیم. به همین جهت ابتدا سرویس IHotelRoomImageService را که در قسمت قبل تکمیل کردیم، به کامپوننت جاری تزریق میکنیم و سپس با استفاده از متد CreateHotelRoomImageAsync، رکوردهای تصویر متناظر با اتاق ثبت شده را اضافه میکنیم:
// ... @inject IHotelRoomImageService HotelRoomImageService @code { // ... private async Task AddHotelRoomImageAsync(HotelRoomDTO roomDto) { foreach (var imageDto in HotelRoomModel.HotelRoomImages) { imageDto.RoomId = roomDto.Id; await HotelRoomImageService.CreateHotelRoomImageAsync(imageDto); } } }
HotelRoomModel.HotelRoomImages.Add(new HotelRoomImageDTO { RoomImageUrl = uploadedImageUrl });
محل فراخوانی AddHotelRoomImageAsync فوق، در متد HandleHotelRoomUpsert است که در قسمتهای قبل تکمیل کردیم. در اینجا پس از ثبت اطلاعات اتاق در بانک اطلاعاتی است که به Id آن دسترسی پیدا میکنیم:
private async Task HandleHotelRoomUpsert() { // ... // Create Mode var createdRoomDto = await HotelRoomService.CreateHotelRoomAsync(HotelRoomModel); await AddHotelRoomImageAsync(createdRoomDto); await JsRuntime.ToastrSuccess($"The `{HotelRoomModel.Name}` created successfully."); // ... }
یک نکته: در انتهای بحث خواهیم دید که اینکار غیرضروری است و با وجود رابطهی one-to-many تعریف شدهی توسط EF-Core، اگر لیست HotelRoomImages موجودیت اتاق تعریف شده و در حال ثبت نیز مقدار دهی شده باشد، به صورت خودکار جزئی از این رابطه و تنها در یک رفت و برگشت، ثبت میشود. یعنی همان متد CreateHotelRoomAsync، قابلیت ثبت خودکار اطلاعات خاصیت HotelRoomImages موجودیت اتاق را نیز دارا است.
نمایش تصاویر یک اتاق، در حالت ویرایش رکورد آن
تا اینجا فقط حالت ثبت یک رکورد جدید را پوشش دادیم. در این حالت اگر به لیست اتاقهای ثبت شده مراجعه کرده و بر روی دکمهی edit یکی از آنها کلیک کنیم، به صفحهی ویرایش رکورد منتقل خواهیم شد؛ اما این صفحه، فاقد اطلاعات تصاویر منتسب به آن رکورد است.
علت اینجا است که در حین ویرایش اطلاعات، در متد OnInitializedAsync، هرچند اطلاعات یک اتاق را از بانک اطلاعاتی دریافت کرده و آنرا تبدیل به Dto آن میکنیم که سبب نمایش جزئیات هر خاصیت در فیلد متصل به آن در فرم جاری میشود:
protected override async Task OnInitializedAsync() { if (Id.HasValue) { // Update Mode Title = "Update"; HotelRoomModel = await HotelRoomService.GetHotelRoomAsync(Id.Value); } // ... }
بنابراین به فایل BlazorServer\BlazorServer.Services\HotelRoomService.cs مراجعه کرده و تغییرات زیر را اعمال میکنیم:
namespace BlazorServer.Services { public class HotelRoomService : IHotelRoomService { // ... public IAsyncEnumerable<HotelRoomDTO> GetAllHotelRoomsAsync() { return _dbContext.HotelRooms .Include(x => x.HotelRoomImages) .ProjectTo<HotelRoomDTO>(_mapperConfiguration) .AsAsyncEnumerable(); } public Task<HotelRoomDTO> GetHotelRoomAsync(int roomId) { return _dbContext.HotelRooms .Include(x => x.HotelRoomImages) .ProjectTo<HotelRoomDTO>(_mapperConfiguration) .FirstOrDefaultAsync(x => x.Id == roomId); } } }
افزودن تصاویر جدید، در حین ویرایش یک رکورد
پس از نمایش لیست تصاویر منتسب به یک اتاق در حال ویرایش، اکنون میخواهیم در همین حالت اگر کاربر تصویر جدیدی را انتخاب کرد، این تصویر را نیز به لیست تصاویر ثبت شدهی در بانک اطلاعاتی اضافه کنیم. برای اینکار نیز به متد HandleHotelRoomUpsert مراجعه کرده و از متد AddHotelRoomImageAsync در قسمت به روز رسانی آن استفاده میکنیم:
private async Task HandleHotelRoomUpsert() { //... // Update Mode var updatedRoomDto = await HotelRoomService.UpdateHotelRoomAsync(HotelRoomModel.Id, HotelRoomModel); await AddHotelRoomImageAsync(updatedRoomDto); await JsRuntime.ToastrSuccess($"The `{HotelRoomModel.Name}` updated successfully."); //... }
برای رفع این مشکل میتوان در متد AddHotelRoomImageAsync، جدید بودن یک تصویر را بر اساس RoomId آن بررسی کرد. اگر این RoomId مساوی صفر بود، یعنی تازه به لیست اضافه شدهاست و حاصل بارگذاری اولیهی فرم ویرایش اطلاعات نیست:
private async Task AddHotelRoomImageAsync(HotelRoomDTO roomDto) { foreach (var imageDto in HotelRoomModel.HotelRoomImages.Where(x => x.RoomId == 0)) { imageDto.RoomId = roomDto.Id; await HotelRoomImageService.CreateHotelRoomImageAsync(imageDto); } }
یک نکته: متد AddHotelRoomImageAsync اضافی است!
چون از AutoMapper استفاده میکنیم، در ابتدای متد ثبت یک اتاق، کار نگاشت DTO، به موجودیت متناظر با آن انجام میشود:
public async Task<HotelRoomDTO> CreateHotelRoomAsync(HotelRoomDTO hotelRoomDTO) { var hotelRoom = _mapper.Map<HotelRoom>(hotelRoomDTO);
مقدار دهی RoomId یک تصویر، در اینجا غیرضروری است؛ چون RoomId و Room، به عنوان کلید خارجی این رابطه تعریف شدهاند که در اینجا Room یک تصویر، دقیقا همین اتاق در حال ثبت است و EF Core در حین ثبت نهایی، آنرا به صورت خودکار در تمام تصاویر مرتبط نیز مقدار دهی میکند.
یعنی نیازی به چندین بار رفت و برگشت تعریف شدهی در متد AddHotelRoomImageAsync نیست و اساسا نیازی به آن نیست؛ نه برای ثبت و نه برای ویرایش اطلاعات!
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-17.zip
گاهی از اوقات نیاز میشود تا در یک لیست، آیتمهای تکراری موجود را مشخص کرد. به صورت پیش فرض متد Distinct برای حذف مقادیر تکراری در یک لیست با استفاده از LINQ موجود است که البته آنهم اما و اگرهایی دارد که در ادامه به آن پرداخته خواهد شد، اما باز هم این مورد پاسخ سؤال اصلی نیست (نمیخواهیم موارد تکراری را حذف کنیم).
برای حذف آیتمهای تکراری از یک لیست جنریک میتوان متد زیر را نوشت:
public static List<T> RemoveDuplicates<T>(List<T> items)
{
return (from s in items select s).Distinct().ToList();
}
public static void TestRemoveDuplicates()
{
List<string> sampleList =
new List<string>() { "A1", "A2", "A3", "A1", "A2", "A3" };
sampleList = RemoveDuplicates(sampleList);
foreach (var item in sampleList)
Console.WriteLine(item);
}
public class Employee
{
public int ID { get; set; }
public string FName { get; set; }
public int Age { get; set; }
}
public static void TestRemoveDuplicates()
{
List<Employee> lstEmp = new List<Employee>()
{
new Employee(){ ID=1, Age=20, FName="F1"},
new Employee(){ ID=2, Age=21, FName="F2"},
new Employee(){ ID=1, Age=20, FName="F1"},
};
lstEmp = RemoveDuplicates<Employee>(lstEmp);
foreach (var item in lstEmp)
Console.WriteLine(item.FName);
}
برای رفع این مشکل باید از آرگومان دوم متد distinct جهت معرفی وهلهای از کلاسی که اینترفیس IEqualityComparer را پیاده سازی میکند، کمک گرفت.
public static IEnumerable<TSource> Distinct<TSource>(this IEnumerable<TSource> source, IEqualityComparer<TSource> comparer);
public class EmployeeComparer : IEqualityComparer<Employee>
{
public bool Equals(Employee x, Employee y)
{
//آیا دقیقا یک وهله هستند؟
if (Object.ReferenceEquals(x, y)) return true;
//آیا یکی از وهلهها نال است؟
if (Object.ReferenceEquals(x, null) ||
Object.ReferenceEquals(y, null))
return false;
return x.Age == y.Age && x.FName == y.FName && x.ID == y.ID;
}
public int GetHashCode(Employee obj)
{
if (Object.ReferenceEquals(obj, null)) return 0;
int hashTextual = obj.FName == null ? 0 : obj.FName.GetHashCode();
int hashDigital = obj.Age.GetHashCode();
return hashTextual ^ hashDigital;
}
}
public static List<T> RemoveDuplicates<T>(List<T> items, IEqualityComparer<T> comparer)
{
return (from s in items select s).Distinct(comparer).ToList();
}
public static void TestRemoveDuplicates()
{
List<Employee> lstEmp = new List<Employee>()
{
new Employee(){ ID=1, Age=20, FName="F1"},
new Employee(){ ID=2, Age=21, FName="F2"},
new Employee(){ ID=1, Age=20, FName="F1"},
};
lstEmp = RemoveDuplicates(lstEmp, new EmployeeComparer());
foreach (var item in lstEmp)
Console.WriteLine(item.FName);
}
سؤال: برای یافتن آیتمهای تکراری یک لیست چه باید کرد؟
احتمالا مقاله "روشهایی برای حذف رکوردهای تکراری" را به خاطر دارید. اینجا هم میتوان کوئری LINQ ایی را نوشت که رکوردها را بر اساس سن، گروه بندی کرده و سپس گروههایی را که بیش از یک رکورد دارند، انتخاب نماید.
public static void FindDuplicates()
{
List<Employee> lstEmp = new List<Employee>()
{
new Employee(){ ID=1, Age=20, FName="F1"},
new Employee(){ ID=2, Age=21, FName="F2"},
new Employee(){ ID=1, Age=20, FName="F1"},
};
var query = from c in lstEmp
group c by c.Age into g
where g.Count() > 1
select new { Age = g.Key, Count = g.Count() };
foreach (var item in query)
{
Console.WriteLine("Age {0} has {1} records", item.Age, item.Count);
}
}