Buforowanie i obsługa połączeń w .NET: samouczek programowania zorientowanego na aspekty

Opublikowany: 2022-03-11

Nikt nie lubi standardowego kodu. Zwykle zmniejszamy go, używając typowych wzorców programowania zorientowanego obiektowo, ale często narzut związany z używaniem wzorców jest prawie taki sam – jeśli nie większy – niż gdybyśmy używali kodu standardowego. Byłoby naprawdę miło jakoś po prostu zaznaczyć część kodu, która powinna implementować określone zachowanie i rozwiązać implementację gdzie indziej.

Na przykład, jeśli mamy StudentRepository , możemy użyć Dappera, aby pobrać wszystkich uczniów z relacyjnej bazy danych:

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

Jest to bardzo prosta implementacja repozytorium relacyjnej bazy danych. Jeśli lista uczniów nie zmienia się zbytnio i jest często wywoływana, możemy buforować te elementy, aby zoptymalizować czas odpowiedzi naszego systemu. Ponieważ zwykle mamy wiele repozytoriów (niezależnie od tego, czy są relacyjne, czy nie) w naszym kodzie, dobrze byłoby odłożyć na bok tę przekrojową troskę o buforowanie i wykorzystać ją bardzo łatwo, na przykład:

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

Bonusem byłoby nie martwić się o połączenia z bazą danych. Odłóż na bok tę przekrojową kwestię i po prostu oznacz metodę, aby użyć zewnętrznego menedżera połączeń, na przykład:

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

W tym artykule rozważymy użycie wzorców zorientowanych aspektowo zamiast powszechnie używanego OOP. Chociaż AOP istnieje już od jakiegoś czasu, programiści zazwyczaj wolą OOP od AOP. Chociaż wszystko, co robisz z AOP, może być również zrobione z OOP, tak jak programowanie proceduralne vs. OOP, AOP daje programistom większy wybór paradygmatów, których mogą używać. Kod AOP jest zorganizowany inaczej, a niektórzy mogą argumentować lepiej w pewnych aspektach (kalambur przeznaczony) niż OOP. Ostatecznie wybór, którego paradygmatu użyć, to osobiste preferencje.

Jak to robimy

W .NET wzorce AOP można zaimplementować za pomocą tkania w języku pośrednim, lepiej znanego jako tkanie IL . Jest to proces, który jest inicjowany po kompilacji kodu i zmienia kod IL wytwarzany przez kompilator, aby kod osiągnął oczekiwane zachowanie. Tak więc, patrząc na wspomniany już przykład, mimo że nie napisaliśmy kodu do buforowania w tej klasie, napisana przez nas metoda zostanie zmieniona (lub zastąpiona) w celu wywołania kodu buforowania. Dla ilustracji wynik końcowy powinien wyglądać mniej więcej tak:

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

Wymagane narzędzia

Cały kod z tego artykułu, w tym aspekty i testy integracji, można znaleźć w notmarkopadjen/dot-net-aspects-postsharp GitHub. Do tkania IL użyjemy PostSharp z rynku Visual Studio. Jest to narzędzie komercyjne, a do celów komercyjnych wymagana jest licencja. W celu eksperymentowania możesz wybrać licencję PostSharp Essentials, która jest bezpłatna.

Jeśli chcesz przeprowadzić testy integracyjne, będziesz potrzebować serwera MySQL i Redis. W powyższym kodzie stworzyłem laboratorium z Docker Compose przy użyciu MariaDB 10.4 i Redis 5.0. Aby z niego skorzystać, musisz zainstalować Docker i uruchomić konfigurację Compose:

 docker-compose up -d

Możesz oczywiście użyć innych serwerów i zmienić parametry połączenia w appsettings.json .

Podstawowe kodowanie zorientowane aspektowo

Wypróbujmy wzór przechwytywania AOP. Aby to zrobić w PostSharp, musimy zaimplementować nowy atrybut, odziedziczyć atrybut MethodInterceptionAspect i nadpisać wymagane metody.

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

Widzimy, że mamy dwie różne metody połączeń synchronizacyjnych i asynchronicznych. Aby w pełni wykorzystać funkcje asynchroniczne platformy .NET, należy je odpowiednio zaimplementować. Podczas czytania z Redis przy użyciu biblioteki StackExchange.Redis używamy wywołań metod StringGet lub StringGetAsync , w zależności od tego, czy jesteśmy w gałęzi kodu synchronizacji, czy asynchronicznej.

