Caching e gestione delle connessioni in .NET: un tutorial di programmazione orientato agli aspetti

Pubblicato: 2022-03-11

A nessuno piace il codice boilerplate. Di solito lo riduciamo utilizzando comuni modelli di programmazione orientati agli oggetti, ma spesso il sovraccarico del codice dell'utilizzo dei modelli è quasi lo stesso, se non maggiore, che se avessimo utilizzato il codice standard in primo luogo. Sarebbe davvero bello in qualche modo contrassegnare semplicemente una parte del codice che dovrebbe implementare un determinato comportamento e risolvere l'implementazione da qualche altra parte.

Ad esempio, se disponiamo di StudentRepository , possiamo utilizzare Dapper per ottenere tutti gli studenti da un database relazionale:

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

Questa è un'implementazione molto semplice di un repository di database relazionali. Se l'elenco degli studenti non cambia molto e viene chiamato spesso, possiamo memorizzare nella cache quegli elementi per ottimizzare i tempi di risposta del nostro sistema. Dal momento che di solito abbiamo molti repository (indipendentemente dal fatto che siano relazionali o meno) nel nostro codice, sarebbe bello mettere da parte questa preoccupazione trasversale della memorizzazione nella cache e utilizzarla molto facilmente, come:

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

Un vantaggio sarebbe non preoccuparsi delle connessioni al database. Tieni da parte anche questa preoccupazione trasversale e etichetta semplicemente un metodo per utilizzare un gestore connessione esterno, come:

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

In questo articolo, considereremo l'utilizzo di modelli orientati all'aspetto anziché l'OOP comunemente usato. Sebbene l'AOP esista ormai da un po' di tempo, gli sviluppatori di solito preferiscono l'OOP all'AOP. Mentre tutto ciò che fai con AOP può essere fatto anche con OOP, come la programmazione procedurale rispetto a OOP, AOP offre agli sviluppatori più scelta nei paradigmi che possono utilizzare. Il codice AOP è organizzato in modo diverso, e alcuni potrebbero discutere meglio, su alcuni aspetti (gioco di parole) rispetto a OOP. Alla fine, la scelta del paradigma da utilizzare è una preferenza personale.

Come lo facciamo

In .NET, i modelli AOP possono essere implementati utilizzando la tessitura di linguaggi intermedi, meglio nota come tessitura IL . Questo è un processo che viene avviato dopo la compilazione del codice e modifica il codice IL prodotto da un compilatore per fare in modo che il codice ottenga il comportamento previsto. Quindi, guardando l'esempio già citato, anche se non abbiamo scritto il codice per la memorizzazione nella cache in questa classe, il metodo che abbiamo scritto verrà modificato (o sostituito) per chiamare il codice della memorizzazione nella cache. A scopo illustrativo, il risultato finale dovrebbe assomigliare a questo:

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

Strumenti richiesti

Tutto il codice di questo articolo, inclusi gli aspetti e i test di integrazione, può essere trovato nel repository GitHub notmarkopadjen/dot-net-aspects-postsharp . Per la tessitura IL, useremo PostSharp dal mercato di Visual Studio. È uno strumento commerciale e per scopi commerciali è necessaria una licenza. Per motivi di sperimentazione, puoi selezionare la licenza PostSharp Essentials, che è gratuita.

Se desideri eseguire i test di integrazione, avrai bisogno del server MySQL e Redis. Nel codice sopra, ho creato un lab con Docker Compose utilizzando MariaDB 10.4 e Redis 5.0. Per usarlo, dovrai installare Docker e avviare la configurazione di Compose:

 docker-compose up -d

Ovviamente puoi utilizzare altri server e modificare le stringhe di connessione in appsettings.json .

Codifica di base orientata agli aspetti

Proviamo il modello di intercettazione di AOP. Per fare ciò in PostSharp, dobbiamo implementare un nuovo attributo, ereditare l'attributo MethodInterceptionAspect e sovrascrivere i metodi richiesti.

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

Vediamo che abbiamo due metodi diversi per la sincronizzazione e le chiamate asincrone. È importante implementarli correttamente per sfruttare appieno le funzionalità di sincronizzazione asincrona di .NET. Durante la lettura da Redis usando la libreria StackExchange.Redis , usiamo le chiamate al metodo StringGet o StringGetAsync , a seconda se siamo in sync o in un ramo di codice asincrono.

