التخزين المؤقت ومعالجة الاتصال في .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
.
سير التنفيذ على النحو التالي:
- يعرّف Aspect فهرس الوسيطة الاختياري
IDbConnection
الذي لم يتم توفير قيمة له. إذا لم يتم العثور عليها ، نتخطى. - يتم إنشاء MySqlConnection بناءً على سلسلة
ConnectionString
المتوفرة. - تم تعيين قيمة الوسيطة
IDbConnection
. - الطريقة الأصلية تسمى.
لذلك ، إذا أردنا استخدام هذا الجانب ، فيمكننا فقط استدعاء طريقة المستودع دون توفير اتصال:
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>(); }
سير التنفيذ على النحو التالي:
- تحقق Aspect مما إذا كان المثيل هو
ICacheAware
والذي يمكن أن يوفر علامة لتخطي استخدام ذاكرة التخزين المؤقت على مثيل كائن معين هذا. - يقوم Aspect بإنشاء مفتاح لاستدعاء الأسلوب.
- يفتح Aspect اتصال Redis.
- إذا كانت القيمة موجودة بالمفتاح المُنشأ ، فيتم إرجاع القيمة ويتم تخطي تنفيذ الطريقة الأصلية.
- إذا لم تكن القيمة موجودة ، يتم استدعاء الطريقة الأصلية ويتم حفظ القيمة المعادة في ذاكرة التخزين المؤقت باستخدام مفتاح تم إنشاؤه.
بالنسبة لإنشاء المفاتيح ، يتم تطبيق بعض القيود هنا:
- يتم دائمًا تجاهل
IDbConnection
، سواء أكان فارغًا أم لا. يتم ذلك عن قصد من أجل استيعاب استخدام الجانب السابق. - يمكن أن تتسبب القيم الخاصة كقيم سلسلة في قراءة خاطئة من ذاكرة التخزين المؤقت ، مثل قيم
<IGNORED>
و<NULL>
. يمكن تجنب ذلك بترميز القيمة. - لا يتم النظر في أنواع المراجع ، يتم استخدام نوعها فقط (
.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.