Na przepływ wykonywania kodu ma wpływ wywoływanie metod MethodInterceptionArgs , obiektu args i ustawianie wartości właściwości obiektu. Najważniejsi członkowie:

  • Proceed ( ProceedAsync ) metoda — wywołuje wykonanie oryginalnej metody.
  • Właściwość ReturnValue — zawiera wartość zwracaną wywołania metody. Przed wykonaniem oryginalnej metody jest pusta, a po niej zawiera oryginalną wartość zwracaną. W każdej chwili można go wymienić.
  • Właściwość MethodSystem.Reflection.MethodBase (zazwyczaj System.Reflection.MethodInfo ) zawiera informacje o odbiciu metody docelowej.
  • Właściwość Instance — obiekt docelowy (instancja nadrzędna metody).
  • Właściwość Arguments — zawiera wartości argumentów. W każdej chwili można go wymienić.

Aspekt połączenia Db

Chcemy móc wywoływać metody repozytorium bez instancji IDbConnection i pozwolić aspektowi tworzyć te połączenia i dostarczać je do wywołania metody. Czasami i tak możesz chcieć zapewnić połączenie (np. z powodu transakcji) i wtedy aspekt nie powinien nic robić.

W poniższej implementacji będziemy mieli kod tylko do zarządzania połączeniami z bazą danych, tak jak mielibyśmy w każdym repozytorium encji bazy danych. W tym konkretnym przypadku wystąpienie MySqlConnection jest analizowane do wykonania metody i usuwane po zakończeniu wykonywania metody.

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

Ważne jest tutaj określenie kolejności wykonania aspektów. Tutaj zostało to zrobione poprzez przypisanie ról aspektowych i zlecenie wykonania ról. Nie chcemy, aby IDbConnection zostało utworzone, jeśli i tak nie będzie używane (np. wartości odczytane z pamięci podręcznej). Określają go następujące atrybuty:

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

PostSharp może również zaimplementować wszystkie aspekty na poziomie klasy i zestawu, dlatego ważne jest zdefiniowanie zakresu atrybutów:

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

Parametry połączenia są odczytywane z appsettings.json , ale można je zastąpić przy użyciu właściwości statycznej ConnectionString .

Przebieg wykonania wygląda następująco:

  1. Aspect identyfikuje opcjonalny indeks argumentu IDbConnection , który nie ma podanej wartości. Jeśli nie zostanie znaleziony, pomijamy.
  2. MySqlConnection jest tworzony na podstawie dostarczonego ConnectionString .
  3. Wartość argumentu IDbConnection jest ustawiona.
  4. Nazywa się oryginalną metodą.

Jeśli więc chcemy użyć tego aspektu, możemy po prostu wywołać metodę repozytorium bez zapewnionego połączenia:

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

Aspekt pamięci podręcznej

Tutaj chcemy zidentyfikować unikalne wywołania metod i buforować je. Wywołania metod są uważane za unikalne, jeśli ta sama metoda z tej samej klasy została wywołana z tymi samymi parametrami.

W poniższej implementacji dla każdej metody tworzony jest klucz przechwytywania dla połączenia. Jest to następnie używane do sprawdzenia, czy zwracana wartość istnieje na serwerze pamięci podręcznej. Jeśli tak, jest zwracany bez wywoływania oryginalnej metody. Jeśli tak nie jest, wywoływana jest oryginalna metoda, a zwrócona wartość jest zapisywana na serwerze pamięci podręcznej w celu dalszego użycia.

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

Tutaj również szanujemy porządek aspektów. Rola aspektu to Caching i jest zdefiniowana po TransactionHandling :

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

Zakres atrybutów jest taki sam jak w przypadku aspektu DbConnection:

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

Wygaśnięcie buforowanych elementów można ustawić dla każdej metody, definiując publiczne pole ExpirySeconds (domyślnie 5 minut), np.:

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

Przebieg wykonania wygląda następująco:

  1. Sprawdzenie aspektu, czy instancja to ICacheAware , która może zapewnić flagę do pominięcia przy użyciu pamięci podręcznej w tym konkretnym wystąpieniu obiektu.
  2. Aspect generuje klucz dla wywołania metody.
  3. Aspect otwiera połączenie Redis.
  4. Jeśli wartość istnieje z wygenerowanym kluczem, zwracana jest wartość i pomijane jest wykonanie oryginalnej metody.
  5. Jeśli value nie istnieje, wywoływana jest oryginalna metoda, a zwracana wartość jest zapisywana w pamięci podręcznej z wygenerowanym kluczem.

