Mise en cache et gestion des connexions dans .NET : un didacticiel de programmation orienté aspect

Publié: 2022-03-11

Personne n'aime le code passe-partout. Nous le réduisons généralement en utilisant des modèles de programmation orientés objet courants, mais souvent la surcharge de code liée à l'utilisation de modèles est presque la même, sinon plus, que si nous avions utilisé du code passe-partout en premier lieu. Ce serait vraiment bien de marquer d'une manière ou d'une autre une partie du code qui devrait implémenter un certain comportement et de résoudre l'implémentation ailleurs.

Par exemple, si nous avons un StudentRepository , nous pouvons utiliser Dapper pour obtenir tous les étudiants d'une base de données relationnelle :

 public class StudentRepository { public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }

Il s'agit d'une implémentation très simple d'un référentiel de base de données relationnelle. Si la liste des étudiants ne change pas beaucoup et est souvent appelée, nous pouvons mettre ces éléments en cache pour optimiser le temps de réponse de notre système. Étant donné que nous avons généralement beaucoup de référentiels (qu'ils soient relationnels ou non) dans notre code, il serait bien de mettre de côté cette préoccupation transversale de la mise en cache et de l'utiliser très facilement, comme :

 public class StudentRepository { [Cache] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }

Un bonus serait de ne pas se soucier des connexions à la base de données. Mettez également de côté cette préoccupation transversale et étiquetez simplement une méthode pour utiliser le gestionnaire de connexion externe, comme:

 public class StudentRepository { [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } }

Dans cet article, nous considérerons l'utilisation de modèles orientés aspect au lieu de la POO couramment utilisée. Bien que l'AOP existe depuis un certain temps déjà, les développeurs préfèrent généralement la POO à l'AOP. Alors que tout ce que vous faites avec AOP peut également être fait avec OOP, comme la programmation procédurale par rapport à OOP, AOP donne aux développeurs plus de choix dans les paradigmes qu'ils peuvent utiliser. Le code AOP est organisé différemment, et certains peuvent argumenter mieux, sur certains aspects (jeu de mots) que la POO. En fin de compte, le choix du paradigme à utiliser est une préférence personnelle.

Comment nous le faisons

Dans .NET, les modèles AOP peuvent être implémentés à l'aide d'un tissage de langage intermédiaire, mieux connu sous le nom de tissage IL . Il s'agit d'un processus qui est lancé après la compilation du code, et il modifie le code IL produit par un compilateur, pour que le code atteigne le comportement attendu. Ainsi, en regardant l'exemple déjà mentionné, même si nous n'avons pas écrit de code pour la mise en cache dans cette classe, la méthode que nous avons écrite sera modifiée (ou remplacée) afin d'appeler le code de mise en cache. Par souci d'illustration, le résultat final devrait ressembler à ceci :

 // 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?()); } }

Outils nécessaires

Tout le code de cet article, y compris les aspects et les tests d'intégration, se trouve sur le référentiel GitHub notmarkopadjen/dot-net-aspects-postsharp . Pour le tissage IL, nous utiliserons PostSharp du marché Visual Studio. Il s'agit d'un outil commercial et une licence est requise à des fins commerciales. Par souci d'expérimentation, vous pouvez sélectionner la licence PostSharp Essentials, qui est gratuite.

Si vous souhaitez exécuter les tests d'intégration, vous aurez besoin du serveur MySQL et Redis. Dans le code ci-dessus, j'ai créé un laboratoire avec Docker Compose en utilisant MariaDB 10.4 et Redis 5.0. Pour l'utiliser, vous devrez installer Docker et démarrer la configuration de Compose :

 docker-compose up -d

Vous pouvez, bien sûr, utiliser d'autres serveurs et modifier les chaînes de connexion dans appsettings.json .

Codage de base orienté aspect

Essayons le modèle d'interception d'AOP. Pour ce faire dans PostSharp, nous devons implémenter un nouvel attribut, hériter de l'attribut MethodInterceptionAspect et remplacer les méthodes requises.

 [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); // ... } }

Nous voyons que nous avons deux méthodes différentes pour les appels synchronisés et asynchrones. Il est important de les implémenter correctement pour tirer pleinement parti des fonctionnalités asynchrones .NET. Lors de la lecture à partir de Redis à l'aide de la bibliothèque StackExchange.Redis , nous utilisons des appels de méthode StringGet ou StringGetAsync , selon que nous sommes dans une branche de code synchronisée ou asynchrone.

