Almacenamiento en caché y manejo de conexiones en .NET: un tutorial de programación orientado a aspectos
Publicado: 2022-03-11A nadie le gusta el código repetitivo. Por lo general, lo reducimos mediante el uso de patrones de programación orientados a objetos comunes, pero a menudo la sobrecarga de código del uso de patrones es casi la misma, si no mayor, que si hubiéramos utilizado código repetitivo en primer lugar. Sería muy bueno de alguna manera marcar parte del código que debería implementar cierto comportamiento y resolver la implementación en otro lugar.
Por ejemplo, si tenemos un StudentRepository
, podemos usar Dapper para obtener todos los estudiantes de una base de datos relacional:
public class StudentRepository { public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Esta es una implementación muy simple de un repositorio de base de datos relacional. Si la lista de estudiantes no cambia mucho y se llama con frecuencia, podemos almacenar en caché esos elementos para optimizar el tiempo de respuesta de nuestro sistema. Dado que generalmente tenemos muchos repositorios (independientemente de si son relacionales o no) en nuestro código, sería bueno dejar de lado esta preocupación transversal del almacenamiento en caché y utilizarlo muy fácilmente, como:
public class StudentRepository { [Cache] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Una ventaja sería no preocuparse por las conexiones de la base de datos. Tenga a un lado esta preocupación transversal también, y simplemente etiquete un método para usar el administrador de conexión externo, como:
public class StudentRepository { [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } }
En este artículo, consideraremos el uso de patrones orientados a aspectos en lugar de OOP de uso común. Aunque AOP existe desde hace algún tiempo, los desarrolladores generalmente prefieren OOP sobre AOP. Si bien todo lo que hace con AOP también se puede hacer con OOP, como la programación de procedimientos frente a OOP, AOP brinda a los desarrolladores más opciones en los paradigmas que pueden usar. El código AOP está organizado de manera diferente, y algunos pueden argumentar mejor, en ciertos aspectos (juego de palabras) que OOP. Al final, la elección de qué paradigma usar es una preferencia personal.
Cómo lo hacemos
En .NET, los patrones AOP se pueden implementar mediante tejido de lenguaje intermedio, mejor conocido como tejido IL . Este es un proceso que se inicia después de la compilación del código y cambia el código IL producido por un compilador para que el código logre el comportamiento esperado. Entonces, mirando el ejemplo ya mencionado, aunque no escribimos código para el almacenamiento en caché en esta clase, el método que escribimos se cambiará (o reemplazará) para llamar al código de almacenamiento en caché. Por el bien de la ilustración, el resultado final debería verse así:
// 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?()); } }
Herramientas necesarias
Todo el código de este artículo, incluidos los aspectos y las pruebas de integración, se puede encontrar en el repositorio de GitHub notmarkopadjen/dot-net-aspects-postsharp
. Para el tejido de IL, usaremos PostSharp del mercado de Visual Studio. Es una herramienta comercial y se requiere una licencia para fines comerciales. Para experimentar, puede seleccionar la licencia de PostSharp Essentials, que es gratuita.
Si desea ejecutar las pruebas de integración, necesitará un servidor MySQL y Redis. En el código anterior, hice un laboratorio con Docker Compose usando MariaDB 10.4 y Redis 5.0. Para usarlo, deberá instalar Docker y arrancar la configuración de Compose:
docker-compose up -d
Por supuesto, puede usar otros servidores y cambiar las cadenas de conexión en appsettings.json
.
Codificación básica orientada a aspectos
Probemos el patrón de intercepción de AOP. Para hacer esto en PostSharp, necesitamos implementar un nuevo atributo, heredar el atributo MethodInterceptionAspect
y anular los métodos requeridos.
[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); // ... } }
Vemos que tenemos dos métodos diferentes para llamadas sincronizadas y asincrónicas. Es importante implementarlos correctamente para aprovechar al máximo las funciones asíncronas de .NET. Cuando leemos desde Redis usando la biblioteca StackExchange.Redis
, usamos las llamadas al método StringGet
o StringGetAsync
, dependiendo de si estamos en una rama de código sincronizada o asíncrona.
El flujo de ejecución del código se ve afectado por la invocación de métodos de MethodInterceptionArgs
, el objeto args
y el establecimiento de valores en las propiedades del objeto. Miembros más importantes:
- Método
Proceed
(ProceedAsync
): invoca la ejecución del método original. - Propiedad
ReturnValue
: contiene el valor de retorno de la llamada al método. Antes de la ejecución del método original, está vacío y después contiene el valor de retorno original. Puede ser reemplazado en cualquier momento. - Propiedad de
Method
:System.Reflection.MethodBase
(normalmenteSystem.Reflection.MethodInfo
) contiene información de reflexión del método de destino. - Propiedad de
Instance
: objeto de destino (instancia principal del método). - Propiedad de
Arguments
: contiene valores de argumento. Puede ser reemplazado en cualquier momento.
El aspecto de la conexión Db
Queremos poder llamar a métodos de repositorio sin una instancia de IDbConnection
y dejar que el aspecto cree esas conexiones y las proporcione a la llamada al método. A veces, es posible que desee proporcionar la conexión de todos modos (por ejemplo, debido a las transacciones) y, en esas ocasiones, el aspecto no debería hacer nada.
En la implementación a continuación, tendremos código solo para la administración de la conexión de la base de datos, como lo tendríamos en cualquier repositorio de entidad de base de datos. En este caso particular, una instancia de MySqlConnection
se analiza para la ejecución del método y se elimina una vez que se completa la ejecución del método.
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; } } }
Es importante aquí es especificar el orden de ejecución de los aspectos. Aquí, se ha hecho asignando roles de aspecto y ordenando la ejecución de roles. No queremos que se IDbConnection
si no se va a usar de todos modos (por ejemplo, valores leídos de caché). Se define por los siguientes atributos:
[ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]
PostSharp también puede implementar todos los aspectos a nivel de clase y de ensamblaje, por lo que es importante definir el alcance del atributo:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
La cadena de conexión se lee desde appsettings.json
, pero se puede anular mediante la propiedad estática ConnectionString
.
El flujo de ejecución es el siguiente:
- Aspect identifica el índice de argumento opcional de
IDbConnection
que no tiene ningún valor proporcionado. Si no se encuentra, saltamos. - MySqlConnection se crea en función de
ConnectionString
proporcionado. - Se establece el valor del argumento
IDbConnection
. - Se llama método original.
Entonces, si queremos usar este aspecto, podemos simplemente llamar al método de repositorio sin proporcionar ninguna conexión:
await studentRepository.InsertAsync(new Student { Name = "Not Marko Padjen" }, connection: null);
Aspecto de caché
Aquí queremos identificar llamadas de método únicas y almacenarlas en caché. Las llamadas a métodos se consideran únicas si se ha llamado al mismo método de la misma clase con los mismos parámetros.
En la implementación a continuación, en cada método, se crea la clave de intercepción para la llamada. Esto luego se usa para verificar si el valor de retorno existe en el servidor de caché. Si lo hace, se devuelve sin llamar al método original. Si no es así, se llama al método original y el valor devuelto se guarda en el servidor de caché para su uso posterior.
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(", "); } } }
Aquí también respetamos el orden de los aspectos. El rol de aspecto es Caching
y está definido para ir después de TransactionHandling
:

[ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]
El alcance del atributo es el mismo que para el aspecto DbConnection:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
La caducidad de los elementos almacenados en caché se puede configurar en cada método definiendo el campo público ExpirySeconds
(el valor predeterminado es 5 minutos), por ejemplo:
[Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }
El flujo de ejecución es el siguiente:
- Verifique si la instancia es
ICacheAware
, lo que puede proporcionar un indicador para omitir el uso de caché en esta determinada instancia de objeto. - Aspect genera una clave para la llamada al método.
- Aspect abre la conexión Redis.
- Si el valor existe con la clave generada, se devuelve el valor y se omite la ejecución del método original.
- Si el valor no existe, se llama al método original y el valor de retorno se guarda en caché con una clave generada.
Para la generación de claves se aplican algunas restricciones aquí:
-
IDbConnection
como parámetro siempre se ignora, sea nulo o no. Esto se hace a propósito para acomodar el uso del aspecto anterior. - Los valores especiales como valores de cadena pueden provocar una lectura incorrecta de la memoria caché, como valores
<IGNORED>
y<NULL>
. Esto se puede evitar con la codificación de valores. - Los tipos de referencia no se consideran, solo su tipo (
.ToString()
se usa en la evaluación del valor). Para la mayoría de los casos, esto está bien y no agrega complejidad adicional.
Para utilizar la memoria caché correctamente, es posible que sea necesario invalidar la memoria caché antes de que caduque, como en la actualización de la entidad o en la eliminación de la entidad.
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; } }
El método auxiliar InvalidateCache
acepta la expresión, por lo que se pueden usar comodines (similar al marco Moq):
this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));
Este aspecto se usa sin parámetros especiales, por lo que los desarrolladores solo deben tener en cuenta las limitaciones del código.
Poniendolo todo junto
La mejor manera es probarlo y depurarlo usando las pruebas de integración provistas en el proyecto Paden.Aspects.DAL.Tests
.
El siguiente método de prueba de integración utiliza servidores reales (base de datos relacional y caché). La fachada de conexión se utiliza solo para realizar un seguimiento de las llamadas a métodos.
[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); }
La base de datos se crea y elimina automáticamente usando la clase 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(); } } }
Se puede realizar una verificación manual durante la depuración solo porque después de ejecutar las pruebas, la base de datos se elimina y la memoria caché se invalida manualmente.
Por ejemplo, durante la ejecución de la prueba Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache
, podemos encontrar los siguientes valores en la base de datos de 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\"}"
La prueba de integración GetAllAsync_Should_Not_Call_Database_On_Second_Call
también se asegura de que las llamadas almacenadas en caché tengan un mejor rendimiento que las llamadas de la fuente de datos original. También producen un seguimiento que nos dice cuánto tiempo se tardó en ejecutar cada llamada:
Database run time (ms): 73 Cache run time (ms): 9
Mejoras antes de usar en producción
El código proporcionado aquí está hecho con fines educativos. Antes de usarlo en el sistema del mundo real, se pueden realizar algunas mejoras:
- Aspecto de la conexión Db:
- El grupo de conexiones se puede implementar si es necesario.
- Se pueden implementar varias cadenas de conexión. El uso común para esto es el clúster de base de datos relacional donde distinguimos los tipos de conexión de solo lectura y lectura-escritura.
- Aspecto de caché:
- El grupo de conexiones se puede implementar si es necesario.
- Los valores de tipo de referencia también se pueden considerar como parte de la clave generada, según el caso de uso. En la mayoría de los casos, probablemente solo proporcionen un inconveniente de rendimiento.
Estas funciones no se han implementado aquí porque están relacionadas con los requisitos específicos del sistema en el que se utilizan y, si no se implementan correctamente, no contribuirán al rendimiento del sistema.
Conclusión
Se puede argumentar que la "responsabilidad única", "abierto-cerrado" y la "inversión de dependencia" del principio de los principios SOLID pueden implementarse mejor con AOP que con OOP. El hecho es que el objetivo de los desarrolladores de .NET debe ser una buena organización del código, que se puede lograr con muchas herramientas, marcos y patrones aplicables a situaciones específicas.
Solo para reiterar: todo el código de este artículo, incluidos los aspectos y las pruebas de integración, se puede encontrar en el repositorio de GitHub notmarkopadjen/dot-net-aspects-postsharp
. Para tejer IL, usamos PostSharp del mercado de Visual Studio. El código incluye un laboratorio hecho con docker compose usando MariaDB 10.4 y Redis 5.0.