الگوی Specification در ASP.NET Core - بهبود Generic Repository Pattern
قبلا و در این لینک در خصوص پیاده سازی الگوی Repositories در Asp.Net Core مفصل صحبت کردیم.
الگوی Repository و Unit of Work برای ایجاد یک لایه انتزاعی بین لایه business logic و لایه data access یک application در نظر گرفته شده است.
در این پست، در مورد پیاده سازی الگوی Specification در برنامه های ASP.NET Core و اینکه چگونه
این الگو می تواند الگوهای Generic Repository موجود را بهبود بخشد و در کنار آن قرار گیرد، صحبت خواهیم کرد.
بیایید یک مثال ساده را برای درک نیاز به استفاده از Specification Pattern مرور کنیم. در زیر یک قطعه کلاس از Developer با ویژگی های مورد نیاز مانند Name، Email، ProposedSalary و غیره آورده شده است.
public class Developer { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public int Level { get; set; } public decimal ProposedSalary { get; set; } public int Followers { get; set; } }
اکنون، احتمالاً یک service layer داریم که مجموعه دادهها را از DB بر روی یک انتزاع مانند Entity Framework Core برمیگرداند.
public class DeveloperService : IDeveloperService { private readonly ApplicationDbContext _context; public DeveloperService(ApplicationDbContext context) { _context = context; } public async Task<IEnumerable<Developer>> GetDeveloperCount() { // return a count of all developers in the database } }
اگرچه شما می توانید تعداد همه Devloperها را دریافت کنید، اما یک نیاز عملی و منطقی تر این است که تعداد توسعه دهندگان با نوعی فیلتر را به دست آورید، موافقید؟ به عنوان مثال، تعداد Devloperهایی که حقوق پیشنهادی آنها 3000 دلار یا بیشتر است، یا توسعه دهندگان با Level متفاوتی را دریافت کنید.
امکانات کاملا نامحدود است!!!
با این حال، این منجر به این می شود که تعداد زیادی توابع در service layer داشته باشیم.
با توسعه نرم افزار و به مرور زمان هرچه نیاز بیشتری بوجود آید، تعداد توابعی یا متدهایی که در نهایت خواهید داشت بیشتر می شود. و اگر به تعداد Devloperها با حقوق پیشنهادی بیشتر از x و Level برابر با y نیاز داشته باشد، چه؟ این یک چالش دیگر است که ممکن است منجر به متدهای اضافی شود.
ممکن است استدلال کنید که میتوانید این فیلترها را مستقیماً روی Entity Framework Core Entity اعمال کنید، چیزی شبیه به
await _context.Developers.Where(a=>a.ProposedSalary > 3000 && a.Level >=2).ToListAsync()
اما نه. این به هیچ وجه به یک codebase نرم افزاری تمیز که نیاز دارید شباهتی ندارد. این رویکرد خیلی زود مقیاس پذیری برنامه شما را به هم می زند در واقع این به هیچ وجه قابل نگهداری نیست.
شما همیشه به یک service layer در برنامه خود نیاز دارید که بین برنامه و پایگاه داده شما قرار گیرد و به تنهایی مسئول مدیریت business logic باشد.
اینجاست که برنامه شما باید از الگوی Specification استفاده کند. دقت کنید، چند محدودیت برای الگوی Generic Repository وجود دارد که با استفاده از Specification Pattern برطرف می شوند.
الگوی Specification برای تعریف فیلترهای نامگذاری شده(define named)، قابل استفاده مجدد(reusable)، ترکیب پذیر(combinable) و تست پذیر(testable) برای Entityها و سایر business objectها استفاده می شود.
یک Specification بخشی از Domain Layer است.
ما یک پروژه می سازیم و به نقطه ای می رسیم که عملاً نیاز به استفاده از Specification دارید.
با این حال، ترکیبی از الگوی Generic Repository به همراه Unit Of Work را به ترکیب اضافه می کنیم تا این پیاده سازی منطقی تر و کاربردی تر شود. ما در اینجا به طور خاص استفاده Specification Pattern را پیاده سازی خواهیم کرد.
پروژه ما براساس آنچه در آموزش کاربردی DDD in ASP.NET Core - قسمت دوم گفته شد پیاده سازی می شود.
در نهایت solution ما در این مرحله اینگونه خواهد بود.
اضافه کردن Model مورد نیاز
در پروژه Domain، یک پوشه جدید به نام Entities ایجاد کنید و يک کلاس به نام Developer را به آن اضافه کنید.
public class Developer { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public int Level { get; set; } public decimal ProposedSalary { get; set; } public int Followers { get; set; } }
اضافه کردن DBContext ,Migrations & Required Packages
بیایید بسته های NuGet مورد نیاز را در پروژه مربوطه نصب کنیم.
Console Package Manager را باز کنید و Infrastructure Project را به عنوان پروژه پیش فرض از منوی کشویی انتخاب کنید در نهایت برای نصب پکیج های مورد نیاز دستورات زیر را کپی کنید.
Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools
سپس پروژه API را به عنوان پروژه پیش فرض تنظیم کنید و دستور زیر را کپی کنید.
Install-Package Microsoft.EntityFrameworkCore.Design
قبل از تنظیم کلاس Application Context، اجازه دهید Connection String را اضافه کنیم. برای این کار، appsettings.json را از API Project باز کنید و موارد زیر را اضافه کنید.
"ConnectionStrings": { "DefaultConnection": "Server=.;Database=SampleSpecificationPattern;Trusted_Connection=True;MultipleActiveResultSets=true" },
با انجام این کار، بیایید کلاس Context مورد نیاز را ایجاد کنیم که به ما در دسترسی به پایگاه داده کمک می کند. برای این کار، در Infrastructure Project، یک پوشه بنام Data و سپس داخل آن یک کلاس جدید اضافه کنید و نام آن را ApplicationDbContext بگذارید.
public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions options) : base(options) { } public DbSet<Developer> Developers { get; set; } }
در اینجا، میبینید که ما کلاس Developer را برای گنجاندن در Application Db Context ذکر میکنیم.
در مرحله بعد، باید این Conrext را به ASP.NET Core Application’s service container خود اضافه کنیم و جزئیات اتصال را نیز پیکربندی کنیم. Program.cs را در پروژه API باز کنید و موارد زیر را اضافه کنید.
builder.Services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)); });
در نهایت، ما آماده هستیم تا migration ها را اضافه کنیم و پایگاه داده را نیز به روز کنیم. یک بار دیگر package manager console را باز کنید و Infrastructure Project را به عنوان پروژه پیش فرض تنظیم کنید. دستورات زیر را اجرا کنید:
add-migration init
update-database
بیایید یک الگوی Generic Repository ایجاد کنیم تا بتواند از ApplicationDbContext برای جستجوی دادهها از پایگاه داده استفاده کند. اهمیت استفاده از الگوی Generic Repository در این است که این کد می تواند برای چندین موجودیت دیگر نیز استفاده مجدد شود.
پیشنهاد می کنم این مطلب را مطالعه کنید : پیاده سازی الگوی Repository در ASP.NET Core
در پوشه Domain، یک پوشه جدید اضافه کنید و نام آن را Interfaces بگذارید. در اینجا، یک اینترفبیس جدید، IGenericRepository را اضافه کنید.
public interface IGenericRepository<T> where T : class { Task<T> GetByIdAsync(int id); Task<List<T>> GetAllAsync(); }
توجه داشته باشید که ما فقط به عملیات Query در پیاده سازی این Repository خواهیم پرداخت. از این رو، ما در اینجا فقط متدهای GetById و GetAll را بررسی خواهیم کرد.
حال بیایید اینترفیس ایجاد شده در بالا را پیاده سازی کنیم.
توجه داشته باشید باید پیاده سازی ها را خارج از Domain اضافه کنیم. این بدان معناست که تمام پیاده سازی های مربوط به داده ها باید در Infrastructure Project اضافه شوند.
در Infrastructure Project یک پوشه جدید بنام Repositories و داخل آن یک کلاس جدید به نام GenericRepository اضافه کنید.
public class GenericRepository<T> : IGenericRepository<T> where T : class { protected readonly ApplicationDbContext _context; public GenericRepository(ApplicationDbContext context) { _context = context; } public async Task<List<T>> GetAllAsync() { return await _context.Set<T>().ToListAsync(); } public async Task<T> GetByIdAsync(int id) { return await _context.Set<T>().FindAsync(); } }
می بینید که ما در حال تزریق نمونه ApplicationDbContext به سازنده این Repository Implementation هستیم.
در نهایت، در Program.cs پروژه API، موارد زیر را اضافه کنید تا اینترفیس های IGenericRepository را در service container برنامه ثبت کنید.
builder.Services.AddScoped(typeof(IGenericRepository<>), (typeof(GenericRepository<>)));
قبلا و در این لینک مفصل به این موضوع پرداخته شده است.
اما بطور خلاصه Generic Repository توسط برخی توسعه دهندگان به عنوان یک ضد الگو در نظر گرفته
می شود. در صورت استفاده نادرست، بله این می تواند تبدیل به یک ضد الگو شود.
شکایت اصلی در مورد Generic Repository این است که یک متد به طور بالقوه می تواند کل کد دسترسی به پایگاه داده را در معرض دید کاربر قرار دهد. همچنین میتواند به معنای نیاز به متدهای متعدد برای هر ترکیبی از الزامات باشد (همانطور که در ابتدای این پست ذکر شد).
به عنوان مثال، به اعلان اینترفیس زیر نگاه کنید.
List<T> FindAsync(Expression<Func<T, bool>> query);
این متد می تواند بخشی از الگوی Generic Repository برای مبارزه با مشکلی باشد که داریم. اما از آنجایی که این متد بسیار تعمیم یافته است، برای Generic Repository امکان ندارد از expressionهایی که به آن منتقل می کنیم اطلاع داشته باشد. ایده دیگر می تواند حذف این متد از رابط IGenericRepository و استفاده از آن در یک رابط جدید، مثلا IDeveloperRepository که از IGenericRepository مشتق شده است باشد. این می تواند کارساز باشد، اما با توجه به اضافه شدن موجودیت ها و تغییرات الزامات در آینده، این تغییر برای ادامه کار هوشمندانه نخواهد بود.
تصور کنید که 20-30 موجودیت جدید داشته باشید و مجبور باشید هزاران Repository جدید ایجاد کنید؟ ایده خوبی نیست، درسته؟ به داشتن چندین متد در IDevloperRepository و پیاده سازی آن فکر کنید... کار تمیزی نیست، درسته؟
اگر راه بسیار تمیزتری برای مقابله با این نیاز وجود داشته باشد چه؟ این دقیقا همان جایی است که Specification Pattern به کار می آید.
الگوی Specification ممکن است در نگاه اول پیچیده به نظر برسد. من هم همین احساس را داشتم.
تنها کاری که باید انجام دهید این است که کلاسهای Specification را ایجاد کنید که معمولاً بسته به نیاز شما بین 2 تا 10 خط هستند. بیایید برویم سراغ Specification Pattern در ASP.NET Core.
در قسمت Domain Project، یک پوشه جدید اضافه کنید و نام آن را Specifications بگذارید. این جایی است که تمام اینترفیس های مربوط به Specification به آن جا می روند.
یک اینترفیس جدید ایجاد کنید و نام آن را ISpecification.cs بگذارید.
public interface ISpecification<T> { Expression<Func<T, bool>> Criteria { get; } List<Expression<Func<T, object>>> Includes { get; } Expression<Func<T, object>> OrderBy { get; } Expression<Func<T, object>> OrderByDescending { get; } }
این فقط یک پیاده سازی حداقلی/minimal است. اجازه دهید هر یک از تعاریف متد اعلام شده را توضیح دهم.
خوب Criteria - اینجا جایی است که می توانید عبارات را بر اساس موجودیت خود اضافه کنید.
Includes – اگر میخواهید دادههای جدول کلید خارجی را درج کنید، میتوانید با استفاده از این متد آن را اضافه کنید.
سپس در همان پوشه، یک کلاس جدید به نام BaseSpecifcation اضافه کنید. این پیاده سازی اینترفیس ISpecification خواهد بود.
public class BaseSpecifcation<T> : ISpecification<T> { public BaseSpecifcation() { } public BaseSpecifcation(Expression<Func<T, bool>> criteria) { Criteria = criteria; } public Expression<Func<T, bool>> Criteria { get; } public List<Expression<Func<T, object>>> Includes { get; } = new List<Expression<Func<T, object>>>(); public Expression<Func<T, object>> OrderBy { get; private set; } public Expression<Func<T, object>> OrderByDescending { get; private set; } protected void AddInclude(Expression<Func<T, object>> includeExpression) { Includes.Add(includeExpression); } protected void AddOrderBy(Expression<Func<T, object>> orderByExpression) { OrderBy = orderByExpression; } protected void AddOrderByDescending(Expression<Func<T, object>> orderByDescExpression) { OrderByDescending = orderByDescExpression; } }
در اینجا، ما 3 متد ضروری و یک constructor را اضافه کردیم.
کمی صبر داشته باشید، در ادامه موضوع روشن تر خواهد شد
برای شروع، بیایید متدی را در اینترفیس IGenericRepository خود اضافه کنیم.
IEnumerable<T> FindWithSpecificationPattern(ISpecification<T> specification = null);
در مرحله بعد، بیایید متد جدید را در کلاس GenericRepository پیاده سازی کنیم.
public IEnumerable<T> FindWithSpecificationPattern(ISpecification<T> specification = null) { return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), specification); }
اکنون، ایده پشت تنظیم همه اینها، ایجاد کلاسهای Specification جداگانه است که میتوانند مجموعههای نتایج خاصی را برگردانند. هر یک از این کلاس های Specification جدید از کلاس BaseSpecification
ارث بری می کنند.
و اما SpecificationEvaluator :
public class SpecificationEvaluator<TEntity> where TEntity : class { public static IQueryable<TEntity> GetQuery(IQueryable<TEntity> inputQuery, ISpecification<TEntity> spec) { var query = inputQuery; if (spec.Criteria != null) { query = query.Where(spec.Criteria); } if (spec.OrderBy != null) { query = query.OrderBy(spec.OrderBy); } if (spec.OrderByDescending != null) { query = query.OrderByDescending(spec.OrderByDescending); } query = spec.Includes.Aggregate(query, (current, include) => current.Include(include)); return query; } }
بیایید اکنون آن کلاس های Specification را ایجاد کنیم.
بنابراین، بیایید 2 کلاس الزامات/Specification را ترسیم کنیم:
در همان پوشه Specification پروژه Domain، اجازه دهید اولین کلاس Specification خود، DeveloperByProposedSalarySpecification را اضافه کنیم.
public class DeveloperByProposedSalarySpecification : BaseSpecifcation<Developer> { public DeveloperByProposedSalarySpecification() { AddOrderByDescending(x => x.ProposedSalary); } }
در اینجا، می بینید که ما از کلاس BaseSpecification و از متد AddOrderByDescending در سازنده استفاده می کنیم. این Specification در حالت ایدهآل فهرستی از توسعهدهندگان را به ترتیب کاهش حقوق پیشنهادی برمیگرداند.
در مرحله بعد، بیایید یک کلاس دیگر اضافه کنیم، DeveloperWithLevelNSpecification
public class DeveloperWithLevelNSpecification: BaseSpecifcation<Developer> { public DeveloperWithLevelNSpecification(int levels) : base(x => x.Level> levels) { AddOrderByDescending(x => x.ProposedSalary); } }
حالا که کلاس های Specification ما آماده است، اجازه دهید برویم سراغ api endpoint.
تحت پروژه API، یک API Controller جدید در پوشه Controllers اضافه کنید و نام آن را DevelopersController بگذارید.
[Route("api/[controller]")] [ApiController] public class DevelopersController : ControllerBase { public readonly IGenericRepository<Developer> _repository; public DevelopersController(IGenericRepository<Developer> repository) { _repository = repository; } [HttpGet] public async Task<IActionResult> GetAll() { var developers = await _repository.GetAllAsync(); return Ok(developers); } [HttpGet("{id}")] public async Task<IActionResult> GetById(int id) { var developer = await _repository.GetByIdAsync(id); return Ok(developer); } [HttpGet("specify")] public async Task<IActionResult> Specify() { //var specification = new DeveloperWithLevelNSpecification(3); var specification = new DeveloperByProposedSalarySpecification(); var developers = _repository.FindWithSpecificationPattern(specification); return Ok(developers); } }
این الگو اغلب در زمینه طراحی دامنه محور استفاده می شود.
الان خیلی منطقی تر است، بله؟ قوانین کسب و کار (الزام ما برای بازگرداندن توسعه دهندگان با سطح معینی از حقوق پیشنهادی یا بالاتر) با معیارهای زنجیره ای ترکیب می شوند .
این الگو به افزایش مقیاس برنامه کمک زیادی می کند. این الگو به طور بالقوه می تواند از Data-Shaping و Pagination نیز پشتیبانی کند.
از آنجایی که الگوي Specification یک query را در یک object توصیف میکند، راهحل خوبی برای مشکل، مکان قرار دادن منطق جستجو، مرتبسازی و صفحهبندی در اختیار ما قرار میدهد.
در حالی که الگوی Specification قدیمیتر از عبارات لامبدا C# است، به طور کلی با lambda expressions مقایسه میشود. برخی از توسعه دهندگان ممکن است فکر کنند که دیگر به آن نیازی نیست و ما می توانیم مستقیماً expressionها را به یک Repository یا یک Domain service ارسال کنیم، همانطور که در زیر نشان داده شده است و بالاتر راجع به آن صحبت شد:
await _context.Developers.Where(a=>a.ProposedSalary > 3000 && a.Level >=2).ToListAsync()
چه موقع باید استفاده کرد؟
برخی از مزایای استفاده از Specification:
چه زمانی استفاده نکنیم؟
استفاده از الگوی Specification با الگوی Unit Of Work/Repository کد ماژولار تري را در اختیار ما قرار
می دهد، کد ما را سازماندهی می کند و اضافه کردن پیچیدگی هاي بیشتر را با رشد برنامه آسان می کند.
اما يک سوال : آیا وقتی میتوانید از Dynamic LINQ استفاده کنید، الگوی Specification منسوخ شده است؟
بیشتر بخوانید : Implementing DDD - Clean Architecture
بیشتر بخوانید : نقشه راه توسعه دهندگان Asp.NET Core