Le flux d'exécution du code est affecté par l'appel des méthodes de MethodInterceptionArgs , l'objet args et la définition des valeurs des propriétés de l'objet. Membres les plus importants :

  • Méthode Proceed ( ProceedAsync ) - Invoque l'exécution de la méthode d'origine.
  • Propriété ReturnValue - Contient la valeur de retour de l'appel de méthode. Avant l'exécution de la méthode d'origine, il est vide et après il contient la valeur de retour d'origine. Il peut être remplacé à tout moment.
  • Propriété Method - System.Reflection.MethodBase (généralement System.Reflection.MethodInfo ) contient les informations de réflexion de la méthode cible.
  • Propriété d' Instance - Objet cible (instance parent de la méthode).
  • Propriété Arguments - Contient des valeurs d'argument. Il peut être remplacé à tout moment.

L'aspect DbConnection

Nous voulons pouvoir appeler des méthodes de référentiel sans instance de IDbConnection , et laisser l'aspect créer ces connexions et les fournir à l'appel de méthode. Parfois, vous voudrez peut-être fournir la connexion de toute façon (par exemple, à cause des transactions) et, à ces occasions, l'aspect ne devrait rien faire.

Dans l'implémentation ci-dessous, nous aurons du code uniquement pour la gestion des connexions à la base de données, comme nous l'aurions dans n'importe quel référentiel d'entités de base de données. Dans ce cas particulier, une instance de MySqlConnection est analysée lors de l'exécution de la méthode et supprimée une fois l'exécution de la méthode terminée.

 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; } } }

Il est important ici de préciser l'ordre d'exécution des aspects. Ici, cela a été fait en attribuant des rôles d'aspect et en ordonnant l'exécution des rôles. Nous ne voulons pas que IDbConnection soit créé s'il ne sera pas utilisé de toute façon (par exemple, les valeurs lues à partir du cache). Il est défini par les attributs suivants :

 [ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]

PostSharp peut également implémenter tous les aspects au niveau de la classe et au niveau de l'assemblage, il est donc important de définir la portée de l'attribut :

 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

La chaîne de connexion est lue à partir de appsettings.json , mais peut être remplacée à l'aide de la propriété statique ConnectionString .

Le flux d'exécution est le suivant :

  1. Aspect identifie l'index d'argument facultatif IDbConnection qui n'a pas de valeur fournie. S'il n'est pas trouvé, nous sautons.
  2. MySqlConnection est créé sur la base de ConnectionString fourni.
  3. La valeur de l'argument IDbConnection est définie.
  4. La méthode originale est appelée.

Donc, si nous voulons utiliser cet aspect, nous pouvons simplement appeler la méthode du référentiel sans connexion fournie :

 await studentRepository.InsertAsync(new Student { Name = "Not Marko Padjen" }, connection: null);

Aspect du cache

Ici, nous voulons identifier les appels de méthode uniques et les mettre en cache. Les appels de méthode sont considérés comme uniques si la même méthode de la même classe a été appelée avec les mêmes paramètres.

Dans l'implémentation ci-dessous, sur chaque méthode, la clé d'interception est créée pour l'appel. Ceci est ensuite utilisé pour vérifier si la valeur de retour existe sur le serveur de cache. Si c'est le cas, elle est renvoyée sans appeler la méthode d'origine. Si ce n'est pas le cas, la méthode d'origine est appelée et la valeur renvoyée est enregistrée sur le serveur de cache pour une utilisation ultérieure.

 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(", "); } } }

Ici, on respecte aussi l'ordre des aspects. Le rôle d'aspect est Caching , et il est défini pour aller après TransactionHandling :

 [ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]

La portée de l'attribut est la même que pour l'aspect DbConnection :

 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

L'expiration des éléments mis en cache peut être définie sur chaque méthode en définissant le champ public ExpirySeconds (la valeur par défaut est de 5 minutes), par exemple :

 [Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }

