.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 读取数据时,我们使用StringGet
或StringGetAsync
方法调用,具体取决于我们是处于同步还是异步代码分支。
代码执行流程受调用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(例如,从缓存中读取的值),我们不希望创建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>(); }
执行流程如下:
- 方面检查实例是否为
ICacheAware
,它可以提供一个标志以跳过在此特定对象实例上使用缓存。 - Aspect 为方法调用生成一个键。
- Aspect 打开 Redis 连接。
- 如果 value 与生成的 key 存在,则返回 value 并跳过原始方法执行。
- 如果 value 不存在,则调用原始方法并将返回值与生成的 key 一起保存在缓存中。
对于密钥生成,这里有一些限制:
-
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 编织,我们使用了 Visual Studio 市场的 PostSharp。 该代码包括一个使用 docker compose 使用 MariaDB 10.4 和 Redis 5.0 制作的实验室。