diff --git a/.vscode/launch.json b/.vscode/launch.json index d5db6fc..97fae70 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -95,6 +95,16 @@ "stopAtEntry": false, "type": "coreclr" }, + { + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/src/MiddlewarePipelineExample", + "name": "MiddlewarePipelineExample", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/MiddlewarePipelineExample/bin/Debug/net10.0/MiddlewarePipelineExample.dll", + "request": "launch", + "stopAtEntry": false, + "type": "coreclr" + }, { "cwd": "${workspaceFolder}/src/MultitenantExample.AspNetCore", "launchSettingsProfile": "MultitenantExample.AspNetCore", diff --git a/Examples.slnx b/Examples.slnx index d07123f..664e268 100644 --- a/Examples.slnx +++ b/Examples.slnx @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index 9afac27..4fd6f27 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Each example has its own README with what it shows and how to run it. | [DynamicProxyExample](src/DynamicProxyExample/README.md) | Method interception, wired both on the registration and by attribute | [`Autofac.Extras.DynamicProxy`](https://github.com/autofac/Autofac.Extras.DynamicProxy) | | [PoolingExample](src/PoolingExample/README.md) | Reusing expensive instances across scopes, with a reset-on-return policy | [`Autofac.Pooling`](https://github.com/autofac/Autofac.Pooling) | | [DotGraphExample](src/DotGraphExample/README.md) | Capturing a resolve operation as a Graphviz graph for troubleshooting | [`Autofac.Diagnostics.DotGraph`](https://github.com/autofac/Autofac.Diagnostics.DotGraph) | +| [MiddlewarePipelineExample](src/MiddlewarePipelineExample/README.md) | Resolve middleware for auditing, timing, caching, ambient state, and scope guards | [`Autofac`](https://github.com/autofac/Autofac) | ### .NET Framework diff --git a/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs new file mode 100644 index 0000000..b24b9c4 --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs @@ -0,0 +1,29 @@ +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Applied to every registration in the container rather than named on each one. +/// This is the answer to wanting a hook that runs for all registered components +/// without calling an extension method on each registration in turn. +/// +public sealed class ActivationAuditMiddleware : IResolveMiddleware +{ + private readonly List _log; + + public ActivationAuditMiddleware(List log) => _log = log; + + public PipelinePhase Phase => PipelinePhase.RegistrationPipelineStart; + + public void Execute(ResolveRequestContext context, Action next) + { + next(context); + + // The instance is available after next() returns, because that is when + // the rest of the pipeline has run and activation has happened. + if (context.NewInstanceActivated) + { + _log.Add(context.Instance!.GetType().Name); + } + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs new file mode 100644 index 0000000..01c4bbd --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs @@ -0,0 +1,33 @@ +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Short-circuits the pipeline. Setting +/// and never calling next means activation is skipped entirely, so nothing +/// downstream runs. Useful for a cache, and the reason middleware can be cheaper +/// than a registration that has to construct something to decide it wasn't needed. +/// +public sealed class CachingMiddleware : IResolveMiddleware +{ + private readonly Action _report; + private object? _cached; + + public CachingMiddleware(Action report) => _report = report; + + public PipelinePhase Phase => PipelinePhase.ResolveRequestStart; + + public void Execute(ResolveRequestContext context, Action next) + { + if (_cached is not null) + { + _report($"cache hit for {context.Service}, activation skipped"); + context.Instance = _cached; + return; + } + + next(context); + _cached = context.Instance; + _report($"cache miss for {context.Service}, instance stored"); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..6cb0ada --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,26 @@ +using Autofac; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Injects ambient state into a resolve without every caller having to pass it. +/// The parameter has to be added before the constructor is chosen, which is why +/// this sits in the parameter selection phase. +/// +public sealed class CorrelationIdMiddleware : IResolveMiddleware +{ + private readonly Func _currentCorrelationId; + + public CorrelationIdMiddleware(Func currentCorrelationId) => _currentCorrelationId = currentCorrelationId; + + public PipelinePhase Phase => PipelinePhase.ParameterSelection; + + public void Execute(ResolveRequestContext context, Action next) + { + context.ChangeParameters( + context.Parameters.Concat([new NamedParameter("correlationId", _currentCorrelationId())])); + + next(context); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs new file mode 100644 index 0000000..41c016b --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs @@ -0,0 +1,26 @@ +using Autofac.Core; +using Autofac.Core.Lifetime; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Fails fast with a readable message when a service that expects a unit of work +/// is resolved straight from the root scope, where it would be captured for the +/// lifetime of the application. +/// +public sealed class RootScopeGuardMiddleware : IResolveMiddleware +{ + public PipelinePhase Phase => PipelinePhase.ScopeSelection; + + public void Execute(ResolveRequestContext context, Action next) + { + if (Equals(context.ActivationScope.Tag, LifetimeScope.RootTag)) + { + throw new DependencyResolutionException( + $"{context.Service} must be resolved from a child lifetime scope, not the root container."); + } + + next(context); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs new file mode 100644 index 0000000..655e9d0 --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Measures how long a resolve takes. Attached as service middleware, so it runs +/// for every resolve of the service regardless of which registration answers it. +/// +public sealed class TimingMiddleware : IResolveMiddleware +{ + private readonly Action _report; + + public TimingMiddleware(Action report) => _report = report; + + public PipelinePhase Phase => PipelinePhase.ResolveRequestStart; + + public void Execute(ResolveRequestContext context, Action next) + { + var stopwatch = Stopwatch.StartNew(); + next(context); + stopwatch.Stop(); + + _report($"{context.Service} took {stopwatch.ElapsedMilliseconds}ms"); + } +} diff --git a/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj b/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj new file mode 100644 index 0000000..9d67923 --- /dev/null +++ b/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj @@ -0,0 +1,13 @@ + + + + Exe + net10.0 + enable + + + + + + + diff --git a/src/MiddlewarePipelineExample/Program.cs b/src/MiddlewarePipelineExample/Program.cs new file mode 100644 index 0000000..c87605e --- /dev/null +++ b/src/MiddlewarePipelineExample/Program.cs @@ -0,0 +1,166 @@ +using Autofac; +using Autofac.Core; +using MiddlewarePipelineExample.Middleware; +using MiddlewarePipelineExample.Services; + +namespace MiddlewarePipelineExample; + +internal static class Program +{ + /// + /// Runs each scenario in turn. They are independent, so they each build their + /// own container rather than sharing one. + /// + public static void Main() + { + AuditEveryRegistration(); + TimeAResolve(); + ShortCircuitWithACache(); + InjectAmbientState(); + RejectRootScopeResolves(); + } + + /// + /// Attaches middleware to every registration at once, without naming it on + /// each one. Autofac issue #1337 asked for exactly this hook. + /// + private static void AuditEveryRegistration() + { + Header("Middleware on every registration"); + + var activated = new List(); + var builder = new ContainerBuilder(); + + builder.ComponentRegistryBuilder.Registered += (sender, args) => + args.ComponentRegistration.PipelineBuilding += (sender2, pipeline) => + pipeline.Use(new ActivationAuditMiddleware(activated)); + + builder.RegisterType().As(); + builder.RegisterType().As(); + + using var container = builder.Build(); + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + scope.Resolve(); + + Console.WriteLine($" activated: {string.Join(", ", activated)}"); + Console.WriteLine(" neither registration mentions the middleware."); + } + + /// + /// Service middleware wraps every resolve of a service, so it can measure what + /// activation actually costs without the service or its registration knowing. + /// + private static void TimeAResolve() + { + Header("Timing a resolve"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As(); + builder.RegisterServiceMiddleware(new TimingMiddleware(m => Console.WriteLine($" {m}"))); + + using var container = builder.Build(); + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + } + + /// + /// Setting Instance and returning without calling next ends the + /// pipeline early, so activation never happens. That is what makes a middleware + /// cache cheaper than a registration which has to construct something before it + /// can decide the work was unnecessary. + /// + private static void ShortCircuitWithACache() + { + Header("Short-circuiting the pipeline"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As(); + builder.RegisterServiceMiddleware(new CachingMiddleware(m => Console.WriteLine($" {m}"))); + + using var container = builder.Build(); + for (var i = 0; i < 2; i++) + { + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + } + + Console.WriteLine(" the second resolve never reached activation."); + } + + /// + /// Supplies a constructor argument no caller passes. Watch which pipeline this + /// one attaches to: parameter selection belongs to the registration pipeline, so + /// service middleware is rejected outright. + /// + private static void InjectAmbientState() + { + Header("Injecting ambient state"); + + var correlationId = "req-001"; + var builder = new ContainerBuilder(); + + // Parameter selection is a registration pipeline phase, so this one goes + // on the registration. Adding it as service middleware throws, because a + // service pipeline has already finished by the time parameters matter. + builder.RegisterType() + .As() + .ConfigurePipeline(pipeline => pipeline.Use(new CorrelationIdMiddleware(() => correlationId))); + + using var container = builder.Build(); + + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" {scope.Resolve().Describe()}"); + } + + correlationId = "req-002"; + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" {scope.Resolve().Describe()}"); + } + + Console.WriteLine(" no caller passed the correlation id."); + } + + /// + /// Turns a captured dependency into an immediate, readable failure. Resolving a + /// per-scope service straight from the root container would otherwise keep it + /// alive for the life of the application, and the symptom usually shows up a + /// long way from the cause. + /// + private static void RejectRootScopeResolves() + { + Header("Failing fast on a root scope resolve"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterServiceMiddleware(new RootScopeGuardMiddleware()); + + using var container = builder.Build(); + + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" from a child scope: {scope.Resolve().Use()}"); + } + + try + { + container.Resolve(); + } + catch (DependencyResolutionException ex) + { + Console.WriteLine($" from the root scope: {ex.Message.Split(" ---> ")[^1]}"); + } + } + + /// + /// Writes a section title so each scenario's output is easy to tell apart. + /// + private static void Header(string title) + { + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); + } +} diff --git a/src/MiddlewarePipelineExample/README.md b/src/MiddlewarePipelineExample/README.md new file mode 100644 index 0000000..e5c9315 --- /dev/null +++ b/src/MiddlewarePipelineExample/README.md @@ -0,0 +1,11 @@ +# MiddlewarePipelineExample + +Five things resolve middleware is actually useful for, each in its own class: auditing every registration without naming the middleware on any of them, timing a resolve, short-circuiting the pipeline from a cache, injecting ambient state as a parameter, and rejecting a resolve that came from the root scope. + +The correlation ID scenario is the one worth reading twice. It runs in the parameter selection phase, which belongs to the registration pipeline, so it has to be attached with `ConfigurePipeline` rather than `RegisterServiceMiddleware`. Registering it as service middleware throws, and that error is the clearest explanation of why Autofac has two pipelines rather than one. + +Packages: [`Autofac`](https://github.com/autofac/Autofac) + +Run `dotnet run --project src/MiddlewarePipelineExample`. Each scenario prints a short section showing what the middleware did. + +See [Resolve Pipelines](https://autofac.readthedocs.io/en/latest/advanced/pipelines.html) for the documentation this example follows. diff --git a/src/MiddlewarePipelineExample/Services/IReportService.cs b/src/MiddlewarePipelineExample/Services/IReportService.cs new file mode 100644 index 0000000..d245a9b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IReportService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IReportService +{ + string Run(); +} diff --git a/src/MiddlewarePipelineExample/Services/IRequestHandler.cs b/src/MiddlewarePipelineExample/Services/IRequestHandler.cs new file mode 100644 index 0000000..ffb0609 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IRequestHandler.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IRequestHandler +{ + string Describe(); +} diff --git a/src/MiddlewarePipelineExample/Services/IScopedResource.cs b/src/MiddlewarePipelineExample/Services/IScopedResource.cs new file mode 100644 index 0000000..9334f57 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IScopedResource.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IScopedResource +{ + string Use(); +} diff --git a/src/MiddlewarePipelineExample/Services/ISlowService.cs b/src/MiddlewarePipelineExample/Services/ISlowService.cs new file mode 100644 index 0000000..ee69687 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ISlowService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface ISlowService +{ + string Fetch(); +} diff --git a/src/MiddlewarePipelineExample/Services/ReportService.cs b/src/MiddlewarePipelineExample/Services/ReportService.cs new file mode 100644 index 0000000..e5ba61f --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ReportService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public sealed class ReportService : IReportService +{ + public string Run() => "report"; +} diff --git a/src/MiddlewarePipelineExample/Services/RequestHandler.cs b/src/MiddlewarePipelineExample/Services/RequestHandler.cs new file mode 100644 index 0000000..ea3165b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/RequestHandler.cs @@ -0,0 +1,14 @@ +namespace MiddlewarePipelineExample.Services; + +/// +/// Takes a correlation identifier it has no way to know at registration time. +/// Middleware supplies it per resolve. +/// +public sealed class RequestHandler : IRequestHandler +{ + private readonly string _correlationId; + + public RequestHandler(string correlationId) => _correlationId = correlationId; + + public string Describe() => $"handling request {_correlationId}"; +} diff --git a/src/MiddlewarePipelineExample/Services/ScopedResource.cs b/src/MiddlewarePipelineExample/Services/ScopedResource.cs new file mode 100644 index 0000000..92f892b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ScopedResource.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public sealed class ScopedResource : IScopedResource +{ + public string Use() => "scoped work"; +} diff --git a/src/MiddlewarePipelineExample/Services/SlowService.cs b/src/MiddlewarePipelineExample/Services/SlowService.cs new file mode 100644 index 0000000..74ed47a --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/SlowService.cs @@ -0,0 +1,12 @@ +namespace MiddlewarePipelineExample.Services; + +/// +/// Expensive to construct, which is what makes the timing and caching middleware +/// worth looking at. +/// +public sealed class SlowService : ISlowService +{ + public SlowService() => Thread.Sleep(75); + + public string Fetch() => "data"; +}