W przypadku generowania kluczy obowiązują pewne ograniczenia:

  1. IDbConnection jako parametr jest zawsze ignorowany, niezależnie od tego, czy jest null. Odbywa się to celowo, aby dostosować wykorzystanie poprzedniego aspektu.
  2. Specjalne wartości jako wartości łańcuchowe mogą powodować błędny odczyt z pamięci podręcznej, np. wartości <IGNORED> i <NULL> . Można tego uniknąć dzięki kodowaniu wartości.
  3. Typy referencyjne nie są brane pod uwagę, tylko ich typ ( .ToString() jest używany do oceny wartości). W większości przypadków jest to w porządku i nie dodaje dodatkowej złożoności.

Aby prawidłowo używać pamięci podręcznej, może być konieczne unieważnienie pamięci podręcznej przed jej wygaśnięciem, na przykład przy aktualizacji encji lub usunięciu encji.

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

Metoda pomocnicza InvalidateCache akceptuje wyrażenia, więc można używać symboli wieloznacznych (podobnie jak framework Moq):

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

Ten aspekt jest używany bez specjalnych parametrów, więc programiści powinni zdawać sobie sprawę tylko z ograniczeń kodu.

Kładąc wszystko razem

Najlepszym sposobem jest wypróbowanie go i debugowanie przy użyciu testów integracji dostarczonych w projekcie Paden.Aspects.DAL.Tests .

Poniższa metoda testowania integracji wykorzystuje rzeczywiste serwery (relacyjną bazę danych i pamięć podręczną). Fasada połączenia służy tylko do śledzenia wywołań metod.

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

Baza danych jest automatycznie tworzona i usuwana za pomocą urządzenia klasy:

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

Sprawdzenie ręczne można przeprowadzić tylko podczas debugowania, ponieważ po wykonaniu testów baza danych jest usuwana, a pamięć podręczna jest ręcznie unieważniana.

Przykładowo podczas wykonywania testu Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache w bazie danych Redis możemy znaleźć następujące wartości:

 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 integracji GetAllAsync_Should_Not_Call_Database_On_Second_Call się również, że wywołania w pamięci podręcznej są bardziej wydajne niż wywołania oryginalnego źródła danych. Tworzą również ślad, który mówi nam, ile czasu zajęło wykonanie każdego połączenia:

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

Ulepszenia przed użyciem w produkcji

Podany tutaj kod jest przeznaczony do celów edukacyjnych. Przed użyciem go w systemie rzeczywistym, można dokonać pewnych ulepszeń:

  • Aspekt połączenia Db:
    • W razie potrzeby można zaimplementować pulę połączeń.
    • Można zaimplementować wiele parametrów połączenia. Typowym zastosowaniem jest klaster relacyjnych baz danych, w którym rozróżniamy typy połączeń tylko do odczytu i do odczytu i zapisu.
  • Aspekt pamięci podręcznej:
    • W razie potrzeby można zaimplementować pulę połączeń.
    • Wartości typu referencyjnego można również uznać za część wygenerowanego klucza, w zależności od przypadku użycia. W większości przypadków prawdopodobnie zapewniłyby one jedynie wadę wydajności.

Te funkcje nie zostały tutaj zaimplementowane, ponieważ są związane z konkretnymi wymaganiami systemu, w którym są używane, a jeśli nie zostaną odpowiednio zaimplementowane, nie wpłyną na wydajność systemu.

Wniosek

Można argumentować, że „pojedyncza odpowiedzialność”, „otwarte-zamknięte” i „odwrócenie zależności” z zasady SOLID mogą być lepiej zaimplementowane z AOP niż z OOP. Faktem jest, że celem programistów .NET powinna być dobra organizacja kodu, którą można osiągnąć za pomocą wielu narzędzi, frameworków i wzorców mających zastosowanie w określonych sytuacjach.

Powtórzę tylko: cały kod z tego artykułu, w tym aspekty i testy integracyjne, można znaleźć w notmarkopadjen/dot-net-aspects-postsharp GitHub. Do tkania IL użyliśmy PostSharp z rynku Visual Studio. Kod zawiera laboratorium wykonane za pomocą docker compose przy użyciu MariaDB 10.4 i Redis 5.0.