From c1d555c183f412463b0373007f92a580a6173216 Mon Sep 17 00:00:00 2001 From: witskeeper Date: Sun, 22 Dec 2024 19:37:44 +0800 Subject: [PATCH 1/5] wip: commandlock --- src/AspNetCore/CommandLocks/CommandLock.cs | 35 +++++++++++++++ src/AspNetCore/CommandLocks/ICommandLock.cs | 20 +++++++++ .../NetCorePal.Extensions.AspNetCore.csproj | 14 ++++++ .../MediatorTest.cs | 44 +++++++++++++++++++ ...sions.Domain.Abstractions.UnitTests.csproj | 1 + 5 files changed, 114 insertions(+) create mode 100644 src/AspNetCore/CommandLocks/CommandLock.cs create mode 100644 src/AspNetCore/CommandLocks/ICommandLock.cs create mode 100644 test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/MediatorTest.cs diff --git a/src/AspNetCore/CommandLocks/CommandLock.cs b/src/AspNetCore/CommandLocks/CommandLock.cs new file mode 100644 index 00000000..9184bc4b --- /dev/null +++ b/src/AspNetCore/CommandLocks/CommandLock.cs @@ -0,0 +1,35 @@ +using MediatR; +using NetCorePal.Extensions.DistributedLocks; +using NetCorePal.Extensions.Primitives; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public sealed class CommandLockBehavior( + ICommandLock commandLock, + IDistributedLock + distributedLock) + : IPipelineBehavior + where TRequest : IBaseCommand +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var options = await commandLock.GetCommandLockOptionsAsync(request, cancellationToken); + await using var lockHandler = + await distributedLock.TryAcquireAsync(options.LockKey, timeout: options.LockTimeout, + cancellationToken: cancellationToken); + if (lockHandler == null) + { + if (options.ErrorMessageFactory != null) + { + return await options.ErrorMessageFactory(cancellationToken); + } + else + { + throw new KnownException(message: options.ErrorMessage, errorCode: 400); + } + } + + return await next(); + } +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/ICommandLock.cs b/src/AspNetCore/CommandLocks/ICommandLock.cs new file mode 100644 index 00000000..b765c731 --- /dev/null +++ b/src/AspNetCore/CommandLocks/ICommandLock.cs @@ -0,0 +1,20 @@ +using NetCorePal.Extensions.Primitives; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public interface ICommandLock where TCommand : IBaseCommand +{ + Task> GetCommandLockOptionsAsync(TCommand command, + CancellationToken cancellationToken = default); +} + +public class CommandLockOptions +{ + public string LockKey { get; set; } = ""; + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan WaitTimeout { get; set; } = TimeSpan.FromSeconds(10); + public string ErrorMessage { get; set; } = "获取锁超时"; + + public Func>? ErrorMessageFactory { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/NetCorePal.Extensions.AspNetCore.csproj b/src/AspNetCore/NetCorePal.Extensions.AspNetCore.csproj index 402a6a06..6f2e6daf 100644 --- a/src/AspNetCore/NetCorePal.Extensions.AspNetCore.csproj +++ b/src/AspNetCore/NetCorePal.Extensions.AspNetCore.csproj @@ -15,9 +15,23 @@ + + + + ResXFileCodeGenerator + R.Designer.cs + + + + + True + True + R.resx + + diff --git a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/MediatorTest.cs b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/MediatorTest.cs new file mode 100644 index 00000000..455bbe1e --- /dev/null +++ b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/MediatorTest.cs @@ -0,0 +1,44 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace NetCorePal.Extensions.Domain.Abstractions.UnitTests; + +public class MediatorTest +{ + [Fact] + public async Task Test() + { + var services = new ServiceCollection(); + services.AddMediatR(c => + { + c.RegisterServicesFromAssembly(typeof(MediatorTest).Assembly) + .AddOpenBehavior(typeof(TestCommandBehavior<,>)); + }); + //services.AddTransient(); + var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + await mediator.Send(new TestCommand()); + } + + +} + +public class TestCommand : IRequest { } + +public class TestCommandHandler : IRequestHandler +{ + public Task Handle(TestCommand request, CancellationToken cancellationToken) + { + return Task.FromResult("TestCommand"); + } +} + +public class TestCommandBehavior : IPipelineBehavior + where TRequest : IRequest +{ + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + return await next(); + } +} \ No newline at end of file diff --git a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/NetCorePal.Extensions.Domain.Abstractions.UnitTests.csproj b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/NetCorePal.Extensions.Domain.Abstractions.UnitTests.csproj index 82ea09f4..85fd020e 100644 --- a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/NetCorePal.Extensions.Domain.Abstractions.UnitTests.csproj +++ b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/NetCorePal.Extensions.Domain.Abstractions.UnitTests.csproj @@ -11,6 +11,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive From af5bdf6a765870be0b4dab8d960c49818a46cd66 Mon Sep 17 00:00:00 2001 From: witskeeper Date: Tue, 31 Dec 2024 18:55:48 +0800 Subject: [PATCH 2/5] wip: commandlock and test --- src/AspNetCore/CommandLocks/CommandLock.cs | 35 -------- .../CommandLocks/CommandLockBehavior.cs | 55 ++++++++++++ .../CommandLockFailedException.cs | 6 ++ .../CommandLocks/CommandLockSettings.cs | 90 +++++++++++++++++++ src/AspNetCore/CommandLocks/ICommandLock.cs | 16 +--- src/AspNetCore/ServiceCollectionExtension.cs | 33 +++++++ .../CommandLockBehaviorTest.cs | 64 +++++++++++++ .../CommandLockSettingsTests.cs | 73 +++++++++++++++ ...Pal.Extensions.AspNetCore.UnitTests.csproj | 1 + .../ServiceCollectionExtensionTests.cs | 11 +++ 10 files changed, 336 insertions(+), 48 deletions(-) delete mode 100644 src/AspNetCore/CommandLocks/CommandLock.cs create mode 100644 src/AspNetCore/CommandLocks/CommandLockBehavior.cs create mode 100644 src/AspNetCore/CommandLocks/CommandLockFailedException.cs create mode 100644 src/AspNetCore/CommandLocks/CommandLockSettings.cs create mode 100644 test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs create mode 100644 test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs diff --git a/src/AspNetCore/CommandLocks/CommandLock.cs b/src/AspNetCore/CommandLocks/CommandLock.cs deleted file mode 100644 index 9184bc4b..00000000 --- a/src/AspNetCore/CommandLocks/CommandLock.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MediatR; -using NetCorePal.Extensions.DistributedLocks; -using NetCorePal.Extensions.Primitives; - -namespace NetCorePal.Extensions.AspNetCore.CommandLocks; - -public sealed class CommandLockBehavior( - ICommandLock commandLock, - IDistributedLock - distributedLock) - : IPipelineBehavior - where TRequest : IBaseCommand -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, - CancellationToken cancellationToken) - { - var options = await commandLock.GetCommandLockOptionsAsync(request, cancellationToken); - await using var lockHandler = - await distributedLock.TryAcquireAsync(options.LockKey, timeout: options.LockTimeout, - cancellationToken: cancellationToken); - if (lockHandler == null) - { - if (options.ErrorMessageFactory != null) - { - return await options.ErrorMessageFactory(cancellationToken); - } - else - { - throw new KnownException(message: options.ErrorMessage, errorCode: 400); - } - } - - return await next(); - } -} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockBehavior.cs b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs new file mode 100644 index 00000000..03a55cea --- /dev/null +++ b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs @@ -0,0 +1,55 @@ +using MediatR; +using NetCorePal.Extensions.DistributedLocks; +using NetCorePal.Extensions.Primitives; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public sealed class CommandLockBehavior( + ICommandLock commandLock, + IDistributedLock distributedLock) + : IPipelineBehavior + where TRequest : ICommand +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var options = await commandLock.GetCommandLockOptionsAsync(request, cancellationToken); + + if (!string.IsNullOrEmpty(options.LockKey)) + { + await using var lockHandler = + await distributedLock.TryAcquireAsync(options.LockKey, timeout: options.AcquireTimeout, + cancellationToken: cancellationToken); + if (lockHandler == null) + { + throw new CommandLockFailedException("Acquire Lock Faild"); + } + + return await next(); + } + else + { + return await LockAndRelease(options, 0, next, cancellationToken); + } + } + + + private async Task LockAndRelease(CommandLockSettings settings, int lockIndex, + RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (lockIndex >= settings.LockKeys!.Count) + { + return await next(); + } + + await using var lockHandler = + await distributedLock.TryAcquireAsync(settings.LockKeys[lockIndex], timeout: settings.AcquireTimeout, + cancellationToken: cancellationToken); + if (lockHandler == null) + { + throw new CommandLockFailedException("Acquire Lock Faild"); + } + + return await LockAndRelease(settings, lockIndex + 1, next, cancellationToken); + } +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockFailedException.cs b/src/AspNetCore/CommandLocks/CommandLockFailedException.cs new file mode 100644 index 00000000..a4cf9bd5 --- /dev/null +++ b/src/AspNetCore/CommandLocks/CommandLockFailedException.cs @@ -0,0 +1,6 @@ +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public class CommandLockFailedException(string message) : Exception(message: message) +{ + +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockSettings.cs b/src/AspNetCore/CommandLocks/CommandLockSettings.cs new file mode 100644 index 00000000..2ebd25f0 --- /dev/null +++ b/src/AspNetCore/CommandLocks/CommandLockSettings.cs @@ -0,0 +1,90 @@ +using System.Reflection.Metadata; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public sealed class CommandLockSettings +{ + /// + /// + /// + /// + /// + /// + /// + /// + public CommandLockSettings(string lockKey, + int acquireSeconds = 10, + string? errorMessage = null, + int errorCode = 500) + { + if (string.IsNullOrEmpty(lockKey)) + { + throw new ArgumentNullException(nameof(lockKey)); + } + + this.LockKey = lockKey; + Init(acquireSeconds, errorMessage, errorCode); + } + + public CommandLockSettings(IEnumerable lockKeys, + int acquireSeconds = 10, + string? errorMessage = null, + int errorCode = 500) + { + if (lockKeys == null) + { + throw new ArgumentNullException(nameof(lockKeys)); + } + + var sortedSet = new SortedSet(); + + foreach (var key in lockKeys) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("lockKeys contains empty string", nameof(lockKeys)); + } + + if (!sortedSet.Add(key)) + { + throw new ArgumentException("lockKeys contains duplicate key", nameof(lockKeys)); + } + } + + if (sortedSet.Count == 0) + { + throw new ArgumentException("lockKeys is empty", nameof(lockKeys)); + } + + LockKeys = sortedSet.ToList(); + + Init(acquireSeconds, errorMessage, errorCode); + } + + + private void Init(int acquireSeconds, string? errorMessage, int errorCode) + { + this.AcquireTimeout = TimeSpan.FromSeconds(acquireSeconds); + if (errorMessage != null) + { + this.ErrorMessage = errorMessage; + } + + this.ErrorCode = errorCode; + } + + + /// + /// 要加锁的key,如果要加锁多个key,请使用LockKeys + /// + public string? LockKey { get; private set; } + + /// + /// 要加锁的列表,当LockKey为空时使用 + /// + public IReadOnlyList? LockKeys { get; private set; } + + public TimeSpan AcquireTimeout { get; private set; } + public string ErrorMessage { get; private set; } = "获取锁超时"; + public int ErrorCode { get; private set; } = 500; +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/ICommandLock.cs b/src/AspNetCore/CommandLocks/ICommandLock.cs index b765c731..9f9581c0 100644 --- a/src/AspNetCore/CommandLocks/ICommandLock.cs +++ b/src/AspNetCore/CommandLocks/ICommandLock.cs @@ -1,20 +1,10 @@ +using MediatR; using NetCorePal.Extensions.Primitives; namespace NetCorePal.Extensions.AspNetCore.CommandLocks; -public interface ICommandLock where TCommand : IBaseCommand +public interface ICommandLock where TCommand : IBaseCommand { - Task> GetCommandLockOptionsAsync(TCommand command, + Task GetCommandLockOptionsAsync(TCommand command, CancellationToken cancellationToken = default); -} - -public class CommandLockOptions -{ - public string LockKey { get; set; } = ""; - public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30); - - public TimeSpan WaitTimeout { get; set; } = TimeSpan.FromSeconds(10); - public string ErrorMessage { get; set; } = "获取锁超时"; - - public Func>? ErrorMessageFactory { get; set; } } \ No newline at end of file diff --git a/src/AspNetCore/ServiceCollectionExtension.cs b/src/AspNetCore/ServiceCollectionExtension.cs index 72bea183..4ff9d887 100644 --- a/src/AspNetCore/ServiceCollectionExtension.cs +++ b/src/AspNetCore/ServiceCollectionExtension.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NetCorePal.Extensions.AspNetCore; +using NetCorePal.Extensions.AspNetCore.CommandLocks; using NetCorePal.Extensions.AspNetCore.Validation; using NetCorePal.Extensions.Domain.Json; using NetCorePal.Extensions.Dto; @@ -131,4 +132,36 @@ public static IMvcBuilder AddEntityIdSystemTextJson(this IMvcBuilder builder) }); return builder; } + + + /// + /// 添加CommandLocks + /// + /// + /// + /// + public static IServiceCollection AddCommandLocks(this IServiceCollection services, params Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + var types = assembly.GetTypes(); + foreach (var type in types) + { + if (type is { IsClass: true, IsAbstract: false, IsGenericType: false }) + { + var interfaces = type.GetInterfaces(); + foreach (var @interface in interfaces) + { + if (@interface.IsGenericType && + @interface.GetGenericTypeDefinition() == typeof(ICommandLock<>)) + { + services.AddTransient(@interface, type); + } + } + } + } + } + + return services; + } } \ No newline at end of file diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs new file mode 100644 index 00000000..9b2eeb0f --- /dev/null +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs @@ -0,0 +1,64 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using NetCorePal.Extensions.AspNetCore.CommandLocks; +using NetCorePal.Extensions.Primitives; +using MediatR; +using Moq; +using NetCorePal.Extensions.DependencyInjection; +using NetCorePal.Extensions.DistributedLocks; + +namespace NetCorePal.Extensions.AspNetCore.UnitTests; + +public class CommandLockBehaviorTest +{ + public class TestCommand : ICommand + { + public string CommandId { get; set; } = Guid.NewGuid().ToString(); + } + + public class CommandLock : ICommandLock + { + public Task GetCommandLockOptionsAsync(TestCommand command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CommandLockSettings(command.CommandId)); + } + + + } + + public class TestCommandHandler : ICommandHandler + { + public Task Handle(TestCommand request, CancellationToken cancellationToken) + { + return Task.FromResult(Unit.Value); + } + } + + [Fact] + public async Task Test() + { + var mockDistributedLock = new Mock(); + var services = new ServiceCollection(); + services.AddSingleton(mockDistributedLock.Object); + + + services.AddMediatR(c => + { + c.RegisterServicesFromAssembly(typeof(CommandLockBehaviorTest).Assembly); + c.AddOpenBehavior(typeof(CommandLockBehavior<,>)); + }); + + services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); + + + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var s = scope.ServiceProvider.GetRequiredService>(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + await mediator.Send(new TestCommand()); + } +} \ No newline at end of file diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs new file mode 100644 index 00000000..77790023 --- /dev/null +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs @@ -0,0 +1,73 @@ +using NetCorePal.Extensions.AspNetCore.CommandLocks; + +namespace NetCorePal.Extensions.AspNetCore.UnitTests; + +public class CommandLockSettingsTests +{ + [Fact] + public void SingeKey_NotEmpty_Should_Success() + { + var key = "abc"; + var settings = new CommandLockSettings(key); + Assert.NotNull(settings); + Assert.NotNull(settings.LockKey); + Assert.Null(settings.LockKeys); + Assert.Equal(key, settings.LockKey); + } + + [Fact] + public void SingeKey_Empty_Should_Throw() + { + Assert.Throws(() => new CommandLockSettings(string.Empty)); + } + + [Fact] + public void MultipleKeys_NotEmpty_Should_Success() + { + var keys = new[] { "abc", "def" }; + var settings = new CommandLockSettings(keys); + Assert.NotNull(settings); + Assert.Null(settings.LockKey); + Assert.NotNull(settings.LockKeys); + Assert.Equal(keys.Length, settings.LockKeys.Count); + Assert.Equal(keys[0], settings.LockKeys[0]); + Assert.Equal(keys[1], settings.LockKeys[1]); + } + + [Fact] + public void MultipleKeys_Empty_Should_Throw() + { + Assert.Throws(() => new CommandLockSettings([string.Empty])); + } + + [Fact] + public void MultipleKeys_ContainsEmpty_Should_Throw() + { + Assert.Throws(() => new CommandLockSettings([string.Empty, "abc"])); + } + + [Fact] + public void MultipleKeys_ContainsEmptyString_Should_Throw() + { + Assert.Throws(() => new CommandLockSettings([null!, "abc"])); + } + + [Fact] + public void MultipleKeys_Contains_SameKey_Should_Throw() + { + Assert.Throws(() => new CommandLockSettings(["abc", "abc"])); + } + + [Fact] + public void MultipleKeys_Should_Ordered() + { + var keys = new[] { "def", "abc" }; + var settings = new CommandLockSettings(keys); + Assert.NotNull(settings); + Assert.Null(settings.LockKey); + Assert.NotNull(settings.LockKeys); + Assert.Equal(keys.Length, settings.LockKeys.Count); + Assert.Equal("abc", settings.LockKeys[0]); + Assert.Equal("def", settings.LockKeys[1]); + } +} \ No newline at end of file diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/NetCorePal.Extensions.AspNetCore.UnitTests.csproj b/test/NetCorePal.Extensions.AspNetCore.UnitTests/NetCorePal.Extensions.AspNetCore.UnitTests.csproj index eed80621..84c317f2 100644 --- a/test/NetCorePal.Extensions.AspNetCore.UnitTests/NetCorePal.Extensions.AspNetCore.UnitTests.csproj +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/NetCorePal.Extensions.AspNetCore.UnitTests.csproj @@ -25,6 +25,7 @@ + diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/ServiceCollectionExtensionTests.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/ServiceCollectionExtensionTests.cs index c8304ad4..63d84dee 100644 --- a/test/NetCorePal.Extensions.AspNetCore.UnitTests/ServiceCollectionExtensionTests.cs +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/ServiceCollectionExtensionTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NetCorePal.Extensions.AspNetCore.CommandLocks; using NetCorePal.Extensions.DependencyInjection; using NetCorePal.Extensions.Primitives; @@ -31,5 +32,15 @@ public void AddAllQueriesTests() var queryHandler4 = scope2.ServiceProvider.GetRequiredService(); Assert.NotSame(queryHandler2, queryHandler4); } + + [Fact] + public void AddCommandLocksTest() + { + var services = new ServiceCollection(); + services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); + var provider = services.BuildServiceProvider(); + var s = provider.GetRequiredService>(); + Assert.NotNull(s); + } } } From 5a951df42883ea05063d86f5c9db8188c0eb8a25 Mon Sep 17 00:00:00 2001 From: witskeeper Date: Mon, 6 Jan 2025 19:43:24 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=AE=9E=E7=8E=B0CommandLockBehavior?= =?UTF-8?q?=EF=BC=8C=E5=AE=9E=E7=8E=B0Scope=E5=86=85=E9=94=81=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommandLocks/CommandLockBehavior.cs | 69 +++++-- .../CommandLockFailedException.cs | 12 +- .../CommandLocks/CommandLockSettings.cs | 32 +-- .../CommandLocks/CommandLockedKeysHolder.cs | 13 ++ .../CommandLocks/EntityIdExtensions.cs | 22 +++ src/AspNetCore/CommandLocks/ICommandLock.cs | 2 +- .../LockSynchronizationHandlerWarpper.cs | 19 ++ .../CommandLockBehaviorTest.cs | 184 +++++++++++++++--- .../EntityIdExtensionsTests.cs | 38 ++++ .../EntityIdTests.cs | 29 +++ .../EntityIdTypeConverterTests.cs | 37 +--- 11 files changed, 360 insertions(+), 97 deletions(-) create mode 100644 src/AspNetCore/CommandLocks/CommandLockedKeysHolder.cs create mode 100644 src/AspNetCore/CommandLocks/EntityIdExtensions.cs create mode 100644 src/AspNetCore/CommandLocks/LockSynchronizationHandlerWarpper.cs create mode 100644 test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs diff --git a/src/AspNetCore/CommandLocks/CommandLockBehavior.cs b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs index 03a55cea..218c5e15 100644 --- a/src/AspNetCore/CommandLocks/CommandLockBehavior.cs +++ b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs @@ -4,28 +4,39 @@ namespace NetCorePal.Extensions.AspNetCore.CommandLocks; -public sealed class CommandLockBehavior( +public class CommandLockBehavior( ICommandLock commandLock, IDistributedLock distributedLock) : IPipelineBehavior - where TRequest : ICommand + where TRequest : IBaseCommand { + private readonly CommandLockedKeysHolder _lockedKeys = new(); + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { - var options = await commandLock.GetCommandLockOptionsAsync(request, cancellationToken); - + var options = await commandLock.GetLockKeysAsync(request, cancellationToken); if (!string.IsNullOrEmpty(options.LockKey)) { - await using var lockHandler = - await distributedLock.TryAcquireAsync(options.LockKey, timeout: options.AcquireTimeout, - cancellationToken: cancellationToken); - if (lockHandler == null) + if (!_lockedKeys.LockedKeys.Keys.Contains(options.LockKey)) { - throw new CommandLockFailedException("Acquire Lock Faild"); - } + await using var lockHandler = + await TryAcquireAsync(options.LockKey, timeout: options.AcquireTimeout, + cancellationToken: cancellationToken); + if (lockHandler == null) + { + throw new CommandLockFailedException("Acquire Lock Faild", options.LockKey); + } - return await next(); + _lockedKeys.LockedKeys.Keys.Add(options.LockKey); + // 确保在执行next后,释放锁 + return await next(); + } + else + { + return await next(); + } } else { @@ -42,14 +53,38 @@ private async Task LockAndRelease(CommandLockSettings settings, int l return await next(); } - await using var lockHandler = - await distributedLock.TryAcquireAsync(settings.LockKeys[lockIndex], timeout: settings.AcquireTimeout, - cancellationToken: cancellationToken); - if (lockHandler == null) + var key = settings.LockKeys[lockIndex]; + + if (!_lockedKeys.LockedKeys.Keys.Contains(key)) + { + await using var lockHandler = + await TryAcquireAsync(key, timeout: settings.AcquireTimeout, + cancellationToken: cancellationToken); + if (lockHandler == null) + { + throw new CommandLockFailedException("Acquire Lock Faild", key); + } + + _lockedKeys.LockedKeys.Keys.Add(key); + // 确保在执行LockAndRelease后,释放锁 + return await LockAndRelease(settings, lockIndex + 1, next, cancellationToken); + } + else + { + return await LockAndRelease(settings, lockIndex + 1, next, cancellationToken); + } + } + + private async Task TryAcquireAsync(string key, TimeSpan timeout, + CancellationToken cancellationToken) + { + var handler = await distributedLock.TryAcquireAsync(key, timeout: timeout, + cancellationToken: cancellationToken); + if (handler == null) { - throw new CommandLockFailedException("Acquire Lock Faild"); + return null; } - return await LockAndRelease(settings, lockIndex + 1, next, cancellationToken); + return new LockSynchronizationHandlerWarpper(key, _lockedKeys.LockedKeys.Keys, handler); } } \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockFailedException.cs b/src/AspNetCore/CommandLocks/CommandLockFailedException.cs index a4cf9bd5..2bff7c83 100644 --- a/src/AspNetCore/CommandLocks/CommandLockFailedException.cs +++ b/src/AspNetCore/CommandLocks/CommandLockFailedException.cs @@ -1,6 +1,14 @@ namespace NetCorePal.Extensions.AspNetCore.CommandLocks; -public class CommandLockFailedException(string message) : Exception(message: message) +/// +#pragma warning disable S3925 +public sealed class CommandLockFailedException : Exception +#pragma warning restore S3925 { - + public CommandLockFailedException(string message, string failedKey) : base(message) + { + FailedKey = failedKey; + } + + public string FailedKey { get; private set; } } \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockSettings.cs b/src/AspNetCore/CommandLocks/CommandLockSettings.cs index 2ebd25f0..68fed22e 100644 --- a/src/AspNetCore/CommandLocks/CommandLockSettings.cs +++ b/src/AspNetCore/CommandLocks/CommandLockSettings.cs @@ -2,20 +2,16 @@ namespace NetCorePal.Extensions.AspNetCore.CommandLocks; -public sealed class CommandLockSettings +public sealed record CommandLockSettings { /// /// /// /// /// - /// - /// /// public CommandLockSettings(string lockKey, - int acquireSeconds = 10, - string? errorMessage = null, - int errorCode = 500) + int acquireSeconds = 10) { if (string.IsNullOrEmpty(lockKey)) { @@ -23,13 +19,11 @@ public CommandLockSettings(string lockKey, } this.LockKey = lockKey; - Init(acquireSeconds, errorMessage, errorCode); + this.AcquireTimeout = TimeSpan.FromSeconds(acquireSeconds); } public CommandLockSettings(IEnumerable lockKeys, - int acquireSeconds = 10, - string? errorMessage = null, - int errorCode = 500) + int acquireSeconds = 10) { if (lockKeys == null) { @@ -57,20 +51,7 @@ public CommandLockSettings(IEnumerable lockKeys, } LockKeys = sortedSet.ToList(); - - Init(acquireSeconds, errorMessage, errorCode); - } - - - private void Init(int acquireSeconds, string? errorMessage, int errorCode) - { this.AcquireTimeout = TimeSpan.FromSeconds(acquireSeconds); - if (errorMessage != null) - { - this.ErrorMessage = errorMessage; - } - - this.ErrorCode = errorCode; } @@ -84,7 +65,8 @@ private void Init(int acquireSeconds, string? errorMessage, int errorCode) /// public IReadOnlyList? LockKeys { get; private set; } + /// key + /// 获取锁的等待时间(秒) + /// public TimeSpan AcquireTimeout { get; private set; } - public string ErrorMessage { get; private set; } = "获取锁超时"; - public int ErrorCode { get; private set; } = 500; } \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/CommandLockedKeysHolder.cs b/src/AspNetCore/CommandLocks/CommandLockedKeysHolder.cs new file mode 100644 index 00000000..f44c2dce --- /dev/null +++ b/src/AspNetCore/CommandLocks/CommandLockedKeysHolder.cs @@ -0,0 +1,13 @@ +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +class CommandLockedKeysHolder +{ + private static readonly AsyncLocal KeysHolderCurrent = new AsyncLocal(); + + public KeysHolder LockedKeys => KeysHolderCurrent.Value ??= new KeysHolder(); +} + +class KeysHolder +{ + public HashSet Keys { get; set; } = new HashSet(); +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/EntityIdExtensions.cs b/src/AspNetCore/CommandLocks/EntityIdExtensions.cs new file mode 100644 index 00000000..8df876d7 --- /dev/null +++ b/src/AspNetCore/CommandLocks/EntityIdExtensions.cs @@ -0,0 +1,22 @@ +using NetCorePal.Extensions.Domain; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +public static class EntityIdExtensions +{ + public static CommandLockSettings ToCommandLockSettings(this TId id, + int acquireSeconds = 10) + where TId : IEntityId + { + return new CommandLockSettings(typeof(TId).Name + "-" + id.ToString()!, acquireSeconds: acquireSeconds); + } + + public static CommandLockSettings ToCommandLockSettings(this IEnumerable ids, + int acquireSeconds = 10) + where TId : IEntityId + { + var typeName = typeof(TId).Name; + return new CommandLockSettings(ids.Select(id => typeName + "-" + id.ToString()), + acquireSeconds: acquireSeconds); + } +} \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/ICommandLock.cs b/src/AspNetCore/CommandLocks/ICommandLock.cs index 9f9581c0..29014faf 100644 --- a/src/AspNetCore/CommandLocks/ICommandLock.cs +++ b/src/AspNetCore/CommandLocks/ICommandLock.cs @@ -5,6 +5,6 @@ namespace NetCorePal.Extensions.AspNetCore.CommandLocks; public interface ICommandLock where TCommand : IBaseCommand { - Task GetCommandLockOptionsAsync(TCommand command, + Task GetLockKeysAsync(TCommand command, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/AspNetCore/CommandLocks/LockSynchronizationHandlerWarpper.cs b/src/AspNetCore/CommandLocks/LockSynchronizationHandlerWarpper.cs new file mode 100644 index 00000000..4b070c8a --- /dev/null +++ b/src/AspNetCore/CommandLocks/LockSynchronizationHandlerWarpper.cs @@ -0,0 +1,19 @@ +using NetCorePal.Extensions.DistributedLocks; + +namespace NetCorePal.Extensions.AspNetCore.CommandLocks; + +/// +/// 在释放锁后,从持有者集合中移除key +/// +/// +/// +/// +class LockSynchronizationHandlerWarpper(string key, HashSet holder, ILockSynchronizationHandler handler) + : IAsyncDisposable +{ + public async ValueTask DisposeAsync() + { + await handler.DisposeAsync(); + holder.Remove(key); + } +} \ No newline at end of file diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs index 9b2eeb0f..9efbc35b 100644 --- a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockBehaviorTest.cs @@ -11,54 +11,194 @@ namespace NetCorePal.Extensions.AspNetCore.UnitTests; public class CommandLockBehaviorTest { - public class TestCommand : ICommand + [Fact] + public async Task CommandLockFailedException_Should_Throw_When_One_Key_Lock_Failed() { - public string CommandId { get; set; } = Guid.NewGuid().ToString(); - } + var mockDistributedLock = new Mock(); + mockDistributedLock.Setup(p => + p.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(default(ILockSynchronizationHandler)); - public class CommandLock : ICommandLock - { - public Task GetCommandLockOptionsAsync(TestCommand command, - CancellationToken cancellationToken = default) + var services = new ServiceCollection(); + services.AddSingleton(mockDistributedLock.Object); + services.AddMediatR(c => { - return Task.FromResult(new CommandLockSettings(command.CommandId)); - } - - + c.RegisterServicesFromAssembly(typeof(CommandLockBehaviorTest).Assembly); + c.AddOpenBehavior(typeof(CommandLockBehavior<,>)); + }); + services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var s = scope.ServiceProvider.GetRequiredService>(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var cmd = new TestCommand(); + var ex = await Assert.ThrowsAsync( + async () => await mediator.Send(cmd)); + Assert.Equal(cmd.CommandId, ex.FailedKey); } - public class TestCommandHandler : ICommandHandler + [Fact] + public async Task CommandLockFailedException_Should_Not_Throw_When_One_Key_Lock_Success() { - public Task Handle(TestCommand request, CancellationToken cancellationToken) + var mockDistributedLock = new Mock(); + int disposeCount = 0; + var mockLockSynchronizationHandler = new Mock(); + mockLockSynchronizationHandler.Setup(p => p.DisposeAsync()) + .Callback(() => disposeCount++); + mockDistributedLock.Setup(p => + p.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockLockSynchronizationHandler.Object); + + var services = new ServiceCollection(); + services.AddSingleton(mockDistributedLock.Object); + services.AddMediatR(c => { - return Task.FromResult(Unit.Value); - } + c.RegisterServicesFromAssembly(typeof(CommandLockBehaviorTest).Assembly); + c.AddOpenBehavior(typeof(CommandLockBehavior<,>)); + }); + services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var s = scope.ServiceProvider.GetRequiredService>(); + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(new TestCommand()); + Assert.Equal(7, disposeCount); } [Fact] - public async Task Test() + public async Task CommandLockFailedException_Should_Throw_When_Any_Key_Lock_Failed() { var mockDistributedLock = new Mock(); - var services = new ServiceCollection(); - services.AddSingleton(mockDistributedLock.Object); + int disposeCount = 0; + var mockLockSynchronizationHandler = new Mock(); + mockLockSynchronizationHandler.Setup(p => p.DisposeAsync()) + .Callback(() => disposeCount++); + mockDistributedLock.Setup(p => + p.TryAcquireAsync(It.IsNotIn("key3"), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockLockSynchronizationHandler.Object); + mockDistributedLock.Setup(p => + p.TryAcquireAsync(It.IsIn("key3"), It.IsAny(), It.IsAny())) + .ReturnsAsync(default(ILockSynchronizationHandler)); + var services = new ServiceCollection(); + services.AddSingleton(mockDistributedLock.Object); services.AddMediatR(c => { c.RegisterServicesFromAssembly(typeof(CommandLockBehaviorTest).Assembly); c.AddOpenBehavior(typeof(CommandLockBehavior<,>)); }); - services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var s = scope.ServiceProvider.GetRequiredService>(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var ex = await Assert.ThrowsAsync( + async () => await mediator.Send(new TestCommand2())); + Assert.Equal("key3", ex.FailedKey); + Assert.Equal(2, disposeCount); + } + [Fact] + public async Task CommandLockFailedException_Should_Not_Throw_When_All_Key_Lock_Success() + { + var mockDistributedLock = new Mock(); + int disposeCount = 0; + var mockLockSynchronizationHandler = new Mock(); + mockLockSynchronizationHandler.Setup(p => p.DisposeAsync()) + .Callback(() => disposeCount++); + mockDistributedLock.Setup(p => + p.TryAcquireAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockLockSynchronizationHandler.Object); + var services = new ServiceCollection(); + services.AddSingleton(mockDistributedLock.Object); + services.AddMediatR(c => + { + c.RegisterServicesFromAssembly(typeof(CommandLockBehaviorTest).Assembly); + c.AddOpenBehavior(typeof(CommandLockBehavior<,>)); + }); + services.AddCommandLocks(typeof(CommandLockBehaviorTest).Assembly); var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); + var s = scope.ServiceProvider.GetRequiredService>(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var cmd = new TestCommand2(); + var r = await mediator.Send(cmd); + Assert.Equal(5, disposeCount); + Assert.Equal(cmd.CommandId + "-handled", r); + } - var s = scope.ServiceProvider.GetRequiredService>(); - var mediator = scope.ServiceProvider.GetRequiredService(); + public class TestCommand : ICommand + { + public string CommandId { get; set; } = Guid.NewGuid().ToString(); + } - await mediator.Send(new TestCommand()); + public class CommandLock : ICommandLock + { + public Task GetLockKeysAsync(TestCommand command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CommandLockSettings(command.CommandId)); + } + } + + public class TestCommandHandler(IMediator mediator) : ICommandHandler + { + public async Task Handle(TestCommand request, CancellationToken cancellationToken) + { + var cmd = new TestCommand3() { CommandId = request.CommandId }; + await mediator.Send(cmd, cancellationToken); + await mediator.Send(cmd, cancellationToken); // 这行用来测试锁释放后,重入是否会重新锁的情况 + } + } + + + public class TestCommand2 : ICommand + { + public string CommandId { get; set; } = Guid.NewGuid().ToString(); + } + + public class CommandLock2 : ICommandLock + { + public Task GetLockKeysAsync(TestCommand2 command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CommandLockSettings(["key1", "key2", "key3"])); + } + } + + public class TestCommandHandler2(IMediator mediator) : ICommandHandler + { + public async Task Handle(TestCommand2 request, CancellationToken cancellationToken) + { + TestCommand3 cmd = new(); + await mediator.Send(cmd, cancellationToken); + await mediator.Send(cmd, cancellationToken); // 这行用来测试锁释放后,重入是否会重新锁的情况 + return request.CommandId + "-handled"; + } + } + + public class TestCommand3 : ICommand + { + public string CommandId { get; set; } = Guid.NewGuid().ToString(); + } + + public class CommandLock3 : ICommandLock + { + public Task GetLockKeysAsync(TestCommand3 command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CommandLockSettings([command.CommandId, "key1", "key2", "key3"])); + } + } + + public class TestCommandHandler3 : ICommandHandler + { + public Task Handle(TestCommand3 request, CancellationToken cancellationToken) + { + return Task.FromResult(request.CommandId + "-handled"); + } } } \ No newline at end of file diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs new file mode 100644 index 00000000..37b43e3c --- /dev/null +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs @@ -0,0 +1,38 @@ +using NetCorePal.Extensions.AspNetCore.CommandLocks; +using NetCorePal.Extensions.Domain; + +namespace NetCorePal.Extensions.AspNetCore.UnitTests; + +public partial record OrderId : IInt64StronglyTypedId; + +public class EntityIdExtensionsTests +{ + [Fact] + public void One_Id_ToCommandLockSettings() + { + var orderId = new OrderId(10); + Assert.Equal("10", orderId.ToString()); + var settings = orderId.ToCommandLockSettings(11); + Assert.Equal("OrderId-10", settings.LockKey); + Assert.Equal(11, settings.AcquireTimeout.TotalSeconds); + Assert.Null(settings.LockKeys); + } + + [Fact] + public void Two_Id_ToCommandLockSettings() + { + var orderId = new OrderId(10); + var orderId2 = new OrderId(20); + + List orderIds = new() { orderId, orderId2 }; + Assert.Equal("10", orderId.ToString()); + Assert.Equal("20", orderId2.ToString()); + var settings = orderIds.ToCommandLockSettings(11); + Assert.Null(settings.LockKey); + Assert.NotNull(settings.LockKeys); + Assert.Equal(2, settings.LockKeys.Count); + Assert.Equal("OrderId-10", settings.LockKeys[0]); + Assert.Equal("OrderId-20", settings.LockKeys[1]); + Assert.Equal(11, settings.AcquireTimeout.TotalSeconds); + } +} \ No newline at end of file diff --git a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTests.cs b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTests.cs index 53424e79..013a7145 100644 --- a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTests.cs +++ b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTests.cs @@ -49,5 +49,34 @@ public void GuidStronglyTypedId_Equal() Assert.False(id1 == id2); Assert.False(id1.Equals(id2)); } + + [Fact] + public void Int64StronglyTypedId_ToString() + { + OrderId1 id1 = new(1); + Assert.Equal("1", id1.ToString()); + } + + [Fact] + public void Int32StronglyTypedId_ToString() + { + OrderId2 id1 = new(1); + Assert.Equal("1", id1.ToString()); + } + + [Fact] + public void StringStronglyTypedId_ToString() + { + OrderId3 id1 = new("1"); + Assert.Equal("1", id1.ToString()); + } + + [Fact] + public void GuidStronglyTypedId_ToString() + { + Guid guid = Guid.NewGuid(); + OrderId4 id1 = new(guid); + Assert.Equal(guid.ToString(), id1.ToString()); + } } } \ No newline at end of file diff --git a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTypeConverterTests.cs b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTypeConverterTests.cs index 772294f5..6fa660c8 100644 --- a/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTypeConverterTests.cs +++ b/test/NetCorePal.Extensions.Domain.Abstractions.UnitTests/EntityIdTypeConverterTests.cs @@ -9,40 +9,17 @@ namespace NetCorePal.Extensions.Domain.Abstractions.UnitTests { - public class EntityIdTypeConverterTests - { - public record OrderId1(long Id) : IInt64StronglyTypedId - { - public override string ToString() - { - return Id.ToString(); - } - } + public partial record OrderId1 : IInt64StronglyTypedId; - public record OrderId2(int Id) : IInt32StronglyTypedId - { - public override string ToString() - { - return Id.ToString(); - } - } + public partial record OrderId2 : IInt32StronglyTypedId; - public record OrderId3(string Id) : IStringStronglyTypedId - { - public override string ToString() - { - return Id.ToString(); - } - } + public partial record OrderId3 : IStringStronglyTypedId; - public record OrderId4(Guid Id) : IGuidStronglyTypedId - { - public override string ToString() - { - return Id.ToString(); - } - } + public partial record OrderId4: IGuidStronglyTypedId; + public class EntityIdTypeConverterTests + { + [Fact] public void CanConvertFromTest() { From 75641d000553f0100ed2c53b94d08d4a80a3b96e Mon Sep 17 00:00:00 2001 From: witskeeper Date: Tue, 7 Jan 2025 15:08:16 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20commandlock,=20add=20docs=E3=80=81u?= =?UTF-8?q?nit=20tests=EF=BC=8Crefactor=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/concurrency/command-lock.md | 67 ++++++++++++++++ netcorepal-cloud-framework.sln | 7 ++ .../CommandLocks/CommandLockBehavior.cs | 17 +++- .../{CommandLocks => }/EntityIdExtensions.cs | 3 +- src/AspNetCore/ServiceCollectionExtension.cs | 27 ++++--- .../CommandLockSettings.cs | 2 +- .../ICommandLock.cs | 9 ++- .../EntityIdExtensionsTests.cs | 2 +- .../CommandLockSettingsTests.cs | 4 +- ...tCorePal.Extensions.Primitives.Test.csproj | 28 +++++++ ...Pal.Extensions.Primitives.UnitTests.csproj | 27 +++++++ .../Commands/CreateOrderCommand.cs | 78 +++++++++++++++++-- .../Commands/CreateOrderCommandHandler.cs | 39 ---------- .../Commands/CreateOrderCommandValidator.cs | 23 ------ test/NetCorePal.Web/Program.cs | 10 +-- 15 files changed, 248 insertions(+), 95 deletions(-) create mode 100644 docs/content/concurrency/command-lock.md rename src/AspNetCore/{CommandLocks => }/EntityIdExtensions.cs (89%) rename src/{AspNetCore/CommandLocks => Primitives}/CommandLockSettings.cs (97%) rename src/{AspNetCore/CommandLocks => Primitives}/ICommandLock.cs (54%) rename test/{NetCorePal.Extensions.AspNetCore.UnitTests => NetCorePal.Extensions.Primitives.UnitTests}/CommandLockSettingsTests.cs (95%) create mode 100644 test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.Test.csproj create mode 100644 test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.UnitTests.csproj delete mode 100644 test/NetCorePal.Web/Application/Commands/CreateOrderCommandHandler.cs delete mode 100644 test/NetCorePal.Web/Application/Commands/CreateOrderCommandValidator.cs diff --git a/docs/content/concurrency/command-lock.md b/docs/content/concurrency/command-lock.md new file mode 100644 index 00000000..b996c40f --- /dev/null +++ b/docs/content/concurrency/command-lock.md @@ -0,0 +1,67 @@ +# 命令锁 +顾名思义,命令锁是为了解决命令并发执行的问题。在一些场景下,我们需要保证某个命令在同一时间只能被一个实例执行,这时候就可以使用命令锁。 +本质上命令锁是一种分布式锁,它的实现方式有很多种,我们默认提供了基于Redis来实现的命令锁。 + +## 注册命令锁 + +在Program.cs注册`CommandLocks`: +```csharp +builder.Services.AddMediatR(cfg => + cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()) + .AddCommandLockBehavior() //注册命令锁行为 + .AddUnitOfWorkBehaviors() + .AddKnownExceptionValidationBehavior()); + +builder.Services.AddCommandLocks(typeof(Program).Assembly); //注册所有的命令锁类型 +``` + +注意: 命令锁应该在事务开启前执行,所以需要在`AddUnitOfWorkBehaviors`之前添加`AddCommandLockBehavior`。 + + +## 使用命令锁 + +定义一个命令锁,实现`ICommandLock`接口,其中`TCommand`是命令类型,例如: + +```csharp +public record PayOrderCommand(OrderId Id) : ICommand; + +public class PayOrderCommandLock : ICommandLock +{ + public Task GetLockKeysAsync(PayOrderCommand command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(command.Id.ToCommandLockSettings()); + } +} +``` + +其中`command.Id.ToCommandLockSettings()`是将`OrderId`转换为`CommandLockSettings`,`CommandLockSettings`是命令锁的配置,包含了锁的Key、获取锁之前可以等待的过期时间。 + +设计上,命令锁与命令是一对一关系,建议将命令锁与命令、命令处理器放在同一个类文件中,便于维护。 + +## 多key命令锁 + +命令锁支持多Key机制,即一个命令可以对应多个Key,例如: + +```csharp + +public class PayOrderCommandLock : ICommandLock +{ + public Task GetLockKeysAsync(PayOrderCommand command, + CancellationToken cancellationToken = default) + { + var ids = new List { new OrderId(1), new OrderId(2) }; + return Task.FromResult(ids.ToCommandLockSettings()); + } +} +``` + +在这个例子中,`PayOrderCommand`对应两个Key,分别是`OrderId(1)`和`OrderId(2)`。 + +当需要锁定多个Key时,CommandLockSettings会对多个Key进行排序,然后逐个锁定,如果其中一个Key锁定失败,则会释放已经锁定的Key。 + + +## 可重入机制 + +命令锁实现了可重入机制,即在同一个请求上下文中,相同的Key可以重复获取锁,不会造成死锁。 +例如上面示例的命令执行后序的事件处理过程中再次执行携带相同Key的命令锁,不会死锁。 \ No newline at end of file diff --git a/netcorepal-cloud-framework.sln b/netcorepal-cloud-framework.sln index ed6f81e4..b3745326 100644 --- a/netcorepal-cloud-framework.sln +++ b/netcorepal-cloud-framework.sln @@ -141,6 +141,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetCorePal.Extensions.Dto", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetCorePal.Extensions.Dto.Tests", "test\NetCorePal.Extensions.Dto.Tests\NetCorePal.Extensions.Dto.Tests.csproj", "{57FCB1FF-0A79-4172-B208-4AD7FC04EF26}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetCorePal.Extensions.Primitives.UnitTests", "test\NetCorePal.Extensions.Primitives.UnitTests\NetCorePal.Extensions.Primitives.UnitTests.csproj", "{F4C18BB0-F078-40AA-B09F-F2F4A06C2785}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -359,6 +361,10 @@ Global {57FCB1FF-0A79-4172-B208-4AD7FC04EF26}.Debug|Any CPU.Build.0 = Debug|Any CPU {57FCB1FF-0A79-4172-B208-4AD7FC04EF26}.Release|Any CPU.ActiveCfg = Release|Any CPU {57FCB1FF-0A79-4172-B208-4AD7FC04EF26}.Release|Any CPU.Build.0 = Release|Any CPU + {F4C18BB0-F078-40AA-B09F-F2F4A06C2785}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4C18BB0-F078-40AA-B09F-F2F4A06C2785}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4C18BB0-F078-40AA-B09F-F2F4A06C2785}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4C18BB0-F078-40AA-B09F-F2F4A06C2785}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -422,6 +428,7 @@ Global {59BB1378-ABEE-4A1E-BF14-7C807F83C8E3} = {605436C1-2560-47BA-99D4-7A80B628230F} {02772177-21D1-4CF8-8F13-FBE4F5DE0F22} = {605436C1-2560-47BA-99D4-7A80B628230F} {57FCB1FF-0A79-4172-B208-4AD7FC04EF26} = {DB5A8331-2B57-4F2B-8F7D-02E167BCD8BB} + {F4C18BB0-F078-40AA-B09F-F2F4A06C2785} = {DB5A8331-2B57-4F2B-8F7D-02E167BCD8BB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D46201E5-1E3C-47A0-8584-4ACCC135CF9A} diff --git a/src/AspNetCore/CommandLocks/CommandLockBehavior.cs b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs index 218c5e15..bdffe1ef 100644 --- a/src/AspNetCore/CommandLocks/CommandLockBehavior.cs +++ b/src/AspNetCore/CommandLocks/CommandLockBehavior.cs @@ -5,17 +5,30 @@ namespace NetCorePal.Extensions.AspNetCore.CommandLocks; public class CommandLockBehavior( - ICommandLock commandLock, + IEnumerable> commandLocks, IDistributedLock distributedLock) : IPipelineBehavior where TRequest : IBaseCommand { +#pragma warning disable S3604 private readonly CommandLockedKeysHolder _lockedKeys = new(); - +#pragma warning restore S3604 public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { + var count = commandLocks.Count(); + if (count == 0) + { + return await next(); + } + + if (count > 1) + { + throw new InvalidOperationException("Only one ICommandLock is allowed"); + } + + var commandLock = commandLocks.First(); var options = await commandLock.GetLockKeysAsync(request, cancellationToken); if (!string.IsNullOrEmpty(options.LockKey)) { diff --git a/src/AspNetCore/CommandLocks/EntityIdExtensions.cs b/src/AspNetCore/EntityIdExtensions.cs similarity index 89% rename from src/AspNetCore/CommandLocks/EntityIdExtensions.cs rename to src/AspNetCore/EntityIdExtensions.cs index 8df876d7..916c9e43 100644 --- a/src/AspNetCore/CommandLocks/EntityIdExtensions.cs +++ b/src/AspNetCore/EntityIdExtensions.cs @@ -1,6 +1,7 @@ using NetCorePal.Extensions.Domain; +using NetCorePal.Extensions.Primitives; -namespace NetCorePal.Extensions.AspNetCore.CommandLocks; +namespace NetCorePal.Extensions.Primitives; public static class EntityIdExtensions { diff --git a/src/AspNetCore/ServiceCollectionExtension.cs b/src/AspNetCore/ServiceCollectionExtension.cs index 4ff9d887..01cd4c5e 100644 --- a/src/AspNetCore/ServiceCollectionExtension.cs +++ b/src/AspNetCore/ServiceCollectionExtension.cs @@ -48,6 +48,17 @@ public static MediatRServiceConfiguration AddKnownExceptionValidationBehavior( return cfg; } + /// + /// 添加CommandLockBehavior,以支持命令锁 + /// + /// + /// + public static MediatRServiceConfiguration AddCommandLockBehavior(this MediatRServiceConfiguration cfg) + { + cfg.AddOpenBehavior(typeof(CommandLockBehavior<,>)); + return cfg; + } + /// /// 将所有实现IQuery接口的类注册为查询类,添加到容器中 @@ -145,18 +156,16 @@ public static IServiceCollection AddCommandLocks(this IServiceCollection service foreach (var assembly in assemblies) { var types = assembly.GetTypes(); - foreach (var type in types) + + foreach (var type in types.Where(p => p is { IsClass: true, IsAbstract: false, IsGenericType: false })) { - if (type is { IsClass: true, IsAbstract: false, IsGenericType: false }) + var interfaces = type.GetInterfaces(); + foreach (var @interface in interfaces) { - var interfaces = type.GetInterfaces(); - foreach (var @interface in interfaces) + if (@interface.IsGenericType && + @interface.GetGenericTypeDefinition() == typeof(ICommandLock<>)) { - if (@interface.IsGenericType && - @interface.GetGenericTypeDefinition() == typeof(ICommandLock<>)) - { - services.AddTransient(@interface, type); - } + services.AddTransient(@interface, type); } } } diff --git a/src/AspNetCore/CommandLocks/CommandLockSettings.cs b/src/Primitives/CommandLockSettings.cs similarity index 97% rename from src/AspNetCore/CommandLocks/CommandLockSettings.cs rename to src/Primitives/CommandLockSettings.cs index 68fed22e..b2224d58 100644 --- a/src/AspNetCore/CommandLocks/CommandLockSettings.cs +++ b/src/Primitives/CommandLockSettings.cs @@ -1,6 +1,6 @@ using System.Reflection.Metadata; -namespace NetCorePal.Extensions.AspNetCore.CommandLocks; +namespace NetCorePal.Extensions.Primitives; public sealed record CommandLockSettings { diff --git a/src/AspNetCore/CommandLocks/ICommandLock.cs b/src/Primitives/ICommandLock.cs similarity index 54% rename from src/AspNetCore/CommandLocks/ICommandLock.cs rename to src/Primitives/ICommandLock.cs index 29014faf..54529f30 100644 --- a/src/AspNetCore/CommandLocks/ICommandLock.cs +++ b/src/Primitives/ICommandLock.cs @@ -1,8 +1,9 @@ -using MediatR; -using NetCorePal.Extensions.Primitives; - -namespace NetCorePal.Extensions.AspNetCore.CommandLocks; +namespace NetCorePal.Extensions.Primitives; +/// +/// 表示一个命令锁 +/// +/// 要锁定的命令类型 public interface ICommandLock where TCommand : IBaseCommand { Task GetLockKeysAsync(TCommand command, diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs b/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs index 37b43e3c..f994b9bb 100644 --- a/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs +++ b/test/NetCorePal.Extensions.AspNetCore.UnitTests/EntityIdExtensionsTests.cs @@ -1,5 +1,5 @@ -using NetCorePal.Extensions.AspNetCore.CommandLocks; using NetCorePal.Extensions.Domain; +using NetCorePal.Extensions.Primitives; namespace NetCorePal.Extensions.AspNetCore.UnitTests; diff --git a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs b/test/NetCorePal.Extensions.Primitives.UnitTests/CommandLockSettingsTests.cs similarity index 95% rename from test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs rename to test/NetCorePal.Extensions.Primitives.UnitTests/CommandLockSettingsTests.cs index 77790023..b20e131b 100644 --- a/test/NetCorePal.Extensions.AspNetCore.UnitTests/CommandLockSettingsTests.cs +++ b/test/NetCorePal.Extensions.Primitives.UnitTests/CommandLockSettingsTests.cs @@ -1,6 +1,4 @@ -using NetCorePal.Extensions.AspNetCore.CommandLocks; - -namespace NetCorePal.Extensions.AspNetCore.UnitTests; +namespace NetCorePal.Extensions.Primitives.UnitTests; public class CommandLockSettingsTests { diff --git a/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.Test.csproj b/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.Test.csproj new file mode 100644 index 00000000..33d8592a --- /dev/null +++ b/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.Test.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0 + enable + enable + + false + true + NetCorePal.Extensions.Primitives.Tests + + + + + + + + + + + + + + + + + + diff --git a/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.UnitTests.csproj b/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.UnitTests.csproj new file mode 100644 index 00000000..d6e00ceb --- /dev/null +++ b/test/NetCorePal.Extensions.Primitives.UnitTests/NetCorePal.Extensions.Primitives.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0;net9.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/test/NetCorePal.Web/Application/Commands/CreateOrderCommand.cs b/test/NetCorePal.Web/Application/Commands/CreateOrderCommand.cs index ede4196d..34241f31 100644 --- a/test/NetCorePal.Web/Application/Commands/CreateOrderCommand.cs +++ b/test/NetCorePal.Web/Application/Commands/CreateOrderCommand.cs @@ -1,13 +1,79 @@ -using NetCorePal.Extensions.Primitives; +using FluentValidation; +using NetCorePal.Extensions.Primitives; using NetCorePal.Web.Domain; -namespace NetCorePal.Web.Application.Commands +namespace NetCorePal.Web.Application.Commands; + +/// +/// +/// +/// +/// +/// +public record CreateOrderCommand(string Name, int Price, int Count) : ICommand; + +public class CreateOrderCommandLock : ICommandLock +{ + /// + /// + /// + /// + /// + /// + public Task GetLockKeysAsync(CreateOrderCommand command, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CommandLockSettings(command.Name)); + } +} + +/// +/// +/// +public class CreateOrderCommandValidator : AbstractValidator { /// /// /// - /// - /// - /// - public record CreateOrderCommand(string Name, int Price, int Count) : ICommand; + public CreateOrderCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(10); + RuleFor(x => x.Price).InclusiveBetween(18, 60); + RuleFor(x => x.Count).MustAsync(async (c, ct) => + { + await Task.CompletedTask; + return c > 0; + }); + } } + +/// +/// +/// +/// +/// +public class CreateOrderCommandHandler(IOrderRepository orderRepository, ILogger logger) + : ICommandHandler +{ + /// + /// + /// + /// + /// + /// + public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) + { + var a = new List(); + for (int i = 0; i < 1000; i++) + { + a.Add(i); + } + + await Parallel.ForEachAsync(a, new ParallelOptions(), async (item, c) => { await Task.Delay(12, c); }); + + var order = new Order(request.Name, request.Count); + order = await orderRepository.AddAsync(order, cancellationToken); + logger.LogInformation("order created, id:{orderId}", order.Id); + return order.Id; + } +} \ No newline at end of file diff --git a/test/NetCorePal.Web/Application/Commands/CreateOrderCommandHandler.cs b/test/NetCorePal.Web/Application/Commands/CreateOrderCommandHandler.cs deleted file mode 100644 index 4cb5a2c2..00000000 --- a/test/NetCorePal.Web/Application/Commands/CreateOrderCommandHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using NetCorePal.Extensions.Mappers; -using NetCorePal.Extensions.Primitives; -using NetCorePal.Web.Application.IntegrationEventHandlers; - -namespace NetCorePal.Web.Application.Commands -{ - /// - /// - /// - /// - /// - public class CreateOrderCommandHandler(IOrderRepository orderRepository, ILogger logger) - : ICommandHandler - { - /// - /// - /// - /// - /// - /// - public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) - { - var a = new List(); - for (int i = 0; i < 1000; i++) - { - a.Add(i); - } - await Parallel.ForEachAsync(a, new ParallelOptions(), async (item,c) => - { - await Task.Delay(12, c); - }); - - var order = new Order(request.Name, request.Count); - order = await orderRepository.AddAsync(order, cancellationToken); - logger.LogInformation("order created, id:{orderId}", order.Id); - return order.Id; - } - } -} \ No newline at end of file diff --git a/test/NetCorePal.Web/Application/Commands/CreateOrderCommandValidator.cs b/test/NetCorePal.Web/Application/Commands/CreateOrderCommandValidator.cs deleted file mode 100644 index 828c9705..00000000 --- a/test/NetCorePal.Web/Application/Commands/CreateOrderCommandValidator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentValidation; - -namespace NetCorePal.Web.Application.Commands -{ - /// - /// - /// - public class CreateOrderCommandValidator : AbstractValidator - { - /// - /// - /// - public CreateOrderCommandValidator() - { - RuleFor(x => x.Name).NotEmpty().MaximumLength(10); - RuleFor(x => x.Price).InclusiveBetween(18, 60); - RuleFor(x => x.Count).MustAsync(async (c, ct) => { - await Task.CompletedTask; - return c > 0; - } ); - } - } -} diff --git a/test/NetCorePal.Web/Program.cs b/test/NetCorePal.Web/Program.cs index 5ea93b83..9307e6de 100644 --- a/test/NetCorePal.Web/Program.cs +++ b/test/NetCorePal.Web/Program.cs @@ -6,18 +6,13 @@ using NetCorePal.Extensions.DependencyInjection; using StackExchange.Redis; using System.Reflection; -using System.Text.Json; -using NetCorePal.Web.Infra; using Microsoft.EntityFrameworkCore; #if NET8_0 using Microsoft.OpenApi.Models; #endif using NetCorePal.Web.Application.Queries; -using NetCorePal.Extensions.DistributedTransactions.Sagas; -using NetCorePal.Extensions.MultiEnv; using NetCorePal.OpenTelemetry.Diagnostics; using NetCorePal.SkyApm.Diagnostics; -using NetCorePal.Web.Application.IntegrationEventHandlers; using NetCorePal.Web.HostedServices; using OpenTelemetry.Exporter; using OpenTelemetry.Resources; @@ -150,8 +145,11 @@ builder.Services.AddContext().AddEnvContext().AddCapContextProcessor(); builder.Services.AddMediatR(cfg => - cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()).AddUnitOfWorkBehaviors() + cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()) + .AddCommandLockBehavior() + .AddUnitOfWorkBehaviors() .AddKnownExceptionValidationBehavior()); + builder.Services.AddCommandLocks(typeof(Program).Assembly); builder.Services.AddRepositories(typeof(ApplicationDbContext).Assembly); builder.Services.AddDbContext(options => { From 33ec107e09838c30865e1e16e5d256bba007ac3d Mon Sep 17 00:00:00 2001 From: witskeeper Date: Tue, 7 Jan 2025 15:15:51 +0800 Subject: [PATCH 5/5] fix docs --- docs/content/{domain => events}/domain-event-handler.md | 0 docs/content/{domain => events}/integration-converter.md | 0 .../integration-event-handler.md} | 0 docs/mkdocs.yml | 6 +++++- 4 files changed, 5 insertions(+), 1 deletion(-) rename docs/content/{domain => events}/domain-event-handler.md (100%) rename docs/content/{domain => events}/integration-converter.md (100%) rename docs/content/{integration-event.md => events/integration-event-handler.md} (100%) diff --git a/docs/content/domain/domain-event-handler.md b/docs/content/events/domain-event-handler.md similarity index 100% rename from docs/content/domain/domain-event-handler.md rename to docs/content/events/domain-event-handler.md diff --git a/docs/content/domain/integration-converter.md b/docs/content/events/integration-converter.md similarity index 100% rename from docs/content/domain/integration-converter.md rename to docs/content/events/integration-converter.md diff --git a/docs/content/integration-event.md b/docs/content/events/integration-event-handler.md similarity index 100% rename from docs/content/integration-event.md rename to docs/content/events/integration-event-handler.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a05a1381..68feef2b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -56,7 +56,6 @@ nav: - 强类型ID: domain/strong-typed-id.md - 领域模型: domain/domain-entity.md - 领域事件: domain/domain-event.md - - 集成事件转换器: domain/integration-converter.md # - 值对象: domain/domain-value-object.md - ID生成器: - Snowflake: id-generator/snowflake.md @@ -65,6 +64,10 @@ nav: - 仓储: data/repository.md - UnitOfWork: data/unit-of-work.md - 事务处理: transaction/transactions.md + - 事件处理: + - 领域事件处理器: events/domain-event-handler.md + - 集成事件处理器: events/integration-event-handler.md + - 集成事件转换器: events/integration-converter.md - 服务发现: - 服务发现客户端: service-discovery/service-discovery-client.md - Kubernetes: service-discovery/k8s-service-discovery-provider.md @@ -75,6 +78,7 @@ nav: - 并发控制: - 乐观锁-行版本号: concurrency/row-version.md - 悲观锁-Redis锁: concurrency/redis-lock.md + - 命令锁: concurrency/command-lock.md - 多环境: - 环境上下文: env/env-context.md - 环境服务选择器: env/env-service-selector.md