การแคชและการจัดการการเชื่อมต่อใน .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 weaving นี่เป็นกระบวนการที่เริ่มต้นหลังจากการคอมไพล์โค้ด และจะเปลี่ยนโค้ด 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 GitHub สำหรับการทอผ้า IL เราจะใช้ PostSharp จาก Visual Studio Marketplace เป็นเครื่องมือเชิงพาณิชย์ และต้องมีใบอนุญาตเพื่อวัตถุประสงค์ทางการค้า เพื่อการทดลอง คุณสามารถเลือกใบอนุญาต PostSharp Essentials ได้ฟรี

หากคุณต้องการเรียกใช้การทดสอบการรวม คุณจะต้องมีเซิร์ฟเวอร์ MySQL และ Redis ในโค้ดด้านบนนี้ ฉันได้สร้างแล็บด้วย Docker Compose โดยใช้ MariaDB 10.4 และ Redis 5.0 ในการใช้งาน คุณจะต้องติดตั้ง Docker และบูตขึ้น Compose configuration:

 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 async อย่างเต็มที่ เมื่ออ่านจาก Redis โดยใช้ไลบรารี StackExchange.Redis เราใช้การเรียกเมธอด StringGet หรือ StringGetAsync ทั้งนี้ขึ้นอยู่กับว่าเราอยู่ในการซิงค์หรือสาขารหัส async

โฟลว์การเรียกใช้โค้ดได้รับผลกระทบจากการเรียกใช้เมธอดของ MethodInterceptionArgs , วัตถุ args และการตั้งค่าคุณสมบัติของอ็อบเจ็กต์ สมาชิกที่สำคัญที่สุด:

  • 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. ตรวจสอบว่าอินสแตนซ์คือ 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