Кэширование и обработка соединений в .NET: учебник по аспектно-ориентированному программированию
Опубликовано: 2022-03-11Никто не любит шаблонный код. Обычно мы уменьшаем его, используя распространенные шаблоны объектно-ориентированного программирования, но часто накладные расходы кода при использовании шаблонов почти такие же, если не больше, чем если бы мы изначально использовали шаблонный код. Было бы действительно неплохо как-то просто пометить часть кода, которая должна реализовывать определенное поведение, и разрешить реализацию где-то еще.
Например, если у нас есть StudentRepository
, мы можем использовать Dapper для получения всех студентов из реляционной базы данных:
public class StudentRepository { public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Это очень простая реализация репозитория реляционной базы данных. Если список учеников не сильно меняется и вызывается часто, мы можем кэшировать эти элементы, чтобы оптимизировать время отклика нашей системы. Поскольку у нас обычно много репозиториев (независимо от того, реляционные они или нет) в нашем коде, было бы неплохо отложить в сторону эту сквозную проблему кэширования и очень легко использовать ее, например:
public class StudentRepository { [Cache] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection) { return connection.GetAllAsync<Student>(); } }
Бонусом было бы не беспокоиться о соединениях с базой данных. Отложите в сторону эту сквозную проблему и просто назовите метод использования внешнего диспетчера соединений, например:
public class StudentRepository { [Cache] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); } }
В этой статье мы рассмотрим использование аспектно-ориентированных паттернов вместо общепринятого ООП. Хотя АОП существует уже некоторое время, разработчики обычно предпочитают ООП АОП. Хотя все, что вы делаете с АОП, можно делать и с ООП, например, процедурное программирование по сравнению с ООП, АОП дает разработчикам больший выбор парадигм, которые они могут использовать. Код АОП организован по-другому, и некоторые могут лучше рассуждать об определенных аспектах (каламбур), чем ООП. В конце концов, выбор того, какую парадигму использовать, является личным предпочтением.
Как мы это делаем
В .NET шаблоны АОП могут быть реализованы с помощью промежуточного языкового плетения, более известного как IL-плетение . Это процесс, который инициируется после компиляции кода и изменяет код IL, созданный компилятором, чтобы код достиг ожидаемого поведения. Итак, глядя на уже упомянутый пример, хотя мы и не писали код для кэширования в этом классе, написанный нами метод будет изменен (или заменен) для вызова кода кэширования. Для наглядности конечный результат должен выглядеть примерно так:
// 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?()); } }
Необходимые инструменты
Весь код из этой статьи, включая аспекты и интеграционные тесты, можно найти в репозитории GitHub notmarkopadjen/dot-net-aspects-postsharp
. Для плетения IL мы будем использовать PostSharp из торговой площадки Visual Studio. Это коммерческий инструмент, и для коммерческих целей требуется лицензия. Ради экспериментов вы можете выбрать бесплатную лицензию PostSharp Essentials.
Если вы хотите запустить интеграционные тесты, вам понадобится сервер MySQL и Redis. В приведенном выше коде я создал лабораторную работу с Docker Compose, используя MariaDB 10.4 и Redis 5.0. Чтобы использовать его, вам нужно будет установить Docker и загрузить конфигурацию Compose:
docker-compose up -d
Вы, конечно, можете использовать другие серверы и изменить строки подключения в appsettings.json
.
Базовое аспектно-ориентированное кодирование
Давайте попробуем шаблон перехвата АОП. Для этого в PostSharp нам нужно реализовать новый атрибут, унаследовать атрибут MethodInterceptionAspect
и переопределить необходимые методы.
[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); // ... } }
Мы видим, что у нас есть два разных метода для синхронных и асинхронных вызовов. Важно правильно их реализовать, чтобы в полной мере воспользоваться асинхронными функциями .NET. При чтении из Redis с использованием библиотеки StackExchange.Redis
мы используем вызовы методов StringGet
или StringGetAsync
, в зависимости от того, находимся ли мы в синхронной или асинхронной ветке кода.
На поток выполнения кода влияет вызов методов MethodInterceptionArgs
, объекта args
и установка значений для свойств объекта. Наиболее важные участники:
- Метод
Proceed
(ProceedAsync
) — вызывает выполнение исходного метода. -
ReturnValue
— содержит возвращаемое значение вызова метода. Перед выполнением исходного метода он пуст, а после содержит исходное возвращаемое значение. Его можно заменить в любой момент. - Свойство
Method
—System.Reflection.MethodBase
(обычноSystem.Reflection.MethodInfo
) содержит информацию об отражении целевого метода. - Свойство
Instance
— целевой объект (экземпляр родительского метода). - Свойство
Arguments
— содержит значения аргументов. Его можно заменить в любой момент.
Аспект DbConnection
Мы хотим иметь возможность вызывать методы репозитория без экземпляра IDbConnection
и позволить аспекту создавать эти соединения и предоставлять их для вызова метода. Иногда вы все равно можете захотеть обеспечить соединение (например, из-за транзакций), и в этих случаях аспект не должен ничего делать.
В приведенной ниже реализации у нас будет код только для управления подключением к базе данных, как и в любом репозитории сущностей базы данных. В этом конкретном случае экземпляр MySqlConnection
анализируется для выполнения метода и удаляется после завершения выполнения метода.
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; } } }
Здесь важно указать порядок выполнения аспектов. Здесь это было сделано путем назначения ролей аспектов и упорядочения выполнения ролей. Мы не хотим, чтобы IDbConnection
создавался, если он все равно не будет использоваться (например, значения, считанные из кеша). Он определяется следующими атрибутами:
[ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]
PostSharp также может реализовать все аспекты на уровне класса и сборки, поэтому важно определить область действия атрибута:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
Строка подключения считывается из appsettings.json
, но ее можно переопределить с помощью статического свойства ConnectionString
.
Последовательность выполнения следующая:
- Аспект идентифицирует необязательный индекс аргумента
IDbConnection
, для которого не указано значение. Если не нашли, пропускаем. - MySqlConnection создается на основе предоставленной
ConnectionString
. - Установлено значение аргумента
IDbConnection
. - Оригинальный метод называется.
Итак, если мы хотим использовать этот аспект, мы можем просто вызвать метод репозитория без предоставления соединения:
await studentRepository.InsertAsync(new Student { Name = "Not Marko Padjen" }, connection: null);
Кэш Аспект
Здесь мы хотим идентифицировать уникальные вызовы методов и кэшировать их. Вызовы методов считаются уникальными, если один и тот же метод из того же класса был вызван с теми же параметрами.
В приведенной ниже реализации для каждого метода создается ключ перехвата вызова. Затем это используется для проверки того, существует ли возвращаемое значение на сервере кеша. Если это так, он возвращается без вызова исходного метода. Если это не так, вызывается исходный метод, а возвращаемое значение сохраняется на кэш-сервере для дальнейшего использования.
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(", "); } } }
Здесь мы также соблюдаем порядок аспектов. Роль аспекта — Caching
, и она определена после TransactionHandling
:

