.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 GitHub 存儲庫中找到。 對於 IL 編織,我們將使用 Visual Studio 市場中的 PostSharp。 它是一個商業工具,商業目的需要許可證。 為了進行實驗,您可以選擇免費的 PostSharp Essentials 許可證。

如果您希望運行集成測試,您將需要 MySQL 和 Redis 服務器。 在上面的代碼中,我使用 MariaDB 10.4 和 Redis 5.0 使用 Docker Compose 創建了一個實驗室。 為了使用它,您需要安裝 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 異步功能非常重要。 當使用StackExchange.Redis庫從 Redis 讀取數據時,我們使用StringGetStringGetAsync方法調用,具體取決於我們是處於同步還是異步代碼分支。

代碼執行流程受調用MethodInterceptionArgsargs對象的方法以及為對象的屬性設置值的影響。 最重要的成員:

  • 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(例如,從緩存中讀取的值),我們不希望創建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. 如果 value 與生成的 key 存在,則返回 value 並跳過原始方法執行。
  5. 如果 value 不存在,則調用原始方法並將返回值與生成的 key 一起保存在緩存中。

對於密鑰生成,這裡有一些限制:

  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 編織,我們使用了 Visual Studio 市場的 PostSharp。 該代碼包括一個使用 docker compose 使用 MariaDB 10.4 和 Redis 5.0 製作的實驗室。