Il flusso di esecuzione del codice è influenzato dal richiamo dei metodi di MethodInterceptionArgs , dall'oggetto args e dall'impostazione di valori nelle proprietà dell'oggetto. Membri più importanti:

  • Metodo Proceed ( ProceedAsync ) - Richiama l'esecuzione del metodo originale.
  • Proprietà ReturnValue : contiene il valore restituito dalla chiamata al metodo. Prima dell'esecuzione del metodo originale, è vuoto e dopo contiene il valore restituito originale. Può essere sostituito in qualsiasi momento.
  • Proprietà del Method - System.Reflection.MethodBase (in genere System.Reflection.MethodInfo ) contiene informazioni sulla riflessione del metodo di destinazione.
  • Proprietà Instance - Oggetto di destinazione (istanza padre del metodo).
  • Proprietà Arguments : contiene i valori degli argomenti. Può essere sostituito in qualsiasi momento.

L'aspetto DbConnection

Vogliamo essere in grado di chiamare i metodi del repository senza un'istanza di IDbConnection e lasciare che l'aspetto crei quelle connessioni e le fornisca alla chiamata al metodo. A volte, potresti voler fornire comunque la connessione (ad esempio, a causa di transazioni) e, in quelle occasioni, l'aspetto non dovrebbe fare nulla.

Nell'implementazione seguente, avremo codice solo per la gestione della connessione al database, come avremmo in qualsiasi repository di entità di database. In questo caso particolare, un'istanza di MySqlConnection viene analizzata nell'esecuzione del metodo ed eliminata al termine dell'esecuzione del metodo.

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

È importante qui specificare l'ordine di esecuzione degli aspetti. Qui, è stato fatto assegnando ruoli aspetto e ordinando l'esecuzione dei ruoli. Non vogliamo che IDbConnection venga creato se non verrà comunque utilizzato (ad esempio, valori letti dalla cache). È definito dai seguenti attributi:

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

PostSharp può anche implementare tutti gli aspetti a livello di classe e di assembly, quindi è importante definire l'ambito degli attributi:

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

La stringa di connessione viene letta da appsettings.json , ma può essere sovrascritta usando la proprietà statica ConnectionString .

Il flusso di esecuzione è il seguente:

  1. Aspect identifica l'indice dell'argomento facoltativo IDbConnection che non ha alcun valore fornito. Se non viene trovato, saltiamo.
  2. MySqlConnection viene creato in base a ConnectionString fornito.
  3. Il valore dell'argomento IDbConnection è impostato.
  4. Viene chiamato il metodo originale.

Quindi, se vogliamo usare questo aspetto, possiamo semplicemente chiamare il metodo repository senza connessione fornita:

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

Aspetto cache

Qui vogliamo identificare le chiamate di metodo univoche e memorizzarle nella cache. Le chiamate ai metodi sono considerate univoche se lo stesso metodo della stessa classe è stato chiamato con gli stessi parametri.

Nell'implementazione seguente, su ciascun metodo, viene creata la chiave di intercettazione per la chiamata. Questo viene quindi utilizzato per verificare se il valore restituito esiste sul server cache. In tal caso, viene restituito senza chiamare il metodo originale. In caso contrario, viene chiamato il metodo originale e il valore restituito viene salvato nel server cache per un ulteriore utilizzo.

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

Qui rispettiamo anche l'ordine degli aspetti. Il ruolo dell'aspetto è Caching ed è definito per andare dopo TransactionHandling :

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

L'ambito dell'attributo è lo stesso dell'aspetto DbConnection:

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

La scadenza degli elementi memorizzati nella cache può essere impostata su ciascun metodo definendo il campo pubblico ExpirySeconds (il valore predefinito è 5 minuti) es:

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

Il flusso di esecuzione è il seguente:

  1. Aspect controlla se l'istanza è ICacheAware che può fornire un flag per saltare l'utilizzo della cache su questa determinata istanza dell'oggetto.
  2. Aspect genera una chiave per la chiamata al metodo.
  3. Aspect apre la connessione Redis.
  4. Se esiste un valore con la chiave generata, viene restituito il valore e l'esecuzione del metodo originale viene ignorata.
  5. Se il valore non esiste, viene chiamato il metodo originale e il valore restituito viene salvato nella cache con una chiave generata.

