.NET의 캐싱 및 연결 처리: Aspect-Oriented Programming Tutorial
게시 됨: 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>(); } }
이 기사에서는 일반적으로 사용되는 OOP 대신 측면 지향 패턴 사용을 고려할 것입니다. AOP가 존재한지 꽤 되었지만 개발자들은 일반적으로 AOP보다 OOP를 선호합니다. 절차적 프로그래밍 대 OOP와 같이 AOP로 수행하는 모든 작업을 OOP로도 수행할 수 있지만 AOP는 개발자에게 사용할 수 있는 패러다임에서 더 많은 선택권을 제공합니다. AOP 코드는 다르게 구성되어 있으며 일부는 OOP보다 특정 측면(말장난 의도)에서 더 잘 주장할 수 있습니다. 결국 어떤 패러다임을 사용할 것인지는 개인의 취향입니다.
우리가 그것을 하는 방법
.NET에서 AOP 패턴은 IL weaving 으로 더 잘 알려진 중간 언어 weaving을 사용하여 구현할 수 있습니다. 이것은 코드 컴파일 후에 시작되는 프로세스로, 컴파일러에서 생성한 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?()); } }
필요한 도구
측면 및 통합 테스트를 포함하여 이 기사의 모든 코드는 notmarkopadjen/dot-net-aspects-postsharp
GitHub 리포지토리에서 찾을 수 있습니다. IL 위빙의 경우 Visual Studio 마켓플레이스의 PostSharp를 사용합니다. 상업적인 도구이며 상업적인 목적을 위해서는 라이선스가 필요합니다. 실험을 위해 무료인 PostSharp Essentials 라이선스를 선택할 수 있습니다.
통합 테스트를 실행하려면 MySQL과 Redis 서버가 필요합니다. 위의 코드에서 MariaDB 10.4 및 Redis 5.0을 사용하여 Docker Compose로 랩을 만들었습니다. 이를 사용하려면 Docker를 설치하고 Compose 구성을 부팅해야 합니다.
docker-compose up -d
물론 다른 서버를 사용하고 appsettings.json
에서 연결 문자열을 변경할 수 있습니다.
기본 Aspect 지향 코딩
AOP의 가로채기 패턴을 시도해보자. 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 비동기 기능을 최대한 활용하려면 이러한 기능을 적절하게 구현하는 것이 중요합니다. StackExchange.Redis
라이브러리를 사용하여 Redis에서 읽을 때 동기화 또는 비동기 코드 분기에 있는지 여부에 따라 StringGet
또는 StringGetAsync
메서드 호출을 사용합니다.
코드 실행 흐름은 MethodInterceptionArgs
, args
개체의 메서드를 호출하고 개체의 속성에 값을 설정하면 영향을 받습니다. 가장 중요한 구성원:
-
Proceed
(ProceedAsync
) 메서드 - 원래 메서드 실행을 호출합니다. -
ReturnValue
속성 - 메서드 호출의 반환 값을 포함합니다. 원래 메서드 실행 전에는 비어 있고 원래 반환 값이 포함된 후에는 비어 있습니다. 언제든지 교체할 수 있습니다. -
Method
속성 -System.Reflection.MethodBase
(일반적으로System.Reflection.MethodInfo
)에는 대상 메서드 반사 정보가 포함됩니다. -
Instance
속성 - 대상 개체(메서드 상위 인스턴스). -
Arguments
속성 - 인수 값을 포함합니다. 언제든지 교체할 수 있습니다.
DbConnection 측면
우리는 IDbConnection
의 인스턴스 없이 저장소 메소드를 호출할 수 있기를 원하고, aspect가 그러한 연결을 생성하고 메소드 호출에 제공하도록 하기를 원합니다. 때때로, 당신은 (예를 들어, 트랜잭션 때문에) 어쨌든 연결을 제공하기를 원할 수 있고, 그런 경우에 aspect는 아무것도 하지 않아야 합니다.
아래 구현에서는 모든 데이터베이스 엔터티 저장소에서와 같이 데이터베이스 연결 관리를 위한 코드만 갖게 됩니다. 이 특별한 경우 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; } } }
여기에서 측면의 실행 순서를 지정하는 것이 중요합니다. 여기서는 aspect 역할을 할당하고 역할 실행을 주문함으로써 수행되었습니다. 어쨌든 사용하지 않을 경우(예: 캐시에서 읽은 값) IDbConnection
이 생성되는 것을 원하지 않습니다. 다음 속성으로 정의됩니다.
[ProvideAspectRole(StandardRoles.TransactionHandling)] [AspectRoleDependency(AspectDependencyAction.Order, AspectDependencyPosition.After, StandardRoles.Caching)]
PostSharp는 또한 클래스 수준 및 어셈블리 수준에서 모든 측면을 구현할 수 있으므로 속성 범위를 정의하는 것이 중요합니다.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
연결 문자열은 appsettings.json
에서 읽히고 있지만 정적 속성 ConnectionString
을 사용하여 재정의할 수 있습니다.
실행 흐름은 다음과 같습니다.
- Aspect는 제공된 값이 없는
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
인지 확인합니다. - Aspect는 메소드 호출을 위한 키를 생성합니다.
- Aspect가 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 원칙 원칙의 "단일 책임", "개방형" 및 "의존성 반전"이 OOP보다 AOP로 더 잘 구현될 수 있다고 주장할 수 있습니다. 사실 .NET 개발자의 목표는 특정 상황에 적용할 수 있는 많은 도구, 프레임워크 및 패턴을 사용하여 달성할 수 있는 우수한 코드 구성이어야 한다는 것입니다.
다시 말하지만 측면 및 통합 테스트를 포함하여 이 기사의 모든 코드는 notmarkopadjen/dot-net-aspects-postsharp
GitHub 리포지토리에서 찾을 수 있습니다. IL 위빙의 경우 Visual Studio 마켓플레이스의 PostSharp를 사용했습니다. 코드에는 MariaDB 10.4 및 Redis 5.0을 사용하여 docker compose로 만든 실습이 포함되어 있습니다.