التخزين المؤقت ومعالجة الاتصال في .NET: برنامج تعليمي عن البرمجة الموجهة إلى الجانب

نشرت: 2022-03-11

لا أحد يحب الكود المعياري. عادةً ما نقوم بتقليلها باستخدام أنماط البرمجة الشائعة الموجهة للكائنات ، ولكن غالبًا ما تكون تكلفة استخدام الأنماط هي نفسها تقريبًا - إن لم تكن أكبر - مما لو كنا قد استخدمنا التعليمات البرمجية المعيارية في المقام الأول. سيكون من الجيد حقًا تحديد جزء من التعليمات البرمجية بطريقة ما يجب أن ينفذ سلوكًا معينًا ، وحل التنفيذ في مكان آخر.

على سبيل المثال ، إذا كان لدينا StudentRepository ، فيمكننا استخدام Dapper للحصول على جميع الطلاب من قاعدة بيانات علائقية:

 public class StudentRepository { public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }

هذا هو تنفيذ بسيط للغاية لمستودع قاعدة البيانات العلائقية. إذا لم تتغير قائمة الطلاب كثيرًا وتم استدعاؤها كثيرًا ، فيمكننا تخزين هذه العناصر مؤقتًا لتحسين وقت استجابة نظامنا. نظرًا لأن لدينا عادةً الكثير من المستودعات (بغض النظر عما إذا كانت علائقية أم لا) في الكود الخاص بنا ، سيكون من الجيد وضع هذا الاهتمام الشامل بالتخزين المؤقت جانبًا واستخدامه بسهولة شديدة ، مثل:

 public class StudentRepository { [Cache] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }

المكافأة لن تقلق بشأن اتصالات قاعدة البيانات. ضع هذا الاهتمام الشامل جانبًا أيضًا ، وقم فقط بتسمية طريقة لاستخدام مدير الاتصال الخارجي ، مثل:

 public class StudentRepository { [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } }

في هذه المقالة ، سننظر في استخدام الأنماط الموجهة للوجه بدلاً من OOP شائعة الاستخدام. على الرغم من وجود AOP لبعض الوقت الآن ، يفضل المطورون عادةً OOP على AOP. في حين أن كل ما تفعله باستخدام AOP يمكن القيام به باستخدام OOP أيضًا ، مثل البرمجة الإجرائية مقابل OOP ، فإن AOP يمنح المطورين مزيدًا من الخيارات في النماذج التي يمكنهم استخدامها. يتم تنظيم كود AOP بشكل مختلف ، وقد يجادل البعض بشكل أفضل ، في جوانب معينة (يقصد التورية) من OOP. في النهاية ، اختيار النموذج الذي يجب استخدامه هو تفضيل شخصي.

كيف نفعلها

في .NET ، يمكن تنفيذ أنماط AOP باستخدام نسج لغة وسيطة ، والمعروف باسم نسج IL . هذه عملية تبدأ بعد تجميع الكود ، وتغير كود IL الذي ينتجه المترجم ، لجعل الكود يحقق السلوك المتوقع. لذلك ، بالنظر إلى المثال المذكور بالفعل ، على الرغم من أننا لم نكتب رمزًا للتخزين المؤقت في هذه الفئة ، فإن الطريقة التي كتبناها سيتم تغييرها (أو استبدالها) من أجل استدعاء رمز التخزين المؤقت. من أجل التوضيح ، يجب أن تبدو النتيجة النهائية كما يلي:

 // Weaved by PostSharp public class StudentRepository { [DebuggerTargetMethod(100663306)] [DebuggerBindingMethod(100663329)] [DebuggerBindingMethod(100663335)] public async Task<IEnumerable<Student>> GetAllAsync( IDbConnection connection = null) { AsyncMethodInterceptionArgsImpl<IEnumerable<Student>> interceptionArgsImpl; try { // ISSUE: reference to a compiler-generated field await <>z__a_1.a2.OnInvokeAsync((MethodInterceptionArgs) interceptionArgsImpl); // ISSUE: reference to a compiler-generated field this.<>1__state = -2; } finally { } return (IEnumerable<Student>) interceptionArgsImpl.TypedReturnValue; } [DebuggerSourceMethod(100663300)] private Task<IEnumerable<Student>> <GetAllAsync>z__OriginalMethod( [Optional] IDbConnection connection) { return (Task<IEnumerable<Student>>) SqlMapperExtensions.GetAllAsync<Student>(connection, (IDbTransaction) null, new int?()); } }

