- برخلاف بسیاری از طراحیهای موجود، بر فراز هر موجودیت اصلی (منظور 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 فراخوانی خواهد شد.