Le flux d'exécution est le suivant :

  1. Aspect vérifie si l'instance est ICacheAware qui peut fournir un indicateur pour ignorer l'utilisation du cache sur cette certaine instance d'objet.
  2. Aspect génère une clé pour l'appel de méthode.
  3. Aspect ouvre la connexion Redis.
  4. Si la valeur existe avec la clé générée, la valeur est renvoyée et l'exécution de la méthode d'origine est ignorée.
  5. Si la valeur n'existe pas, la méthode d'origine est appelée et la valeur de retour est enregistrée dans le cache avec une clé générée.

Pour la génération de clé, certaines restrictions s'appliquent :

  1. IDbConnection en tant que paramètre est toujours ignoré, qu'il soit nul ou non. Ceci est fait exprès afin de tenir compte de l'utilisation de l'aspect précédent.
  2. Les valeurs spéciales en tant que valeurs de chaîne peuvent entraîner une mauvaise lecture du cache, comme les valeurs <IGNORED> et <NULL> . Cela peut être évité avec l'encodage de valeur.
  3. Les types de référence ne sont pas pris en compte, seul leur type ( .ToString() est utilisé lors de l'évaluation de la valeur). Dans la plupart des cas, cela convient et n'ajoute pas de complexité supplémentaire.

Afin d'utiliser correctement le cache, il peut être nécessaire d'invalider le cache avant son expiration, comme lors de la mise à jour d'une entité ou de la suppression d'une entité.

 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; } }

La méthode d'assistance InvalidateCache accepte l'expression, de sorte que des caractères génériques peuvent être utilisés (similaire au framework Moq):

 this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));

Cet aspect est utilisé sans paramètres spéciaux, les développeurs ne doivent donc être conscients que des limitations du code.

Mettre tous ensemble

La meilleure façon est de l'essayer et de déboguer en utilisant les tests d'intégration fournis dans le projet Paden.Aspects.DAL.Tests .

La méthode de test d'intégration suivante utilise des serveurs réels (base de données relationnelle et cache). La façade de connexion est utilisée uniquement pour suivre les appels de méthode.

 [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 données est automatiquement créée et supprimée à l'aide de la classe 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(); } } }

Une vérification manuelle peut être effectuée pendant le débogage uniquement car après l'exécution des tests, la base de données est supprimée et le cache est invalidé manuellement.

Par exemple, lors de l'exécution du test Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache , nous pouvons trouver les valeurs suivantes dans la base de données 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\"}"

Le test d'intégration GetAllAsync_Should_Not_Call_Database_On_Second_Call s'assure également que les appels mis en cache sont plus performants que les appels de source de données d'origine. Ils produisent également une trace qui nous indique combien de temps il a fallu pour exécuter chaque appel :

 Database run time (ms): 73 Cache run time (ms): 9

Améliorations avant utilisation en production

Le code fourni ici est fait à des fins éducatives. Avant de l'utiliser dans le système du monde réel, certaines améliorations peuvent être apportées :

  • Aspect DbConnection :
    • Le pool de connexion peut être implémenté si nécessaire.
    • Plusieurs chaînes de connexion peuvent être implémentées. L'utilisation courante pour cela est le cluster de base de données relationnelle où nous distinguons les types de connexion en lecture seule et en lecture-écriture.
  • Aspect cache :
    • Le pool de connexion peut être implémenté si nécessaire.
    • Les valeurs de type de référence peuvent également être considérées comme faisant partie de la clé générée, selon le cas d'utilisation. Dans la plupart des cas, ils ne fourniraient probablement qu'un inconvénient de performance.

Ces fonctionnalités n'ont pas été implémentées ici car elles sont liées aux exigences spécifiques du système dans lequel elles sont utilisées et, si elles ne sont pas implémentées correctement, elles ne contribueront pas aux performances du système.

Conclusion

On peut affirmer que la «responsabilité unique», «ouvert-fermé» et «l'inversion de dépendance» du principe des principes SOLID peuvent être mieux mises en œuvre avec AOP qu'avec OOP. Le fait est que l'objectif des développeurs .NET doit être une bonne organisation du code, ce qui peut être réalisé avec de nombreux outils, frameworks et modèles applicables à des situations spécifiques.

Juste pour réitérer : tout le code de cet article, y compris les aspects et les tests d'intégration, peut être trouvé sur le référentiel GitHub notmarkopadjen/dot-net-aspects-postsharp . Pour le tissage IL, nous avons utilisé PostSharp du marché Visual Studio. Le code comprend un laboratoire réalisé avec docker compose à l'aide de MariaDB 10.4 et Redis 5.0.