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 فراخوانی خواهد شد.