راه اندازی StimulSoft Report در ASP.NET MVC
برای مطالعه بیشتر
مشکل بهم ریختگی متون فارسی انگلیسی در کامپیوتر
سندرم شماره نامه به هم ریخته
معرفی برنامهی Subtitle Tools
تغییرات:
- اضافه شدن امکان یکی کردن دیالوگهای دو فایل زیر نویس. برای مثال دو زیرنویس فارسی و انگلیسی را میخواهید یکی کنید.
نکتهای که در اینجا مدنظر است، دسترسی به عدد جمع آخر گزارش و سپس بر اساس آن، ساخت دو ستون اضافی ذیل جدول اصلی گزارش است که موارد مالیات، عوارض، جمع کل و مبلغ به حروف را نسبت به مثالهای قبلی، اضافهتر دارد.
در ادامه کدهای کامل این مثال را مشاهده میکنید. همچنین این کد و کلاسهای وابسته به آن مانند User و TransparentTemplate به سورسهای کتابخانه PdfReport نیز اضافه شدهاند.
using System; using System.Collections.Generic; using iTextSharp.text; using iTextSharp.text.pdf; using PdfReportSamples.Models; using PdfReportSamples.Templates; using PdfRpt.Core.Contracts; using PdfRpt.Core.Helper; using PdfRpt.FluentInterface; namespace PdfReportSamples.Tax { public class TaxPdfReport { public IPdfReportData CreatePdfReport() { return new PdfReport().DocumentPreferences(doc => { doc.RunDirection(PdfRunDirection.RightToLeft); doc.Orientation(PageOrientation.Portrait); doc.PageSize(PdfPageSize.A4); doc.DocumentMetadata(new DocumentMetadata { Author = "Vahid", Application = "PdfRpt", Keywords = "Test", Subject = "Test Rpt", Title = "Test" }); }) .DefaultFonts(fonts => { fonts.Path(AppPath.ApplicationPath + "\\fonts\\irsans.ttf", Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\verdana.ttf"); }) .PagesFooter(footer => { footer.DefaultFooter(DateTime.Now.ToString("MM/dd/yyyy")); }) .PagesHeader(header => { header.DefaultHeader(defaultHeader => { defaultHeader.ImagePath(AppPath.ApplicationPath + "\\Images\\01.png"); defaultHeader.Message("گزارش جدید ما"); }); }) .MainTableTemplate(template => { template.CustomTemplate(new TransparentTemplate()); }) .MainTablePreferences(table => { table.ColumnsWidthsType(TableColumnWidthType.Relative); }) .MainTableDataSource(dataSource => { var listOfRows = new List<User>(); for (int i = 0; i < 7; i++) { listOfRows.Add(new User { Id = i, LastName = "نام خانوادگی " + i, Name = "نام " + i, Balance = i + 1000 }); } dataSource.StronglyTypedList<User>(listOfRows); }) .MainTableSummarySettings(summarySettings => { summarySettings.OverallSummarySettings("جمع کل"); summarySettings.PreviousPageSummarySettings("نقل از صفحه قبل"); summarySettings.PageSummarySettings("جمع صفحه"); }) .MainTableColumns(columns => { columns.AddColumn(column => { column.PropertyName("rowNo"); column.IsRowNumber(true); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(0); column.Width(1); column.HeaderCell("ردیف"); }); columns.AddColumn(column => { column.PropertyName<User>(x => x.Id); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(1); column.Width(2); column.HeaderCell("شماره"); }); columns.AddColumn(column => { column.PropertyName<User>(x => x.Name); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(2); column.Width(3); column.HeaderCell("نام"); }); columns.AddColumn(column => { column.PropertyName<User>(x => x.LastName); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(3); column.Width(3); column.HeaderCell("نام خانوادگی"); }); columns.AddColumn(column => { column.PropertyName<User>(x => x.Balance); column.CellsHorizontalAlignment(HorizontalAlignment.Center); column.IsVisible(true); column.Order(4); column.Width(2); column.HeaderCell("موجودی"); column.ColumnItemsTemplate(template => { template.TextBlock(); template.DisplayFormatFormula(obj => obj == null ? string.Empty : string.Format("{0:n0}", obj)); }); column.AggregateFunction(aggregateFunction => { aggregateFunction.NumericAggregateFunction(AggregateFunction.Sum); aggregateFunction.DisplayFormatFormula(obj => obj == null ? string.Empty : string.Format("{0:n0}", obj)); }); }); }) .MainTableEvents(events => { events.DataSourceIsEmpty(message: "There is no data available to display."); events.MainTableAdded(args => { var balanceData = args.LastOverallAggregateValueOf<User>(u => u.Balance); var balance = double.Parse(balanceData, System.Globalization.NumberStyles.AllowThousands); var others = Math.Round(balance * 1.8 / 100); var tax = Math.Round(balance * 2.2 / 100); var total = balance + tax + others; var taxTable = new PdfPTable(args.Table); // Create a clone of the MainTable's structure taxTable.AddSimpleRow( null /* null = empty cell */, null, null, (data, cellProperties) => { data.Value = "مالیات"; cellProperties.PdfFont = args.PdfFont; cellProperties.HorizontalAlignment = HorizontalAlignment.Left; }, (data, cellProperties) => { data.Value = string.Format("{0:n0}", tax); cellProperties.PdfFont = args.PdfFont; cellProperties.BorderColor = BaseColor.LIGHT_GRAY; cellProperties.ShowBorder = true; }); taxTable.AddSimpleRow( null, null, null, (data, cellProperties) => { data.Value = "عوارض"; cellProperties.PdfFont = args.PdfFont; cellProperties.HorizontalAlignment = HorizontalAlignment.Left; }, (data, cellProperties) => { data.Value = string.Format("{0:n0}", others); cellProperties.PdfFont = args.PdfFont; cellProperties.BorderColor = BaseColor.LIGHT_GRAY; cellProperties.ShowBorder = true; }); taxTable.AddSimpleRow( null, null, null, (data, cellProperties) => { data.Value = "جمع کل"; cellProperties.PdfFont = args.PdfFont; cellProperties.HorizontalAlignment = HorizontalAlignment.Left; }, (data, cellProperties) => { data.Value = string.Format("{0:n0}", total); cellProperties.PdfFont = args.PdfFont; cellProperties.BorderColor = BaseColor.LIGHT_GRAY; cellProperties.ShowBorder = true; }); taxTable.AddSimpleRow( null, null, null, (data, cellProperties) => { data.Value = "قابل پرداخت"; cellProperties.PdfFont = args.PdfFont; cellProperties.HorizontalAlignment = HorizontalAlignment.Left; }, (data, cellProperties) => { data.Value = total.NumberToText(Language.Persian) + " ریال"; cellProperties.PdfFont = args.PdfFont; cellProperties.BorderColor = BaseColor.LIGHT_GRAY; cellProperties.ShowBorder = true; cellProperties.PdfFontStyle = DocumentFontStyle.Bold; }); args.PdfDoc.Add(taxTable); }); }) .Export(export => { export.ToExcel(); }) .Generate(data => data.AsPdfFile(AppPath.ApplicationPath + "\\Pdf\\TaxReportSample.pdf")); } } }
تنها تفاوت این مثال با مثالهای قبلی، کدهای مرتبط با متد events.MainTableAdded میباشند.
توسط متد args.LastOverallAggregateValueOf، میتوان به مقدار نهایی متد تجمعی تعریف شده برای یک ستون خاص دسترسی یافت:
var balanceData = args.LastOverallAggregateValueOf<User>(u => u.Balance); var balance = double.Parse(balanceData, System.Globalization.NumberStyles.AllowThousands);
var others = Math.Round(balance * 1.8 / 100); var tax = Math.Round(balance * 2.2 / 100); var total = balance + tax + others;
var taxTable = new PdfPTable(args.Table);
taxTable.AddSimpleRow( null /* null = empty cell */, null, null, (data, cellProperties) => { data.Value = "مالیات"; cellProperties.PdfFont = args.PdfFont; cellProperties.HorizontalAlignment = HorizontalAlignment.Left; }, (data, cellProperties) => { data.Value = string.Format("{0:n0}", tax); cellProperties.PdfFont = args.PdfFont; cellProperties.BorderColor = BaseColor.LIGHT_GRAY; cellProperties.ShowBorder = true; });
با مقدار دهی data، مقدار مورد نظر در آن سلول ثبت میگردد. با مقدار دهی خواص cellProperties، نوع قلم، جهت قرارگیری و سایر تنظیماتی را که ملاحظه میکنید، میتوان اعمال کرد.
و در آخر لازم است که این جدول جدید را به شیء Document اضافه کنیم تا نمایش داده شود:
args.PdfDoc.Add(taxTable);
یک نکته:
متد NumberToText جزئی از کتابخانه PdfReport (تعریف شده در فضای نام PdfRpt.Core.Helper) است و برای نمایش رقم به حروف میتواند مورد استفاده قرار گیرد:
total.NumberToText(Language.Persian)
دریافت اطلاعات کامل گروه در هدر سفارشی
public PdfGrid RenderingGroupHeader(Document pdfDoc, PdfWriter pdfWriter, IList<CellData> newGroupInfo, IList<SummaryCellData> summaryData) { var teacherFullName = newGroupInfo.GetSafeStringValueOf("TeacherFullName"); var teacherCode = newGroupInfo.GetSafeStringValueOf("TeacherCode"); var teacherId = newGroupInfo.GetSafeStringValueOf("TeacherId"); //-- اینجا میخواهم مجموع //-- SessionCount //-- تمام سطرهای گروه را نمایش دهم . اما تنها به اطلاعات سطر اول دسترسی دارم. //var sessionCount = newGroupInfo.GetSafeStringValueOf("SessionCount"); var table = new PdfGrid(2) { WidthPercentage = 100, HorizontalAlignment = PdfWriter.RUN_DIRECTION_RTL, RunDirection = PdfWriter.RUN_DIRECTION_RTL }; table.AddSimpleRow ( (cellData, cellProperties) => { cellData.Value = teacherFullName; cellProperties.PdfFont = FontHelper.GetIPdfFont(FontHelper.GetFontPath(FarsiFonts.BZar), new BaseColor(Color.Gray), 14); cellProperties.HorizontalAlignment = HorizontalAlignment.Right; }, (cellData, cellProperties) => { cellData.Value = teacherCode; cellProperties.PdfFont = FontHelper.GetIPdfFont(FontHelper.GetFontPath(FarsiFonts.BYekan), new BaseColor(Color.Gray), 13); cellProperties.HorizontalAlignment = HorizontalAlignment.Right; } ); return table.AddBorderToTable(borderColor: BaseColor.LIGHT_GRAY, spacingBefore: 5f); }
1) تهیه یک ActionResult جدید به نام FeedResult برای سازگاری و یکپارچگی بهتر با ASP.NET MVC
2) اعمال زبان فارسی به خروجی نهایی (این مورد حداقل در IE محترم شمرده میشود و فید را، راست به چپ نمایش میدهد)
3) اعمال جهتهای rtl و ltr به متون فارسی یا انگلیسی به صورت خودکار؛ به نحوی که خروجی نهایی در تمام فیدخوانها یکسان به نظر میرسد.
4) اعمال کاراکتر یونیکد RLE به صورت خودکار به عناوین فارسی (این مساله سبب میشود تا عنوانهای ترکیبی متشکل از حروف و کلمات فارسی و انگلیسی، در فیدخوانهایی که متون را، راست به چپ نمایش نمیدهند، صحیح و بدون مشکل نمایش داده شود.)
5) نیازی به کتابخانه اضافی خاصی ندارد و پایه آن فضای نام System.ServiceModel.Syndication دات نت است.
6) تنظیم صحیح ContentEncoding و ContentType جهت نمایش بدون مشکل متون فارسی
سورس کامل این کتابخانه به همراه یک مثال استفاده از آن را از اینجا میتوانید دریافت کنید:
توضیحاتی در مورد نحوه استفاده از آن
کتابخانه کمکی MvcRssHelper به صورت یک پروژه Class library جدا تهیه شده است. بنابراین تنها کافی است ارجاعی را به اسمبلی آن به پروژه خود اضافه کنید. البته این پروژه برای MVC4 کامپایل شده است؛ اما با MVC3 هم قابل کامپایل است. فقط باید ارجاع به اسمبلی System.Web.Mvc.dll را حذف و نمونه MVC3 آنرا جایگزین کنید.
پس از اضافه کردن ارجاعی به اسمبلی آن، اکشن متد فید شما یک چنین امضایی را باید بازگشت دهد:
FeedResult(string feedTitle, IList<FeedItem> rssItems, string language = "fa-IR")
using System; namespace MvcRssHelper { public class FeedItem { public string Title { set; get; } public string AuthorName { set; get; } public string Content { set; get; } public string Url { set; get; } public DateTime LastUpdatedTime { set; get; } } }
الف) تاریخ استاندارد یک فید میلادی است نه شمسی. به همین جهت DateTime در اینجا ظاهر شده است.
ب) Url آدرسی است به مطلب متناظر در سایت و باید یک آدرس مطلق مثلا شروع شده با http باشد.
یک مثال از استفاده آن
فرض کنید مدل مطالب سایت ما به نحو زیر است:
using System; namespace MvcRssApplication.Models { public class Post { public int Id { set; get; } public string Title { set; get; } public string AuthorName { set; get; } public string Body { set; get; } public DateTime Date { set; get; } } }
using System; using System.Collections.Generic; using MvcRssApplication.Models; namespace MvcRssApplication.DataSource { public static class BlogItems { public static IList<Post> GetLastBlogPostsList() { var results = new List<Post>(); for (int i = 1; i < 21; i++) { results.Add(new Post { AuthorName = "شخص " + i, Body = "مطلب " + i, Date = DateTime.Now.AddDays(-i), Id = i, Title = "عنوان "+i }); } return results; } } }
using System.Collections.Generic; using System.Web.Mvc; using MvcRssApplication.DataSource; using MvcRssApplication.Models; using MvcRssHelper; namespace MvcRssApplication.Controllers { public class HomeController : Controller { const int Min15 = 900; [OutputCache(Duration = Min15)] public ActionResult Index() { var list = BlogItems.GetLastBlogPostsList(); var feedItemsList = mapPostsToFeedItems(list); return new FeedResult("فید مطالب سایت ما", feedItemsList); } private List<FeedItem> mapPostsToFeedItems(IList<Post> list) { var feedItemsList = new List<FeedItem>(); foreach (var item in list) { feedItemsList.Add(new FeedItem { AuthorName = item.AuthorName, Content = item.Body, LastUpdatedTime = item.Date, Title = item.Title, //این آدرس باید مطلق باشد به نحو زیر Url = this.Url.Action(actionName: "Details", controllerName: "Post", routeValues: new { id = item.Id }, protocol: "http") }); } return feedItemsList; } } }
BlogItems.GetLastBlogPostsList منبع داده فرضی ما است. در ادامه باید این اطلاعات را به صورت لیستی از FeedItemها در آوریم. میتوانید از AutoMapper استفاده کنید یا در این مثال ساده، نحوه انجام کار را در متد mapPostsToFeedItems ملاحظه میکنید.
نکته مهم بکارگرفته شده در متد mapPostsToFeedItems، استفاده از Url.Action برای تولید آدرسهایی مطلق متناظر با کنترلر نمایش مطالب سایت است.
پس از اینکه feedItemsList نهایی به صورت پویا تهیه شد، تنها کافی است return new FeedResult را به نحوی که ملاحظه میکنید فراخوانی کنیم تا خروجی حاصل به صورت یک فید RSS نمایش داده شود و قابل استفاده باشد. ضمنا جهت کاهش بار سرور میتوان از OutputCache نیز به مدتی مشخص استفاده کرد.
- برخلاف بسیاری از طراحیهای موجود، بر فراز هر موجودیت اصلی (منظور 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 فراخوانی خواهد شد.