feat(dispatcher): add registration-time pipeline behavior ordering via AddBehavior<T>(order:)
Pipeline behavior ordering previously depended on discovery/registration order. Add an AddBehavior<T>(order:) API that captures the order at registration time; the BehaviorTypeIndex now orders matching behaviors by that explicit order (lower runs first), falling back to registration order for ties. Removed the runtime Order property approach.
This commit is contained in:
parent
8099898f3d
commit
f9de3a99de
7 changed files with 143 additions and 17 deletions
|
|
@ -56,6 +56,7 @@ To have a consistent experience across all packages, some public interfaces have
|
|||
- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold
|
||||
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered
|
||||
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order
|
||||
- **request.dispatcher:** Add `AddBehavior<T>(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order
|
||||
|
||||
### Removed
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,26 @@ internal sealed class ScalarBehaviourTests
|
|||
await Assert.That(tracker.Executed).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<ScalarTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(ScalarTestHandler))
|
||||
.AddBehavior<OrderedScalarBehaviorA>(2)
|
||||
.AddBehavior<OrderedScalarBehaviorB>(1));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<ScalarTestTracker>();
|
||||
|
||||
var request = new ScalarTestRequest { Value = "Hello" };
|
||||
await dispatcher.DispatchAsync(request);
|
||||
|
||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -66,6 +66,26 @@ internal sealed class StreamBehaviourTests
|
|||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_order_behaviours_explicitly_by_order_property()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<StreamTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(StreamTestHandler))
|
||||
.AddBehavior<OrderedStreamBehaviorA>(2)
|
||||
.AddBehavior<OrderedStreamBehaviorB>(1));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<StreamTestTracker>();
|
||||
|
||||
var request = new StreamTestRequest { Value = "Hello" };
|
||||
await dispatcher.DispatchAsync(request).ToListAsync();
|
||||
|
||||
// Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order.
|
||||
await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
||||
{
|
||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedA");
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
|
||||
{
|
||||
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedB");
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedA");
|
||||
await foreach (var item in next(request, cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
|
||||
{
|
||||
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("OrderedB");
|
||||
await foreach (var item in next(request, cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,27 @@ public static class RequestDispatcherBuilderExtensions
|
|||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
|
||||
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
|
||||
/// first (outermost); behaviors with equal order run in registration order.
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
|
||||
where TBehavior : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
|
||||
|
||||
builder.Services.AddOptions<RequestDispatcherOptions>()
|
||||
.Configure(options => options.Inspect([typeof(TBehavior)], order));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified type to the request dispatcher configuration for inspection.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher;
|
|||
|
||||
internal sealed class RequestDispatcherOptions
|
||||
{
|
||||
private readonly List<Type> _search = [];
|
||||
private readonly List<(Type Type, int? Order)> _search = [];
|
||||
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
|
||||
private readonly Lazy<TypeIndex> _handlersTypeIndex;
|
||||
|
||||
public RequestDispatcherOptions()
|
||||
{
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
|
||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
|
||||
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
|
||||
}
|
||||
|
||||
public void Inspect(IEnumerable<Type> assembly)
|
||||
public void Inspect(IEnumerable<Type> assembly, int? order = null)
|
||||
{
|
||||
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
|
||||
{
|
||||
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
|
||||
}
|
||||
|
||||
_search.AddRange(assembly);
|
||||
foreach (var type in assembly)
|
||||
{
|
||||
_search.Add((type, order));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
|
||||
|
|
@ -47,15 +50,21 @@ internal sealed class RequestDispatcherOptions
|
|||
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
|
||||
protected readonly List<Type> _openTypeInfo = [];
|
||||
protected readonly Dictionary<Type, int> _order = [];
|
||||
protected readonly Dictionary<Type, int> _explicitOrder = [];
|
||||
|
||||
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
|
||||
protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate)
|
||||
{
|
||||
var index = 0;
|
||||
|
||||
foreach (var type in collection)
|
||||
foreach (var (type, order) in collection)
|
||||
{
|
||||
_order[type] = index++;
|
||||
|
||||
if (order is { } explicitOrder)
|
||||
{
|
||||
_explicitOrder[type] = explicitOrder;
|
||||
}
|
||||
|
||||
if (type.IsGenericTypeDefinition)
|
||||
{
|
||||
if (type.GetInterfaces().Any(predicate))
|
||||
|
|
@ -73,6 +82,11 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
protected int EffectiveOrder(Type type)
|
||||
{
|
||||
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
|
||||
}
|
||||
|
||||
public IEnumerable<T> Resolve<T>(IServiceProvider services)
|
||||
{
|
||||
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
|
||||
|
|
@ -102,7 +116,7 @@ internal sealed class RequestDispatcherOptions
|
|||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
||||
}
|
||||
|
||||
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
|
||||
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
: TypeIndex(collection, IsRequestHandlerType)
|
||||
{
|
||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||
|
|
@ -113,7 +127,7 @@ internal sealed class RequestDispatcherOptions
|
|||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((_order[type], type));
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +143,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((_order[type], impl));
|
||||
result.Add((EffectiveOrder(type), impl));
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -145,7 +159,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = type.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((_order[type], impl));
|
||||
result.Add((EffectiveOrder(type), impl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +168,7 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(x => x.Order).Select(x => x.Type).ToList();
|
||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +179,7 @@ internal sealed class RequestDispatcherOptions
|
|||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
|
||||
}
|
||||
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
|
||||
: TypeIndex(collection, IsRequestBehaviorType)
|
||||
{
|
||||
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
|
||||
|
|
@ -176,7 +190,7 @@ internal sealed class RequestDispatcherOptions
|
|||
{
|
||||
foreach (var type in list)
|
||||
{
|
||||
result.Add((_order[type], type));
|
||||
result.Add((EffectiveOrder(type), type));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +205,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType, responseType);
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((_order[behaviour], impl));
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -204,7 +218,7 @@ internal sealed class RequestDispatcherOptions
|
|||
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add((_order[behaviour], impl));
|
||||
result.Add((EffectiveOrder(behaviour), impl));
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
|
|
@ -212,7 +226,7 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(x => x.Order).Select(x => x.Type).ToList();
|
||||
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue