يفترض نموذج CQRS بشكل أو بآخر أن استعلامات Query لن تغير حالة التطبيق. بمعنى أن مكالمات متعددة إلى نفس الاستعلام داخل نفس الاستعلام سيكون لها نفس النتيجة.
دع جميع الواجهات المستخدمة كجزء من الاستعلام تكون من نوع IQuery أو IAsyncQuery:
public interface IQuery<TIn, TOut> { TOut Query(TIn input); } public interface IAsyncQuery<TIn, TOut>: IQuery<TIn, Task<TOut> { }
تصف هذه الواجهات تمامًا استلام البيانات ، على سبيل المثال ، استلام الأسعار المنسقة مع مراعاة الخصومات / العلاوات وكل شيء آخر:
public class ProductPriceQuery: IQuery<ProductDto,PricePresentationDto> { public ProductPriceQuery( IQuery<ProductDto, PriceWithSalesDto> priceWithSalesQuery, IQuery<PriceWithSalesDto, PricePresentationDto> pricePresentationQuery) { _priceWithSalesQuery = priceWithSalesQuery; _pricePresentationQuery = pricePresentationQuery; } public PricePresentationDto Query(ProductDto dto) { var withSales = _priceWithSalesQuery(dto); var result = _pricePresentationQuery(withSales); return result; } }
واجهات خطوط الأنابيب
تتمثل ميزة هذا النهج في توحيد واجهات التطبيق ، والتي يمكن بناؤها في خط الأنابيب:
public class Aggregate2Query<TIn, TOut1, TOut2> : BaseAggregateQuery<TIn, TOut2> { public Aggregate2Query( IQuery<TIn, TOut1> query0, IQuery<TOut1, TOut2> query1) : base(query0, query1){} } public abstract class BaseAggregateQuery<TIn, TOut> : IQuery<TIn, TOut> { private object[] queries { get; set; } protected BaseAggregateQuery(params object[] queries) { this.queries = queries; } public TOut Query(TIn input) => queries.Aggregate<object, dynamic>(input, (current, query) => ((dynamic) query).Query(current)); }
سجل مثل هذا:
serviceCollection.AddScoped(typeof(Aggregate2Query<,,>));
نحصل على:
public ProductPriceQuery( BaseAggregateQuery<ProductDto,PriceWithSalesDto,PricePresentationDto> query) { _aggregateQuery = query; } public PricePresentationDto Query(ProductDto dto) => _aggregateQuery.Query(dto);
من الناحية المثالية ، يجب أن تتحول البرمجة إلى مجموعة من المنشئ ، لكنها في الواقع مجرد خدعة جميلة لإرضاء فخر المبرمج.
الديكور و ASP.NET CORE
مكتبة MediatR مبنية على توحيد الواجهات والديكور.
تتيح لك أدوات الديكور تعليق بعض الوظائف الإضافية على واجهة IQuery <TIn ، TOut> القياسية ، على سبيل المثال تسجيل:
public class LoggingQuery<TIn,TOut>: IQuery<TIn,TOut> { public LoggingQuery(IQuery<TIn,TOut> priceQuery) { _priceQuery = priceQuery } public TOut Query(TIn input) { Console.WriteLine($"Query {_priceQuery.GetType()} Start"); var result= _priceQuery.Query(input); Console.WriteLine($"Query {_priceQuery.GetType()} End"); return result; } }
سوف أغفل حقيقة أن الديكور يسمح لك بكتابة وظائف شاملة في مكان واحد ، بدلاً من الانتشار عبر البرنامج ، هذا ليس جزءًا من نطاق هذه المقالة.
إن حاوية IoC القياسية التي يوفرها .Net Core غير قادرة على تسجيل المزينين ، ولكن الصعوبة تكمن في أن لدينا تطبيقين من نفس الواجهة: الاستعلام الأصلي والديكور ، ونفس الواجهة التي ينفذها الديكور يأتي إلى مُنشئ الديكور. لا يمكن للحاوية حل مثل هذا الرسم البياني ورمي خطأ "التبعية الدائرية".
هناك عدة طرق لحل هذه المشكلة ، خاصة بالنسبة للحاوية .Net Core ، يتم كتابة مكتبة Scrutor ، ويمكنها تسجيل المصممين:
services.Decorate(typeof(IQuery<,>), typeof(LoggingQuery<,>));
إذا كنت لا ترغب في إضافة تبعيات إضافية إلى المشروع ، فيمكنك كتابة هذه الوظيفة بنفسك ، وهذا ما قمت به. قبل عرض الكود ، دعنا نناقش التخزين المؤقت لنتائج الاستعلام كجزء من الاستعلام. إذا كنت بحاجة إلى إضافة ذاكرة التخزين المؤقت والمفتاح هو الفئة ، فيجب عليك تجاوز GetHashCode و Equals ، لذلك سنتخلص من المقارنة بالرجوع إليها.
طرق التخزين المؤقت
سأقدم مثالاً على ذاكرة التخزين المؤقت البسيطة:
عند البحث عن قيمة ، يتم استدعاء الأسلوب GetHashCode أولاً للعثور على السلة المطلوبة ، وبعد ذلك ، إذا كانت السلة تحتوي على أكثر من عنصر واحد ، يتم استدعاء Equals للمقارنة. معرفة ما إذا كنت لا تفهم تماما كيف يعمل.
تنشئ ReSharper نفسها هذه الأساليب ، لكننا نطبق التخزين المؤقت على المستوى العالمي ، والمبرمج الذي قام بتطبيق واجهة IQuery <TIn ، TOut> ، وبصفة عامة ، واجهة IQuery <TIn ، TOut> ، لا تنسى وجود SRP. لذلك ، لا يناسبنا توليد الأساليب من خلال resharper.
عندما نتعامل مع وظائف نهاية إلى نهاية ، فإن أطر عمل AOP تنقذ. EqualsFody ، مكون إضافي لـ Fody ، يعيد كتابة IL ، متجاوزة Equals و GetHashCode في الفئات التي تحمل سمة EqualsAttribute.
حتى لا يتم وضع علامة على كل Dto بهذه السمة ، يمكننا إعادة كتابة واجهة IQuery قليلاً
public IQuery<TIn,TOut> where TIn : CachedDto{ } [Equals] public class CachedDto{ }
الآن ستعيد جميع Dto بالتأكيد الأساليب اللازمة ، ونحن لسنا بحاجة إلى إضافة سمة إلى كل إدخال DTO (سيتم التقاطها من الفئة الأساسية). إذا كانت إعادة كتابة IL غير مناسبة لك ، فقم بتطبيق CachedDto على هذا المنوال (استخدم السياق الذي تُسمى فيه أساليب الفئة الأساسية):
public class CachedDto{ public override bool Equals(object x) => DeepEquals.Equals(this,x); public override int GetHashCode() => DeepHash.GetHashCode(this); }
DeepEquals.Equals و DeepHash.GetHashCode يستخدمون الانعكاس ، سيكون أبطأ من Fody ، إنه ليس قاتلاً بالنسبة لتطبيقات الشركات.
ولكن تذكر حول برنامج التقويم الاستراتيجي ، يجب ألا يعرف IQuery أنه تم تخزينه مؤقتًا.
سيكون الحل الأفضل هو تطبيق IEqualityComparer. يأخذها القاموس في المُنشئ ويستخدمه عند الإدراج / الحذف / البحث.
public class EqualityComparerUsingReflection<TKey> : IEqualityComparer<TKey> { public bool Equals(TKey x, TKey y) => DeepEqualsCommonType(x, y); public int GetHashCode(TKey obj) => Hash.GetHashCode(obj); }
الآن يمكنك وضع قيود على TIn ، لقد حققنا ما أردنا. دعنا نكتب ديكور التخزين المؤقت:
public class BaseCacheQuery<TIn, TOut> : IQuery<TIn, TOut> { private readonly ConcurrentDictionary<TIn, TOut> _cache; private readonly IQuery<TIn, TOut> _query; protected BaseCacheQuery( IQuery<TIn, TOut> query, IConcurrentDictionaryFactory<TIn, TOut> factory) { _cache = factory.Create(); _query = query; } public TOut Query(TIn input) => _cache .GetOrAdd(input, x => _query.Query(input)); }
انتبه إلى IConcurrentDictionaryFactory ، والهدف من هذا المصنع هو توفير مثيل للقاموس ، ولكن لماذا لا تنشئه فقط في المنشئ؟
أولاً ، DI و SRP ، من الممكن تمامًا أنك تحتاج إلى إضافة تطبيق مقارنة آخر (على سبيل المثال ، أسهل لأنواع معينة من DTO) أو تغيير التنفيذ تمامًا ، وثانياً ، يكون الموقف ممكنًا عندما تبدأ ذاكرة التخزين المؤقت في التباطؤ نظرًا للانعكاس والتجريد التسريبات. سأقوم بالحل الوسط وإذا تم تجاوز "المساواة" في Dto ولن تستخدم GetHashCode "EqualityComparer" الثقيلة.
الغرض من المصنع هو التحقق مما إذا كانت الطرق قد تم تجاوزها ، إذا كان الأمر كذلك ، لإرجاع قاموس قياسي باستخدام طرق تم إعادة تعريفها في DTO ، لا - مع وجود قاموس مقارنة.
تسجيل
دعنا نعود إلى كيفية تسجيل كل هذا.
وسيطة الخدمات الخاصة بأسلوب ConfigureServices عبارة عن مجموعة من ServiceDescriptors ، حيث يحتوي كل واصف على معلومات حول التبعية المسجلة
public class ServiceDescriptor{
وبالتالي ، تتم إضافة ServiceDescriptor جديد مع LifeTime = Scoped إلى مجموعة الخدمات.
ServiceType = typeof (IService) ، ImplementType = typeof (Service):
services.AddScoped<IService,Service>().
تسمح لك خاصية ApplicationFactory بتحديد كيفية إنشاء التبعية ، وسوف نستخدمها. سأكتب امتدادًا لـ IServiceCollection ، والذي سيجد كل IQuery و IAsyncQuery في التجميعات ، والديكور المعلق والتسجيل.
public static void AddCachedQueries(this IServiceCollection serviceCollection) {
تستحق طريقة AddDecorator اهتمامًا خاصًا ، وهنا نستخدم الطرق الثابتة لفئة ActivatorUtilities. يقبل ActivatorUtilities.CreateInstance IServiceProvider ، ونوع الكائن الذي سيتم إنشاؤه وحالات التبعية التي يقبلها هذا الكائن في المنشئ (يمكنك تحديد فقط تلك التي لم يتم تسجيلها ، وسيتم السماح بالباقي بواسطة الموفر)
يقوم ActivatorUtilities.GetServiceOrCreateInstance - بنفس الشيء ، لكن لا يسمح بالتبعية المفقودة لتمريرها إلى مُنشئ الكائن المُنشأ. إذا تم تسجيل الكائن في الحاوية ، فسوف يقوم ببساطة بإنشائه (أو إرجاع الكائن الذي تم إنشاؤه بالفعل) ، وإذا لم يكن الأمر كذلك ، فسوف يقوم بإنشاء الكائن ، شريطة أن يتمكن من حل جميع تبعياته
وبالتالي ، يمكنك إنشاء دالة تقوم بإرجاع كائن ذاكرة التخزين المؤقت وإضافة واصف يصف هذا التسجيل إلى الخدمات.
دعنا نكتب اختبار:
public class DtoQuery : IQuery<Dto, Something> { private readonly IRepository _repository; public DtoQuery(IRepository repository) { _repository = repository; } public Something Query(Dto input) => _repository.GetSomething(); }
يعد ReposityMock - Mock من مكتبة Moq أمرًا مضحكًا ، ولكن لاختبار عدد المرات التي تم فيها استدعاء أسلوب GetSomething () للمستودع ، فإنه يستخدم أيضًا أدوات تزيين ، على الرغم من أنه يولدها تلقائيًا باستخدام Castle.Interceptor. نحن اختبار الديكور باستخدام الديكور.
هذه هي الطريقة التي يمكنك بها إضافة التخزين المؤقت لجميع نتائج IQuery <TIn ، TOut> ، ومن غير المريح جدًا كتابة الكثير من التعليمات البرمجية لتنفيذ القليل من الوظائف.
حلول أخرى
MediatR
واجهة المكتبة المركزية:
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse> { Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken); }
تتمثل الوظيفة الرئيسية لـ MediatR في إضافة غلافات على IRequestHandler ، على سبيل المثال ، تنفيذ خط أنابيب باستخدام واجهة IPipelineBehavior ، هذه هي الطريقة التي يمكنك بها تسجيل CachePipelineBehaviour ، وسيتم تطبيقه على جميع واجهات IRequestHandler المسجلة:
sc.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachePipelineBehaviour<,>));
ننفذ التخزين المؤقت PipelineBehaviour:
public class CachePipelineBehaviour<TDto, TResult> : IPipelineBehavior<TDto, TResult> { private readonly ConcurrentDictionary<TDto, Task<TResult>> _cache; public CachePipelineBehaviour( IConcurrentDictionaryFactory<TDto, Task<TResult>> cacheFactory) { _cache = cacheFactory.Create(); } public async Task<TResult> Handle(TDto request, CancellationToken cancellationToken, RequestHandlerDelegate<TResult> next) => await _cache.GetOrAdd(request, x => next()); }
تأتي Dto الطلب ورمز الإلغاء و RequestHandlerDelegate إلى أسلوب المؤشر. هذا الأخير هو مجرد التفاف على المكالمات القادمة من الديكور الأخرى و handler'a. تقوم MediatR بفحص التجميعات وتسجيل جميع تطبيقات الواجهة نفسها. لاستخدام تحتاج إلى حقن IMediator ، واستدعاء طريقة إرسال على ذلك يمر Dto:
public async Task<IActionResult>([FromBody] Dto dto){ return Ok(mediator.Send(dto)); }
سوف تجد MediatR نفسها ، وتجد تطبيقًا مناسبًا لـ IRequestHabdler'a وتطبق جميع الديكورات (بالإضافة إلى PipelineBehaviour ، هناك أيضًا IPreRequestHandler و IPostRequestHandler)
قلعة وندسور
ميزة الحاوية هي توليد مغلفة ديناميكية ، وهذا هو دينامية AOP.
يستخدمه Entity Framework في تحميل Lazy ، في getter الخاصية ، يتم استدعاء أسلوب Load لواجهة ILazyLoader ، والتي يتم حقنها في فئات جميع الأغلفة على الكيانات من خلال تطبيق المُنشئ .
لتكوين الحاوية مع إنشاء الأغلفة ، تحتاج إلى إنشاء اعتراض وتسجيله
public class CacheInterceptor<TIn, TOut> : IInterceptor { private readonly ConcurrentDictionary<TIn, TOut> _cache; public CacheInterceptor( IConcurrentDictionaryFactory<TIn, TOut> cacheFactory) { _cache = cacheFactory.Create(); } public void Intercept(IInvocation invocation) { var input = (TIn) invocation.Arguments.Single(); if (_cache.TryGetValue(input, out var value)) invocation.ReturnValue = value; else { invocation.Proceed(); _cache.TryAdd(input, (TOut) invocation.ReturnValue); } } }
توفر واجهة IInvocation معلومات حول عضو الكائن المزين الذي تم الوصول إليه ؛ العضو العام الوحيد في الواجهة هو أسلوب Query ، لذلك لن نتحقق من أن الوصول تم تسجيله إليه ، لا توجد خيارات أخرى.
إذا كان هناك كائن بمثل هذا المفتاح في ذاكرة التخزين المؤقت ، فقم بملء القيمة المرتجعة للطريقة (بدون الاتصال بها) ، وإذا لم يكن كذلك ، فاستدعي الأسلوب Proceed ، والذي بدوره سوف يستدعي الأسلوب المزين ويملأ ReturnValue.
تسجيل اعتراض ورمز كامل يمكن الاطلاع على جيثب