الادوات المطلوبة

يمكن العثور على جميع التعليمات البرمجية الواردة في هذه المقالة ، بما في ذلك الجوانب واختبارات التكامل ، في مستودع جيثب notmarkopadjen/dot-net-aspects-postsharp . بالنسبة إلى النسيج IL ، سنستخدم PostSharp من سوق Visual Studio. إنها أداة تجارية ، والترخيص مطلوب لأغراض تجارية. من أجل التجربة ، يمكنك تحديد ترخيص PostSharp Essentials ، وهو مجاني.

إذا كنت ترغب في إجراء اختبارات التكامل ، فستحتاج إلى خادم MySQL و Redis. في الكود أعلاه ، قمت بإنشاء مختبر باستخدام Docker Compose باستخدام MariaDB 10.4 و Redis 5.0. لاستخدامه ، ستحتاج إلى تثبيت Docker وتشغيل تكوين Compose:

 docker-compose up -d

يمكنك بالطبع استخدام خوادم أخرى وتغيير سلاسل الاتصال في appsettings.json .

الترميز الأساسي الموجه نحو الجانب

لنجرب نمط اعتراض AOP. للقيام بذلك في PostSharp ، نحتاج إلى تنفيذ سمة جديدة ، ورث سمة MethodInterceptionAspect وإلغاء الطرق المطلوبة.

 [PSerializable] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class CacheAttribute : MethodInterceptionAspect { // ... public override void OnInvoke(MethodInterceptionArgs args) { // ... var redisValue = db.StringGet(key); // ... } public override async Task OnInvokeAsync(MethodInterceptionArgs args) { // ... var redisValue = await db.StringGetAsync(key); // ... } }

نرى أن لدينا طريقتين مختلفتين للمزامنة والمكالمات غير المتزامنة. من المهم تنفيذ تلك الميزات بشكل صحيح للاستفادة الكاملة من ميزات .NET غير المتزامنة. عند القراءة من Redis باستخدام مكتبة StackExchange.Redis ، نستخدم استدعاءات أسلوب StringGet أو StringGetAsync ، اعتمادًا على ما إذا كنا متزامنين أو فرع كود غير متزامن.

يتأثر تدفق تنفيذ التعليمات البرمجية باستدعاء طرق MethodInterceptionArgs و args object و تعيين القيم على خصائص الكائن. أهم الأعضاء:

  • طريقة Proceed ( ProceedAsync ) - تستدعي تنفيذ الطريقة الأصلية.
  • خاصية ReturnValue - تحتوي على قيمة الإرجاع لاستدعاء الأسلوب. قبل تنفيذ الطريقة الأصلية ، تكون فارغة ، وبعدها تحتوي على القيمة المرجعة الأصلية. يمكن استبداله في أي وقت.
  • تحتوي خاصية Method - System.Reflection.MethodBase (عادةً System.Reflection.MethodInfo ) على معلومات انعكاس الأسلوب الهدف.
  • خاصية Instance - الكائن الهدف (مثيل أصل الطريقة).
  • خاصية Arguments - تحتوي على قيم الوسيطة. يمكن استبداله في أي وقت.

جانب DbConnection

نريد أن نكون قادرين على استدعاء طرق المستودع دون مثيل IDbConnection ، والسماح للجانب بإنشاء تلك الاتصالات وتوفيرها لاستدعاء الطريقة. في بعض الأحيان ، قد ترغب في توفير الاتصال على أي حال (على سبيل المثال ، بسبب المعاملات) ، وفي تلك المناسبات ، لا ينبغي للجانب أن يفعل شيئًا.

في التنفيذ أدناه ، سيكون لدينا رمز فقط لإدارة اتصال قاعدة البيانات ، كما هو الحال في أي مستودع كيان قاعدة بيانات. في هذه الحالة بالذات ، يتم تحليل مثيل MySqlConnection لتنفيذ الأسلوب والتخلص منه بعد اكتمال تنفيذ الطريقة.

 using Microsoft.Extensions.Configuration; using MySql.Data.MySqlClient; using PostSharp.Aspects; using PostSharp.Aspects.Dependencies; using PostSharp.Serialization; using System; using System.Data; using System.Threading.Tasks; namespace Paden.Aspects.Storage.MySQL { [PSerializable] [ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class DbConnectionAttribute : MethodInterceptionAspect { const string DefaultConnectionStringName = "DefaultConnection"; static Lazy<IConfigurationRoot> config; static string connectionString; public static string ConnectionString { get { return connectionString ?? config.Value.GetConnectionString(DefaultConnectionStringName); } set { connectionString = value; } } static DbConnectionAttribute() { config = new Lazy<IConfigurationRoot>(() => new ConfigurationBuilder().AddJsonFile("appsettings.json", false, false).Build()); } public override void OnInvoke(MethodInterceptionArgs args) { var i = GetArgumentIndex(args); if (!i.HasValue) { args.Proceed(); return; } using (IDbConnection db = new MySqlConnection(ConnectionString)) { args.Arguments.SetArgument(i.Value, db); args.Proceed(); } } public override async Task OnInvokeAsync(MethodInterceptionArgs args) { var i = GetArgumentIndex(args); if (!i.HasValue) { await args.ProceedAsync(); return; } using (IDbConnection db = new MySqlConnection(ConnectionString)) { args.Arguments.SetArgument(i.Value, db); await args.ProceedAsync(); } } private int? GetArgumentIndex(MethodInterceptionArgs args) { var parameters = args.Method.GetParameters(); for (int i = 0; i < parameters.Length; i++) { var parameter = parameters[i]; if (parameter.ParameterType == typeof(IDbConnection) && parameter.IsOptional && args.Arguments[i] == null) { return i; } } return null; } } }

من المهم هنا تحديد ترتيب تنفيذ الجوانب. هنا ، تم القيام بذلك عن طريق تعيين الأدوار الجانبية ، وطلب تنفيذ الأدوار. لا نريد إنشاء IDbConnection إذا لم يتم استخدامه على أي حال (على سبيل المثال ، القيم المقروءة من ذاكرة التخزين المؤقت). يتم تعريفه بالسمات التالية:

 [ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]

يمكن لـ PostSharp أيضًا تنفيذ جميع الجوانب على مستوى الفصل ومستوى التجميع ، لذلك من المهم تحديد نطاق السمة:

 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

تتم قراءة سلسلة الاتصال من appsettings.json ، ولكن يمكن تجاوزها باستخدام الخاصية الثابتة ConnectionString .

سير التنفيذ على النحو التالي:

  1. يعرّف Aspect فهرس الوسيطة الاختياري IDbConnection الذي لم يتم توفير قيمة له. إذا لم يتم العثور عليها ، نتخطى.
  2. يتم إنشاء MySqlConnection بناءً على سلسلة ConnectionString المتوفرة.
  3. تم تعيين قيمة الوسيطة IDbConnection .
  4. الطريقة الأصلية تسمى.

لذلك ، إذا أردنا استخدام هذا الجانب ، فيمكننا فقط استدعاء طريقة المستودع دون توفير اتصال:

 await studentRepository.InsertAsync(new Student { Name = "Not Marko Padjen" }, connection: null);

ذاكرة التخزين المؤقت الجانب

هنا نريد تحديد استدعاءات الطريقة الفريدة وتخزينها مؤقتًا. تعتبر استدعاءات الطريقة فريدة إذا تم استدعاء نفس الطريقة من نفس الفئة بنفس المعلمات.

في التنفيذ أدناه ، في كل طريقة ، يتم إنشاء مفتاح الاعتراض للمكالمة. ثم يتم استخدام هذا للتحقق مما إذا كانت قيمة الإرجاع موجودة على خادم ذاكرة التخزين المؤقت. إذا حدث ذلك ، فسيتم إعادته دون استدعاء الطريقة الأصلية. إذا لم يكن الأمر كذلك ، فسيتم استدعاء الطريقة الأصلية ، ويتم حفظ القيمة التي تم إرجاعها في خادم ذاكرة التخزين المؤقت لمزيد من الاستخدام.

 using Microsoft.Extensions.Configuration; using Newtonsoft.Json; using PostSharp.Aspects; using PostSharp.Aspects.Dependencies; using PostSharp.Serialization; using StackExchange.Redis; using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Text; using System.Threading.Tasks; namespace Paden.Aspects.Caching.Redis { [PSerializable] [ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class CacheAttribute : MethodInterceptionAspect { const int DefaultExpirySeconds = 5 * 60; static Lazy<string> redisServer; public int ExpirySeconds = DefaultExpirySeconds; private TimeSpan? Expiry => ExpirySeconds == -1 ? (TimeSpan?)null : TimeSpan.FromSeconds(ExpirySeconds); static CacheAttribute() { redisServer = new Lazy<string>(() => new ConfigurationBuilder().AddJsonFile("appsettings.json", false, false).Build()["Redis:Server"]); } public override void OnInvoke(MethodInterceptionArgs args) { if (args.Instance is ICacheAware cacheAware && !cacheAware.CacheEnabled) { args.Proceed(); return; } var key = GetKey(args.Method as MethodInfo, args.Arguments); using (var connection = ConnectionMultiplexer.Connect(redisServer.Value)) { var db = connection.GetDatabase(); var redisValue = db.StringGet(key); if (redisValue.IsNullOrEmpty) { args.Proceed(); db.StringSet(key, JsonConvert.SerializeObject(args.ReturnValue), Expiry); } else { args.ReturnValue = JsonConvert.DeserializeObject(redisValue.ToString(), (args.Method as MethodInfo).ReturnType); } } } public override async Task OnInvokeAsync(MethodInterceptionArgs args) { if (args.Instance is ICacheAware cacheAware && !cacheAware.CacheEnabled) { await args.ProceedAsync(); return; } var key = GetKey(args.Method as MethodInfo, args.Arguments); using (var connection = ConnectionMultiplexer.Connect(redisServer.Value)) { var db = connection.GetDatabase(); var redisValue = await db.StringGetAsync(key); if (redisValue.IsNullOrEmpty) { await args.ProceedAsync(); db.StringSet(key, JsonConvert.SerializeObject(args.ReturnValue), Expiry); } else { args.ReturnValue = JsonConvert.DeserializeObject(redisValue.ToString(), (args.Method as MethodInfo).ReturnType.GenericTypeArguments[0]); } } } private string GetKey(MethodInfo method, IList<object> values) { var parameters = method.GetParameters(); var keyBuilder = GetKeyBuilder(method); keyBuilder.Append("("); foreach (var parameter in parameters) { AppendParameterValue(keyBuilder, parameter, values[parameter.Position]); } if (parameters.Any()) { keyBuilder.Remove(keyBuilder.Length - 2, 2); } keyBuilder.Append(")"); return keyBuilder.ToString(); } public static void InvalidateCache<T, TResult>(Expression<Func<T, TResult>> expression) { var methodCallExpression = expression.Body as MethodCallExpression; var keyBuilder = GetKeyBuilder(methodCallExpression.Method); var parameters = methodCallExpression.Method.GetParameters(); var anyMethod = typeof(CacheExtensions).GetMethod(nameof(CacheExtensions.Any)); keyBuilder.Append("("); for (int i = 0; i < parameters.Length; i++) { var parameter = parameters[i]; var argument = methodCallExpression.Arguments[i]; object value = null; if (argument is ConstantExpression constantArgument) { value = constantArgument.Value; } else if (argument is MemberExpression memberArgument) { value = Expression.Lambda(memberArgument).Compile().DynamicInvoke(); } else if (argument is MethodCallExpression methodCallArgument) { if (methodCallArgument.Method == anyMethod.MakeGenericMethod(methodCallArgument.Method.GetGenericArguments())) { value = "*"; } } AppendParameterValue(keyBuilder, parameter, value); } if (methodCallExpression.Arguments.Any()) { keyBuilder.Remove(keyBuilder.Length - 2, 2); } keyBuilder.Append(")"); using (var connection = ConnectionMultiplexer.Connect(redisServer.Value)) { connection.GetDatabase().ScriptEvaluate(@" local keys = redis.call('keys', ARGV[1]) for i=1, #keys, 5000 do redis.call('del', unpack(keys, i, math.min(i + 4999, #keys))) end", values: new RedisValue[] { CacheExtensions.EscapeRedisString(keyBuilder.ToString()) }); } } private static StringBuilder GetKeyBuilder(MethodInfo method) { var keyBuilder = new StringBuilder(); keyBuilder.Append(method.ReturnType.FullName); keyBuilder.Append(" {"); keyBuilder.Append(method.ReflectedType.AssemblyQualifiedName); keyBuilder.Append("}."); keyBuilder.Append(method.ReflectedType.FullName); keyBuilder.Append("."); keyBuilder.Append(method.Name); return keyBuilder; } private static void AppendParameterValue(StringBuilder keyBuilder, ParameterInfo parameter, object value) { keyBuilder.Append(parameter.ParameterType.FullName); keyBuilder.Append(" "); if (parameter.ParameterType == typeof(IDbConnection)) { keyBuilder.Append("<IGNORED>"); } else { keyBuilder.Append(value == null ? "<NULL>" : value.ToString()); } keyBuilder.Append(", "); } } }

هنا ، نحترم أيضًا ترتيب الجوانب. دور الجانب هو Caching ، ويتم تعريفه لمتابعة TransactionHandling :

 [ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]

نطاق السمة هو نفسه بالنسبة لجانب DbConnection:

 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

يمكن تعيين انتهاء صلاحية العناصر المخزنة مؤقتًا على كل طريقة عن طريق تحديد الحقل العام ExpirySeconds (الافتراضي 5 دقائق) على سبيل المثال:

 [Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }

سير التنفيذ على النحو التالي:

  1. تحقق Aspect مما إذا كان المثيل هو ICacheAware والذي يمكن أن يوفر علامة لتخطي استخدام ذاكرة التخزين المؤقت على مثيل كائن معين هذا.
  2. يقوم Aspect بإنشاء مفتاح لاستدعاء الأسلوب.
  3. يفتح Aspect اتصال Redis.
  4. إذا كانت القيمة موجودة بالمفتاح المُنشأ ، فيتم إرجاع القيمة ويتم تخطي تنفيذ الطريقة الأصلية.
  5. إذا لم تكن القيمة موجودة ، يتم استدعاء الطريقة الأصلية ويتم حفظ القيمة المعادة في ذاكرة التخزين المؤقت باستخدام مفتاح تم إنشاؤه.

بالنسبة لإنشاء المفاتيح ، يتم تطبيق بعض القيود هنا:

  1. يتم دائمًا تجاهل IDbConnection ، سواء أكان فارغًا أم لا. يتم ذلك عن قصد من أجل استيعاب استخدام الجانب السابق.
  2. يمكن أن تتسبب القيم الخاصة كقيم سلسلة في قراءة خاطئة من ذاكرة التخزين المؤقت ، مثل قيم <IGNORED> و <NULL> . يمكن تجنب ذلك بترميز القيمة.
  3. لا يتم النظر في أنواع المراجع ، يتم استخدام نوعها فقط ( .ToString() في تقييم القيمة). هذا جيد في معظم الحالات ، ولا يضيف تعقيدًا إضافيًا.

من أجل استخدام ذاكرة التخزين المؤقت بشكل صحيح ، قد يكون من الضروري إبطال ذاكرة التخزين المؤقت قبل انتهاء صلاحيتها ، مثل تحديث الكيان أو حذف الكيان.

 public class StudentRepository : ICacheAware { // ... [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } [Cache] [DbConnection] public Task<Student> GetAsync(int id, IDbConnection connection = null) { return connection.GetAsync<Student>(id); } [DbConnection] public async Task<int> InsertAsync(Student student, IDbConnection connection = null) { var result = await connection.InsertAsync(student); this.InvalidateCache(r => r.GetAllAsync(Any<IDbConnection>())); return result; } [DbConnection] public async Task<bool> UpdateAsync(Student student, IDbConnection connection = null) { var result = await connection.UpdateAsync(student); this.InvalidateCache(r => r.GetAllAsync(Any<IDbConnection>())); this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>())); return result; } [DbConnection] public async Task<bool> DeleteAsync(Student student, IDbConnection connection = null) { var result = await connection.DeleteAsync(student); this.InvalidateCache(r => r.GetAllAsync(Any<IDbConnection>())); this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>())); return result; } }

يقبل التابع المساعد InvalidateCache التعبير ، لذا يمكن استخدام أحرف البدل (مثل إطار Moq):

 this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));

يتم استخدام هذا الجانب بدون معلمات خاصة ، لذلك يجب أن يكون المطورون على دراية فقط بقيود الكود.

ضع كل شيء معا

أفضل طريقة لتجربتها وتصحيح الأخطاء هي باستخدام اختبارات التكامل المتوفرة في مشروع Paden.Aspects.DAL.Tests .

تستخدم طريقة اختبار التكامل التالية خوادم حقيقية (قاعدة بيانات علائقية وذاكرة تخزين مؤقت). يتم استخدام واجهة الاتصال فقط لتتبع استدعاءات الطريقة.

 [Fact] public async Task Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache() { var student = new Student { Id = studentId, Name = "Not Marko Padjen" }; var studentUpdated = new Student { Id = studentId, Name = "Not Marko Padjen UPDATED" }; await systemUnderTest.InsertAsync(student); // Gets entity by id, should save in cache Assert.Equal(student.Name, (await systemUnderTest.GetAsync(studentId)).Name); // Updates entity by id, should invalidate cache await systemUnderTest.UpdateAsync(studentUpdated); var connectionMock = fixture.GetConnectionFacade(); // Gets entity by id, ensures that it is the expected one Assert.Equal(studentUpdated.Name, (await systemUnderTest.GetAsync(studentId, connectionMock)).Name); // Ensures that database was used for the call Mock.Get(connectionMock).Verify(m => m.CreateCommand(), Times.Once); var connectionMockUnused = fixture.GetConnectionFacade(); // Calls again, should read from cache Assert.Equal(studentUpdated.Name, (await systemUnderTest.GetAsync(studentId, connectionMockUnused)).Name); // Ensures that database was not used Mock.Get(connectionMockUnused).Verify(m => m.CreateCommand(), Times.Never); }

يتم إنشاء قاعدة البيانات تلقائيًا والتخلص منها باستخدام أداة الفصل:

 using Microsoft.Extensions.Configuration; using Moq; using MySql.Data.MySqlClient; using Paden.Aspects.DAL.Entities; using Paden.Aspects.Storage.MySQL; using System; using System.Data; namespace Paden.Aspects.DAL.Tests { public class DatabaseFixture : IDisposable { public MySqlConnection Connection { get; private set; } public readonly string DatabaseName = $"integration_test_{Guid.NewGuid():N}"; public DatabaseFixture() { var config = new ConfigurationBuilder().AddJsonFile("appsettings.json", false, false).Build(); var connectionString = config.GetConnectionString("DefaultConnection"); Connection = new MySqlConnection(connectionString); Connection.Open(); new MySqlCommand($"CREATE DATABASE `{DatabaseName}`;", Connection).ExecuteNonQuery(); Connection.ChangeDatabase(DatabaseName); DbConnectionAttribute.ConnectionString = $"{connectionString};Database={DatabaseName}"; } public void RecreateTables() { new MySqlCommand(Student.ReCreateStatement, Connection).ExecuteNonQuery(); } public IDbConnection GetConnectionFacade() { var connectionMock = Mock.Of<IDbConnection>(); Mock.Get(connectionMock).Setup(m => m.CreateCommand()).Returns(Connection.CreateCommand()).Verifiable(); Mock.Get(connectionMock).SetupGet(m => m.State).Returns(ConnectionState.Open).Verifiable(); return connectionMock; } public void Dispose() { try { new MySqlCommand($"DROP DATABASE IF EXISTS `{DatabaseName}`;", Connection).ExecuteNonQuery(); } catch (Exception) { // ignored } Connection.Close(); } } }

يمكن إجراء فحص يدوي أثناء التصحيح فقط لأنه بعد تنفيذ الاختبارات ، يتم حذف قاعدة البيانات وإبطال ذاكرة التخزين المؤقت يدويًا.

على سبيل المثال ، أثناء تنفيذ اختبار Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache ، يمكننا العثور على القيم التالية في قاعدة بيانات Redis:

 127.0.0.1:6379> KEYS * 1) "System.Threading.Tasks.Task`1[[Paden.Aspects.DAL.Entities.Student, Paden.Aspects.DAL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]] {Paden.Aspects.DAL.StudentRepository, Paden.Aspects.DAL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}.Paden.Aspects.DAL.StudentRepository.GetAsync(System.Int32 1, System.Data.IDbConnection <IGNORED>)" 127.0.0.1:6379> GET "System.Threading.Tasks.Task`1[[Paden.Aspects.DAL.Entities.Student, Paden.Aspects.DAL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]] {Paden.Aspects.DAL.StudentRepository, Paden.Aspects.DAL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}.Paden.Aspects.DAL.StudentRepository.GetAsync(System.Int32 1, System.Data.IDbConnection <IGNORED>)" "{\"Id\":1,\"Name\":\"Not Marko Padjen\"}"

اختبار التكامل GetAllAsync_Should_Not_Call_Database_On_Second_Call أيضًا من أن المكالمات المخزنة مؤقتًا أكثر أداءً من مكالمات مصدر البيانات الأصلي. كما أنها تنتج تتبعًا يخبرنا عن مقدار الوقت المستغرق لتنفيذ كل مكالمة:

 Database run time (ms): 73 Cache run time (ms): 9

تحسينات قبل استخدامها في الإنتاج

الكود المقدم هنا مخصص للأغراض التعليمية. قبل استخدامه في نظام العالم الحقيقي ، يمكن إجراء بعض التحسينات:

  • جانب DbConnection:
    • يمكن تنفيذ تجمع الاتصال إذا لزم الأمر.
    • يمكن تنفيذ سلاسل اتصال متعددة. الاستخدام الشائع لهذا هو مجموعة قواعد البيانات العلائقية حيث نميز بين أنواع اتصال للقراءة فقط والقراءة والكتابة.
  • جانب ذاكرة التخزين المؤقت:
    • يمكن تنفيذ تجمع الاتصال إذا لزم الأمر.
    • يمكن أيضًا اعتبار قيم النوع المرجعي جزءًا من المفتاح الذي تم إنشاؤه ، اعتمادًا على حالة الاستخدام. في معظم الحالات ، من المحتمل أن يقدموا فقط عيبًا في الأداء.

لم يتم تنفيذ هذه الميزات هنا لأنها مرتبطة بالمتطلبات المحددة للنظام الذي يتم استخدامها فيه ، وإذا لم يتم تنفيذها بشكل صحيح فلن تساهم في أداء النظام.

خاتمة

قد يجادل المرء بأن "المسؤولية الفردية" ، "المفتوحة - المغلقة" ، و "انعكاس التبعية" من مبادئ SOLID قد يتم تنفيذها بشكل أفضل باستخدام AOP ثم OOP. الحقيقة هي أن هدف مطوري .NET يجب أن يكون تنظيمًا جيدًا للرمز ، والذي يمكن تحقيقه باستخدام العديد من الأدوات والأطر والأنماط القابلة للتطبيق في مواقف محددة.

فقط للتكرار: يمكن العثور على جميع الكود من هذه المقالة ، بما في ذلك الجوانب واختبارات التكامل ، في notmarkopadjen/dot-net-aspects-postsharp GitHub. بالنسبة إلى النسيج IL ، استخدمنا PostSharp من سوق Visual Studio. يشتمل الكود على معمل تم إنشاؤه باستخدام عامل ميناء مؤلف باستخدام MariaDB 10.4 و Redis 5.0.