[ProvideAspectRole(StandardRoles.Caching)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.Before, StandardRoles.TransactionHandling)]
Область действия атрибута такая же, как и для аспекта DbConnection:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
Срок действия кэшированных элементов можно установить для каждого метода, определив общедоступное поле ExpirySeconds
(по умолчанию — 5 минут), например:
[Cache(ExpirySeconds = 2 * 60 /* 2 minutes */)] [DbConnection] public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection = null) { return connection.GetAllAsync<Student>(); }
Последовательность выполнения следующая:
- Проверка аспекта, если экземпляр является
ICacheAware
, который может предоставить флаг для пропуска использования кеша в этом конкретном экземпляре объекта. - Аспект генерирует ключ для вызова метода.
- Аспект открывает соединение с Redis.
- Если значение существует со сгенерированным ключом, возвращается значение, а выполнение исходного метода пропускается.
- Если значение не существует, вызывается исходный метод, а возвращаемое значение сохраняется в кеше со сгенерированным ключом.
Для генерации ключей действуют некоторые ограничения:
-
IDbConnection
как параметр всегда игнорируется, независимо от того, имеет он значение null или нет. Это сделано специально, чтобы учесть использование предыдущего аспекта. - Специальные значения в виде строковых значений могут привести к неправильному чтению из кеша, например
<IGNORED>
и<NULL>
. Этого можно избежать с помощью кодирования значений. - Ссылочные типы не учитываются, только их тип (
.ToString()
используется при оценке значения). В большинстве случаев это нормально и не добавляет дополнительных сложностей.
Чтобы правильно использовать кеш, может потребоваться аннулировать кеш до истечения срока его действия, например, при обновлении или удалении объекта.
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
принимает выражение, поэтому можно использовать подстановочные знаки (аналогично инфраструктуре Moq):
this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));
Этот аспект используется без специальных параметров, поэтому разработчики должны знать только об ограничениях кода.
Собираем все вместе
Лучше всего попробовать и отладить его с помощью интеграционных тестов, представленных в проекте Paden.Aspects.DAL.Tests
.
В следующем методе интеграционного тестирования используются реальные серверы (реляционная база данных и кэш). Фасад подключения используется только для отслеживания вызовов методов.
[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); }
База данных автоматически создается и удаляется с помощью фикстуры класса:
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(); } } }
Ручная проверка может выполняться во время отладки только потому, что после выполнения тестов база данных удаляется, а кеш вручную становится недействительным.
Например, во время выполнения теста Get_Should_Call_Database_If_Entity_Not_Dirty_Otherwise_Read_From_Cache
мы можем найти в базе данных 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\"}"
Интеграционный тест GetAllAsync_Should_Not_Call_Database_On_Second_Call
также обеспечивает более высокую производительность кэшированных вызовов по сравнению с исходными вызовами источника данных. Они также производят трассировку, которая сообщает нам, сколько времени потребовалось для выполнения каждого вызова:
Database run time (ms): 73 Cache run time (ms): 9
Улучшения перед использованием в производстве
Приведенный здесь код предназначен для образовательных целей. Прежде чем использовать его в реальной системе, можно внести некоторые улучшения:
- Аспект DbConnection:
- При необходимости может быть реализован пул соединений.
- Можно реализовать несколько строк подключения. Обычно для этого используется кластер реляционной базы данных, где мы различаем типы подключения только для чтения и для чтения и записи.
- Аспект кэша:
- При необходимости может быть реализован пул соединений.
- Значения ссылочного типа также можно рассматривать как часть сгенерированного ключа, в зависимости от варианта использования. В большинстве случаев они, вероятно, только ухудшат производительность.
Эти функции не были реализованы здесь, потому что они связаны с конкретными требованиями системы, в которой они используются, и, если они не будут реализованы должным образом, это не повлияет на производительность системы.
Заключение
Можно возразить, что «единая ответственность», «открыто-закрыто» и «инверсия зависимостей» из принципа SOLID могут быть лучше реализованы с помощью АОП, чем с помощью ООП. Дело в том, что целью разработчиков .NET должна быть хорошая организация кода, чего можно достичь с помощью множества инструментов, фреймворков и шаблонов, применимых к конкретным ситуациям.
Просто повторюсь: весь код из этой статьи, включая аспекты и интеграционные тесты, можно найти в репозитории GitHub notmarkopadjen/dot-net-aspects-postsharp
. Для создания IL мы использовали PostSharp из магазина Visual Studio. Код включает в себя лабораторную работу, выполненную с помощью docker compose с использованием MariaDB 10.4 и Redis 5.0.