Per la generazione di chiavi si applicano alcune restrizioni qui:

  1. IDbConnection come parametro viene sempre ignorato, essendo null o meno. Questo viene fatto apposta per adattarsi all'uso dell'aspetto precedente.
  2. Valori speciali come valori di stringa possono causare una lettura errata dalla cache, come i <IGNORED> e <NULL> . Questo può essere evitato con la codifica del valore.
  3. I tipi di riferimento non vengono considerati, solo il loro tipo ( .ToString() viene utilizzato nella valutazione del valore). Per la maggior parte dei casi va bene e non aggiunge ulteriore complessità.

Per utilizzare correttamente la cache, potrebbe essere necessario invalidare la cache prima che scada, ad esempio durante l'aggiornamento dell'entità o l'eliminazione dell'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; } }

Il metodo di supporto InvalidateCache accetta l'espressione, quindi è possibile utilizzare i caratteri jolly (simili al framework Moq):

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

Questo aspetto viene utilizzato senza parametri speciali, quindi gli sviluppatori dovrebbero essere consapevoli solo delle limitazioni del codice.

Mettere tutto insieme

Il modo migliore è provarlo ed eseguire il debug utilizzando i test di integrazione forniti nel progetto Paden.Aspects.DAL.Tests .

Il seguente metodo di test di integrazione utilizza server reali (database relazionale e cache). La facciata di connessione viene utilizzata solo per tenere traccia delle chiamate di metodo.

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

Il database viene creato e smaltito automaticamente utilizzando il dispositivo di classe:

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

È possibile eseguire un controllo manuale durante il debug solo perché dopo l'esecuzione dei test, il database viene eliminato e la cache viene invalidata manualmente.

Ad esempio, durante l'esecuzione del test Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache , possiamo trovare i seguenti valori nel database 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\"}"

Test di integrazione GetAllAsync_Should_Not_Call_Database_On_Second_Call si assicura inoltre che le chiamate memorizzate nella cache siano più performanti rispetto alle chiamate all'origine dati originali. Producono anche una traccia che ci dice quanto tempo ci è voluto per eseguire ogni chiamata:

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

Miglioramenti prima dell'uso in produzione

Il codice qui fornito è realizzato a scopo didattico. Prima di utilizzarlo nel sistema del mondo reale, è possibile apportare alcuni miglioramenti:

  • Aspetto DbConnection:
    • Il pool di connessioni può essere implementato se necessario.
    • È possibile implementare più stringhe di connessione. L'uso comune per questo è il cluster di database relazionali in cui distinguiamo i tipi di connessione di sola lettura e lettura-scrittura.
  • Aspetto cache:
    • Il pool di connessioni può essere implementato se necessario.
    • I valori del tipo di riferimento possono anche essere considerati come parte della chiave generata, a seconda del caso d'uso. Nella maggior parte dei casi, probabilmente fornirebbero solo uno svantaggio di prestazioni.

Queste funzionalità non sono state implementate qui perché sono correlate ai requisiti specifici del sistema in cui vengono utilizzate e, se non implementate correttamente, non contribuirebbero alle prestazioni del sistema.

Conclusione

Si potrebbe obiettare che "responsabilità singola", "aperto-chiuso" e "inversione di dipendenza" dal principio dei principi SOLID possono essere implementati meglio con AOP che con OOP. Il fatto è che l'obiettivo per gli sviluppatori .NET dovrebbe essere una buona organizzazione del codice, che può essere raggiunta con molti strumenti, framework e modelli applicabili a situazioni specifiche.

Giusto per ribadire: tutto il codice di questo articolo, inclusi aspetti e test di integrazione, può essere trovato nel repository GitHub notmarkopadjen/dot-net-aspects-postsharp . Per la tessitura IL, abbiamo usato PostSharp dal mercato di Visual Studio. Il codice include un lab realizzato con docker compose utilizzando MariaDB 10.4 e Redis 5.0.