Caching und Verbindungsbehandlung in .NET: Ein Tutorial zur aspektorientierten Programmierung
Veröffentlicht: 2022-03-11Niemand mag Boilerplate-Code. Normalerweise reduzieren wir ihn, indem wir allgemeine objektorientierte Programmiermuster verwenden, aber oft ist der Code-Overhead bei der Verwendung von Mustern fast derselbe – wenn nicht sogar größer – als wenn wir überhaupt Boilerplate-Code verwendet hätten. Es wäre wirklich schön, irgendwie nur einen Teil des Codes zu markieren, der ein bestimmtes Verhalten implementieren soll, und die Implementierung an anderer Stelle aufzulösen.
Wenn wir beispielsweise ein StudentRepository
haben, können wir Dapper verwenden, um alle Studenten aus einer relationalen Datenbank abzurufen:
public class StudentRepository { public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Dies ist eine sehr einfache Implementierung eines relationalen Datenbank-Repositorys. Wenn sich die Schülerliste nicht stark ändert und häufig aufgerufen wird, können wir diese Elemente zwischenspeichern, um die Reaktionszeit unseres Systems zu optimieren. Da wir normalerweise viele Repositories (unabhängig davon, ob sie relational sind oder nicht) in unserem Code haben, wäre es schön, dieses Querschnittsproblem des Cachings beiseite zu legen und es sehr einfach zu verwenden, wie:
public class StudentRepository { [Cache] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Ein Bonus wäre, sich keine Gedanken über Datenbankverbindungen zu machen. Lassen Sie auch dieses Querschnittsproblem beiseite und benennen Sie einfach eine Methode zur Verwendung eines externen Verbindungsmanagers, wie:
public class StudentRepository { [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } }
In diesem Artikel werden wir die Verwendung von aspektorientierten Mustern anstelle von häufig verwendetem OOP betrachten. Obwohl AOP schon seit einiger Zeit existiert, bevorzugen Entwickler OOP normalerweise gegenüber AOP. Während alles, was Sie mit AOP tun, auch mit OOP ausgeführt werden kann, wie z. B. prozedurale Programmierung im Vergleich zu OOP, bietet AOP Entwicklern eine größere Auswahl an Paradigmen, die sie verwenden können. AOP-Code ist anders organisiert, und einige argumentieren vielleicht besser in Bezug auf bestimmte Aspekte (Wortspiel beabsichtigt) als OOP. Letztendlich ist die Wahl des zu verwendenden Paradigmas eine persönliche Präferenz.
Wie machen wir es
In .NET können AOP-Muster mithilfe von Intermediate Language Weaving, besser bekannt als IL-Weaving , implementiert werden. Dies ist ein Prozess, der nach der Codekompilierung initiiert wird und den von einem Compiler erzeugten IL-Code ändert, damit der Code das erwartete Verhalten erreicht. Wenn wir uns also das bereits erwähnte Beispiel ansehen, wird die von uns geschriebene Methode geändert (oder ersetzt), um Caching-Code aufzurufen, obwohl wir keinen Code für das Caching in dieser Klasse geschrieben haben. Zur Veranschaulichung sollte das Endergebnis in etwa so aussehen:
// 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?()); } }
Werkzeuge benötigt
Der gesamte Code aus diesem Artikel, einschließlich Aspekten und Integrationstests, befindet sich im GitHub-Repository notmarkopadjen/dot-net-aspects-postsharp
. Für das IL-Weben verwenden wir PostSharp aus dem Visual Studio Marketplace. Es ist ein kommerzielles Tool und für kommerzielle Zwecke ist eine Lizenz erforderlich. Zum Experimentieren können Sie die kostenlose PostSharp Essentials-Lizenz auswählen.
Wenn Sie die Integrationstests ausführen möchten, benötigen Sie MySQL- und Redis-Server. Im obigen Code habe ich ein Lab mit Docker Compose unter Verwendung von MariaDB 10.4 und Redis 5.0 erstellt. Um es zu verwenden, müssen Sie Docker installieren und die Compose-Konfiguration starten:
docker-compose up -d
Sie können natürlich auch andere Server verwenden und die Verbindungszeichenfolgen in appsettings.json
.
Grundlegende aspektorientierte Kodierung
Lassen Sie uns das Abfangmuster von AOP ausprobieren. Dazu müssen wir in PostSharp ein neues Attribut implementieren, das MethodInterceptionAspect
-Attribut erben und die erforderlichen Methoden überschreiben.
[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); // ... } }
Wir sehen, dass wir zwei verschiedene Methoden für synchrone und asynchrone Aufrufe haben. Es ist wichtig, diese ordnungsgemäß zu implementieren, um die asynchronen Funktionen von .NET voll auszuschöpfen. Beim Lesen aus Redis mithilfe der StackExchange.Redis
-Bibliothek verwenden wir StringGet
oder StringGetAsync
Methodenaufrufe, je nachdem, ob wir uns im synchronen oder asynchronen Codezweig befinden.
Der Ablauf der Codeausführung wird durch das Aufrufen von Methoden von MethodInterceptionArgs
, args
-Objekt und das Festlegen von Werten für die Eigenschaften des Objekts beeinflusst. Wichtigste Mitglieder:
-
Proceed
-Methode (ProceedAsync
) – Ruft die Ausführung der ursprünglichen Methode auf. -
ReturnValue
Eigenschaft – Enthält den Rückgabewert des Methodenaufrufs. Vor der Ausführung der ursprünglichen Methode ist es leer und enthält danach den ursprünglichen Rückgabewert. Es kann jederzeit ausgetauscht werden. -
Method
–System.Reflection.MethodBase
(normalerweiseSystem.Reflection.MethodInfo
) enthält Reflexionsinformationen zur Zielmethode. -
Instance
– Zielobjekt (Elterninstanz der Methode). -
Arguments
Eigenschaft – Enthält Argumentwerte. Es kann jederzeit ausgetauscht werden.
Der DbConnection-Aspekt
Wir möchten in der Lage sein, Repository-Methoden ohne eine Instanz von IDbConnection
, und den Aspekt diese Verbindungen erstellen und für den Methodenaufruf bereitstellen lassen. Manchmal möchten Sie die Verbindung trotzdem bereitstellen (z. B. aufgrund von Transaktionen), und bei diesen Gelegenheiten sollte der Aspekt nichts tun.
In der folgenden Implementierung haben wir Code nur für die Datenbankverbindungsverwaltung, wie wir es in jedem Datenbankentitätsrepository hätten. In diesem speziellen Fall wird eine Instanz von MySqlConnection
für die Methodenausführung geparst und verworfen, nachdem die Methodenausführung abgeschlossen ist.
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; } } }
Wichtig ist hierbei, die Ausführungsreihenfolge der Aspekte anzugeben. Hier wurde dies durch die Zuweisung von Aspektrollen und die Anordnung der Rollenausführung erreicht. Wir wollen nicht, dass IDbConnection
erstellt wird, wenn es sowieso nicht verwendet wird (zB Werte aus dem Cache lesen). Es wird durch die folgenden Attribute definiert:
[ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]
PostSharp kann auch alle Aspekte auf Klassenebene und Assemblyebene implementieren, daher ist es wichtig, den Attributbereich zu definieren:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
Die Verbindungszeichenfolge wird aus appsettings.json
gelesen, kann aber mit der statischen Eigenschaft ConnectionString
überschrieben werden.
Der Ausführungsablauf ist wie folgt:
- Der Aspekt identifiziert den Index des optionalen Arguments
IDbConnection
, für den kein Wert bereitgestellt wurde. Wenn nicht gefunden, überspringen wir. - MySqlConnection wird basierend auf dem bereitgestellten
ConnectionString
erstellt. - Der Wert
IDbConnection
Arguments ist festgelegt. - Ursprüngliche Methode wird aufgerufen.
Wenn wir also diesen Aspekt verwenden möchten, können wir einfach die Repository-Methode ohne bereitgestellte Verbindung aufrufen:
await studentRepository.InsertAsync(new Student { Name = "Not Marko Padjen" }, connection: null);
Cache-Aspekt
Hier wollen wir eindeutige Methodenaufrufe identifizieren und zwischenspeichern. Methodenaufrufe gelten als eindeutig, wenn dieselbe Methode aus derselben Klasse mit denselben Parametern aufgerufen wurde.
In der folgenden Implementierung wird für jede Methode der Abfangschlüssel für den Anruf erstellt. Damit wird dann überprüft, ob der Rückgabewert auf dem Cache-Server vorhanden ist. Wenn dies der Fall ist, wird sie zurückgegeben, ohne die ursprüngliche Methode aufzurufen. Ist dies nicht der Fall, wird die ursprüngliche Methode aufgerufen, und der zurückgegebene Wert wird zur weiteren Verwendung auf dem Cache-Server gespeichert.
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(", "); } } }
Auch hier achten wir auf die Reihenfolge der Aspekte. Die Aspektrolle ist Caching
und ist so definiert, dass sie nach TransactionHandling
geht:

[ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]
Der Attributbereich ist derselbe wie für den DbConnection-Aspekt:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
Der Ablauf von zwischengespeicherten Elementen kann für jede Methode festgelegt werden, indem das öffentliche Feld ExpirySeconds
definiert wird (Standard ist 5 Minuten), z. B.:
[Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }
Der Ausführungsablauf ist wie folgt:
- Aspektprüfung, ob die Instanz
ICacheAware
ist, die ein Flag bereitstellen kann, um die Verwendung des Caches für diese bestimmte Objektinstanz zu überspringen. - Aspect generiert einen Schlüssel für den Methodenaufruf.
- Aspect öffnet die Redis-Verbindung.
- Wenn der Wert mit dem generierten Schlüssel vorhanden ist, wird der Wert zurückgegeben und die ursprüngliche Methodenausführung wird übersprungen.
- Wenn der Wert nicht vorhanden ist, wird die ursprüngliche Methode aufgerufen und der Rückgabewert mit einem generierten Schlüssel im Cache gespeichert.
Für die Schlüsselgenerierung gelten hier einige Einschränkungen:
-
IDbConnection
als Parameter wird immer ignoriert, ob er null ist oder nicht. Dies geschieht absichtlich, um die Verwendung des vorherigen Aspekts zu ermöglichen. - Spezielle Werte wie Zeichenfolgenwerte können zu einem falschen Lesen aus dem Cache führen, wie
<IGNORED>
und<NULL>
-Werte. Dies kann durch Wertekodierung vermieden werden. - Referenztypen werden nicht berücksichtigt, nur ihr Typ (
.ToString()
wird bei der Wertauswertung verwendet). In den meisten Fällen ist dies in Ordnung und fügt keine zusätzliche Komplexität hinzu.
Um den Cache ordnungsgemäß zu verwenden, kann es erforderlich sein, den Cache zu entwerten, bevor er abgelaufen ist, z. B. bei einer Entitätsaktualisierung oder einer Entitätslöschung.
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
Hilfsmethode akzeptiert Ausdrücke, sodass Platzhalter verwendet werden können (ähnlich wie beim Moq-Framework):
this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));
Dieser Aspekt wird ohne spezielle Parameter verwendet, daher sollten sich Entwickler nur der Codebeschränkungen bewusst sein.
Alles zusammenfügen
Der beste Weg zum Ausprobieren und Debuggen ist die Verwendung der Integrationstests, die im Projekt Paden.Aspects.DAL.Tests
.
Die folgende Integrationstestmethode verwendet reale Server (relationale Datenbank und Cache). Die Verbindungsfassade wird nur verwendet, um Methodenaufrufe zu verfolgen.
[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); }
Die Datenbank wird automatisch erstellt und mithilfe der Klassenbefestigung verworfen:
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(); } } }
Eine manuelle Überprüfung kann nur während des Debuggens durchgeführt werden, da nach der Ausführung der Tests die Datenbank gelöscht und der Cache manuell ungültig gemacht wird.
Während der Ausführung des Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache
-Tests können wir beispielsweise die folgenden Werte in der Redis-Datenbank finden:
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\"}"
Der Integrationstest GetAllAsync_Should_Not_Call_Database_On_Second_Call
stellt außerdem sicher, dass zwischengespeicherte Aufrufe leistungsfähiger sind als die ursprünglichen Datenquellenaufrufe. Sie erzeugen auch eine Ablaufverfolgung, die uns sagt, wie viel Zeit es gedauert hat, jeden Aufruf auszuführen:
Database run time (ms): 73 Cache run time (ms): 9
Verbesserungen vor dem Einsatz in der Produktion
Der hier bereitgestellte Code dient Bildungszwecken. Vor der Verwendung im realen System können einige Verbesserungen vorgenommen werden:
- DbConnection-Aspekt:
- Verbindungspool kann bei Bedarf implementiert werden.
- Es können mehrere Verbindungszeichenfolgen implementiert werden. Die übliche Verwendung hierfür ist ein relationaler Datenbank-Cluster, bei dem wir schreibgeschützte und schreibgeschützte Verbindungstypen unterscheiden.
- Cache-Aspekt:
- Verbindungspool kann bei Bedarf implementiert werden.
- Referenztypwerte können je nach Anwendungsfall auch als Teil des generierten Schlüssels betrachtet werden. In den meisten Fällen würden sie wahrscheinlich nur einen Leistungsnachteil bieten.
Diese Funktionen wurden hier nicht implementiert, da sie sich auf die spezifischen Anforderungen des Systems beziehen, in dem sie verwendet werden, und wenn sie nicht ordnungsgemäß implementiert würden, würden sie nicht zur Leistung des Systems beitragen.
Fazit
Man könnte argumentieren, dass „Einzelverantwortung“, „offen-geschlossen“ und „Umkehrung der Abhängigkeit“ aus dem Prinzip der SOLID-Prinzipien besser mit AOP implementiert werden können als mit OOP. Tatsache ist, dass das Ziel für .NET-Entwickler eine gute Codeorganisation sein sollte, die mit vielen Tools, Frameworks und Mustern erreicht werden kann, die auf bestimmte Situationen anwendbar sind.
Nur zur Wiederholung: Der gesamte Code aus diesem Artikel, einschließlich Aspekten und Integrationstests, befindet sich im GitHub-Repository notmarkopadjen/dot-net-aspects-postsharp
. Für das IL-Weben haben wir PostSharp vom Visual Studio Marketplace verwendet. Der Code enthält ein Lab, das mit Docker Compose unter Verwendung von MariaDB 10.4 und Redis 5.0 erstellt wurde.