Caching and Connection Handling in .NET: An Aspect-Oriented Programming Tutorial
Nobody likes the boilerplate code. We usually reduce it by using common object-oriented programming patterns, but often the code overhead of using patterns is almost the same—if not bigger—than if we’d used boilerplate code in the first place. It would be really nice to somehow just mark part of the code that should implement certain behavior, and resolve implementation somewhere else.
For example, if we have a StudentRepository
, we can use Dapper to get all the students from a relational database:
public class StudentRepository
{
public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection)
{
return connection.GetAllAsync<Student>();
}
}
This is a very simple implementation of a relational database repository. If the student list doesn’t change much and is called often, we can cache those items to optimize our system’s response time. Since we usually have a lot of repositories (regardless of whether they’re relational or not) in our code, it would be nice to put this cross-cutting concern of caching aside and utilize it very easily, like:
public class StudentRepository
{
[Cache]
public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection)
{
return connection.GetAllAsync<Student>();
}
}
A bonus would be not to worry about database connections. Have this cross-cutting concern aside as well, and just label a method to use external connection manager, like:
public class StudentRepository
{
[Cache]
[DbConnection]
public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null)
{
return connection.GetAllAsync<Student>();
}
}
In this article, we will consider aspect-oriented patterns usage instead of commonly used OOP. Although AOP has existed for some time now, developers usually prefer OOP over AOP. While everything you do with AOP can be done with OOP as well, like procedural programming vs. OOP, AOP gives developers more choice in the paradigms they can use. AOP code is organized differently, and some may argue better, on certain aspects (pun intended) than OOP. In the end, the choice of which paradigm to use is personal preference.
How We Do It
In .NET, AOP patterns can be implemented using intermediate language weaving, better known as IL weaving. This is a process that is initiated after code compilation, and it changes the IL code produced by a compiler, to make the code achieve expected behavior. So, looking at the example already mentioned, even though we didn’t write code for caching in this class, the method we wrote will be changed (or replaced) in order to call caching code. For the sake of illustration, the end result should look something like this:
// 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?());
}
}
Tools Required
All the code from this article, including aspects and integration tests, can be found on the notmarkopadjen/dot-net-aspects-postsharp
GitHub repository. For IL weaving, we will use PostSharp from the Visual Studio marketplace. It is a commercial tool, and a license is required for commercial purposes. For the sake of experimenting, you can select the PostSharp Essentials license, which is free.
If you wish to run the integration tests, you will need MySQL and Redis server. In the code above, I’ve made a lab with Docker Compose using MariaDB 10.4 and Redis 5.0. In order to use it, you will need to install Docker and boot up Compose configuration:
docker-compose up -d
You can, of course, use other servers and change the connection strings in appsettings.json
.
Basic Aspect-oriented Coding
Let’s try out AOP’s interception pattern. To do this in PostSharp, we need to implement a new attribute, inherit the MethodInterceptionAspect
attribute and override required methods.
[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);
// ...
}
}
We see that we have two different methods for sync and async calls. It is important to implement those properly to take full advantage of .NET async features. When reading from Redis using the StackExchange.Redis
library, we use StringGet
or StringGetAsync
method calls, depending on if we are in sync or async code branch.
Code execution flow is affected by invoking methods of MethodInterceptionArgs
, args
object, and setting values to the properties of the object. Most important members:
Proceed
(ProceedAsync
) method - Invokes original method execution.ReturnValue
property - Contains the return value of the method call. Before original method execution, it is empty, and after it contains the original return value. It can be replaced at any time.Method
property -System.Reflection.MethodBase
(usuallySystem.Reflection.MethodInfo
) contains target method reflection information.Instance
property - Target object (method parent instance).Arguments
property - Contains argument values. It can be replaced at any time.
The DbConnection Aspect
We want to be able to call repository methods without an instance of IDbConnection
, and let the aspect create those connections and provide it to the method call. Sometimes, you may want to provide the connection anyway (e.g., because of transactions) and, on those occasions, the aspect should do nothing.
In the implementation below, we will have code only for database connection management, as we would have in any database entity repository. In this particular case, an instance of MySqlConnection
is parsed to the method execution and disposed of after method execution is completed.
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; } } }
It is important here is to specify the execution order of the aspects. Here, it has been done by assigning aspect roles, and ordering roles execution. We don’t want
IDbConnection
to be created if it’s not going to be used anyway (e.g., values read from cache). It is defined by the following attributes:[ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]
PostSharp can also implement all aspects on class level, and assembly level, so it’s important to define attribute scope:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
The connection string is being read from appsettings.json
, but can be overridden using static property ConnectionString
.
The execution flow is as follows:
- The aspect identifies an optional argument index that has no value provided. If not found, we skip.
- MySqlConnection is created based on provided
ConnectionString
. IDbConnection
argument value is set.- The original method is called.
So, if we want to use this aspect, we can just call the repository method with no connection provided:
await studentRepository.InsertAsync(new Student
{
Name = "Not Marko Padjen"
}, connection: null);
Cache Aspect
Here we want to identify unique method calls and cache them. Method calls are considered unique if the same method from the same class has been called with the same parameters.
In the implementation below, on each method, the interception key is created for the call. This is then used to check if the return value exists on the cache server. If it does, it is returned without calling the original method. If it’s not, the original method is called, and the returned value is saved to the cache server for further usage.
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(", ");
}
}
}
Here, we also respect the order of the aspects. The aspect role is Caching
, and it is defined to go after TransactionHandling
:
[ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]
The attribute scope is the same as for the DbConnection aspect:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
Cached items expiration can be set on each method by defining the public field
ExpirySeconds
(default is 5 minutes) e.g.:
[Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }
The execution flow is as follows:
- Aspect check if the instance is
ICacheAware
which can provide a flag to skip using cache on this certain object instance. - Aspect generates a key for the method call.
- Aspect opens Redis connection.
- If the value exists with generated key, value is returned and the original method execution is skipped.
- If value doesn’t exist, the original method is called and the return value is saved in the cache with a generated key.
For key generation some restrictions here apply:
IDbConnection
as a parameter is always ignored, is null or not. This is done on purpose in order to accommodate usage of previous aspect.- Special values as string values can cause the wrong read from cache, like
<IGNORED>
and<NULL>
values. This can be avoided with value encoding. - Reference types are not considered, only their type (
.ToString()
is used on value evaluation). In most cases, this is fine and doesn’t add additional complexity.
In order to use cache properly, it may be required to invalidate the cache before it’s expired, like on entity update, or entity deletion.
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
helper method accepts expression, so wildcards can be used (similar like the Moq framework):
this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));
This aspect is being used with no special parameters, so developers should only be aware of code limitations.
Putting It All Together
The best way is to try it out and debug is by using the integration tests provided in the project Paden.Aspects.DAL.Tests
.
The following integration test method uses real servers (relational database and cache). Connection facade is utilized only to keep track of method calls.
[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);
}
The database is automatically created and disposed of using class fixture:
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();
}
}
}
A manual check can be performed during debugging only because after tests are executed, the database is deleted and the cache is manually invalidated.
For example, during the execution of Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache
the test, we can find the following values in the Redis database:
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\"}"
Integration test GetAllAsync_Should_Not_Call_Database_On_Second_Call
is also making sure that cached calls are more performant than the original data source calls. They also produce trace which tells us how much time it took to execute each call:
Database run time (ms): 73
Cache run time (ms): 9
Improvements Before Using in Production
The code provided here is made for educational purposes. Before using it in a real-world system, some improvements may be done:
- DbConnection aspect:
- A connection pool can be implemented if required.
- Multiple connection strings can be implemented. Common usage for this is a relational database cluster where we distinct read-only and read-write connection types.
- Cache aspect:
- A connection pool can be implemented if required.
- Reference type values can also be considered as part of the generated key, depending on the use case. In most cases, they would probably only provide performance drawbacks.
These features have not been implemented here because they are related to the specific requirements of the system they are being used in, and if not implemented properly wouldn’t contribute to the system’s performance.
Conclusion
One may argue that “single responsibility,” “open-closed,” and “dependency inversion” from the principle of the SOLID principle may be better implemented with AOP then with OOP. The fact is that the goal for .NET developers should be good code organization, which can be achieved with many tools, frameworks, and patterns applicable to specific situations.
Just to reiterate: All the code from this article, including aspects and integration tests, can be found on the notmarkopadjen/dot-net-aspects-postsharp
GitHub repository. For IL weaving, we used PostSharp from the Visual Studio marketplace. The code includes a lab-made with docker-compose using MariaDB 10.4 and Redis 5.0.
Add comment