没有人喜欢样板代码。我们通常通过使用常见的面向对象编程模式来减少它,但通常使用模式的代码开销与我们首先使用样板代码几乎相同——如果不是更大的话。以某种方式标记应该实现某些行为的代码的一部分,并在其他地方解决实现,这将是非常好的。
例如,如果我们有一个StudentRepository
,我们可以使用Dapper从关系数据库中获取所有学生:
public class StudentRepository
{
public Task<IEnumerable<Student>> GetAllAsync(IDbConnection connection)
{
return connection.GetAllAsync<Student>();
}
}
.NET如何缓存和连接?这是关系数据库存储库的一个非常简单的实现。如果学生列表变化不大并且经常被调用,我们可以缓存这些项目以优化系统的响应时间。由于我们的代码中通常有很多存储库(无论它们是否相关),因此最好将缓存的这种横切关注放在一边并很容易地使用它,例如:
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缓存和连接处理教程中,我们将考虑使用面向方面的模式而不是常用的 OOP。尽管 AOP 已经存在一段时间了,但开发人员通常更喜欢 OOP 而不是 AOP。虽然你用 AOP 做的一切都可以用 OOP 来完成,就像过程编程与 OOP 一样,AOP 为开发人员提供了他们可以使用的范式的更多选择。AOP 代码的组织方式与 OOP 不同,在某些方面(双关语),有些人可能会争论得更好。最后,选择使用哪种范式是个人喜好。
.NET面向方面编程教程:我们如何做
在 .NET 中,AOP 模式可以使用中间语言编织来实现,更好地称为IL 编织。这是一个在代码编译后启动的过程,它改变编译器产生的IL代码,使代码达到预期的行为。所以,看已经提到的例子,即使我们没有在这个类中编写缓存代码,我们编写的方法也会被改变(或替换)以调用缓存代码。.NET缓存和连接处理示例:为了说明起见,最终结果应如下所示:
// 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
.
.NET缓存和连接处理教程:面向方面的基本编码
我们来试试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如何缓存和连接?我们看到我们有两种不同的同步和异步调用方法。正确实现这些以充分利用 .NET 异步功能非常重要。当使用StackExchange.Redis
库从 Redis 读取时,我们使用StringGet
或StringGetAsync
方法调用,这取决于我们是同步还是异步代码分支。
代码执行流程受调用MethodInterceptionArgs
、args
对象的方法以及为对象的属性设置值的影响。最重要的成员:
Proceed
(ProceedAsync
) 方法 - 调用原始方法执行。ReturnValue
属性 - 包含方法调用的返回值。原始方法执行前为空,执行后包含原始返回值。它可以随时更换。Method
属性 -System.Reflection.MethodBase
(通常System.Reflection.MethodInfo
)包含目标方法反射信息。Instance
属性 - 目标对象(方法父实例)。Arguments
属性 - 包含参数值。它可以随时更换。
.NET面向方面编程教程: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
。
执行流程如下:
- Aspect 标识
IDbConnection
没有提供值的可选参数索引。如果没有找到,我们跳过。 - MySqlConnection 是基于提供的
ConnectionString
. IDbConnection
设置参数值。- 调用原始方法。
所以,如果我们想使用这个方面,我们可以在不提供连接的情况下调用存储库方法:
await studentRepository.InsertAsync(new Student
{
Name = "Not Marko Padjen"
}, connection: null);
.NET缓存和连接处理教程:缓存方面
在这里,我们要识别唯一的方法调用并缓存它们。如果使用相同参数调用了来自同一类的相同方法,则方法调用被认为是唯一的。
.NET如何缓存和连接?在下面的实现中,在每个方法上,都会为调用创建拦截键。这随后用于检查缓存服务器上是否存在返回值。如果是,则在不调用原始方法的情况下返回它。如果不是,则调用原始方法,并将返回值保存到缓存服务器以供进一步使用。
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 连接。
- 如果 value 与生成的键一起存在,则返回 value 并跳过原始方法执行。
- 如果值不存在,则调用原始方法并将返回值与生成的键一起保存在缓存中。
对于密钥生成,这里有一些限制:
IDbConnection
作为参数始终被忽略,无论是否为空。这是故意完成的,以适应先前方面的使用。- 作为字符串值的特殊值可能会导致从缓存中错误读取,例如
<IGNORED>
和<NULL>
值。这可以通过值编码来避免。 - 不考虑引用类型,只考虑它们的类型(
.ToString()
用于值评估)。对于大多数情况,这很好,并且不会增加额外的复杂性。
.NET缓存和连接处理示例:为了正确使用缓存,可能需要在缓存过期之前使缓存失效,例如实体更新或实体删除。
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
helper 方法接受表达式,因此可以使用通配符(类似于Moq框架):
this.InvalidateCache(r => r.GetAsync(student.Id, Any<IDbConnection>()));
这方面的使用没有特殊参数,因此开发人员应该只知道代码限制。
.NET面向方面编程教程:把它放在一起
最好的方法是试用和调试是使用项目中提供的集成测试Paden.Aspects.DAL.Tests
。
.NET如何缓存和连接?以下集成测试方法使用真实服务器(关系数据库和缓存)。连接外观仅用于跟踪方法调用。
[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();
}
}
}
只能在调试时手动检查,因为测试执行后,数据库被删除,缓存被手动失效。
.NET缓存和连接处理示例:例如,在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
.NET面向方面编程教程:在生产中使用前的改进
此处提供的代码用于教育目的。在实际系统中使用它之前,可能会做一些改进:
- 数据库连接方面:
- 如果需要,可以实现连接池。
- 可以实现多个连接字符串。对此的常见用法是关系数据库集群,其中我们区分只读和读写连接类型。
- 缓存方面:
- 如果需要,可以实现连接池。
- 根据用例,引用类型值也可以被视为生成键的一部分。在大多数情况下,它们可能只会提供性能缺陷。
这些功能在此处没有实现,因为它们与它们所使用的系统的特定要求相关,如果没有正确实现,则不会对系统性能做出贡献。
.NET缓存和连接处理教程结论
有人可能会争辩说,来自SOLID 原则的“单一职责”、“开闭”和“依赖倒置”可能用 AOP 比用 OOP 更好地实现。事实上,.NET 开发人员的目标应该是良好的代码组织,这可以通过适用于特定情况的许多工具、框架和模式来实现。
重申一下:本文中的所有代码,包括方面和集成测试,都可以在notmarkopadjen/dot-net-aspects-postsharp
GitHub 存储库上找到。对于 IL 编织,我们使用了 Visual Studio 市场中的PostSharp。该代码包括使用 docker compose 使用 MariaDB 10.4 和 Redis 5.0 制作的实验室。