diff --git a/docs/docs/pipeline-runners.md b/docs/docs/pipeline-runners.md index 590ef47..fc6d4b7 100644 --- a/docs/docs/pipeline-runners.md +++ b/docs/docs/pipeline-runners.md @@ -5,37 +5,69 @@ title: Pipeline Runners A pipeline runner is responsible for executing a pipeline by resolving and invoking each registered step in order. It handles lazy instantiation, short-circuiting, cancellation, and exceptions during execution. -## Runner Interfaces and Defaults +## Default Runner Interfaces and Implementation -All runners implement the `IPipelineRunner` interface, where `TStepInterface` must implement `IPipelineStep`. In most cases, `TStepInterface` is a custom interface used to identify which steps belong to a specific pipeline. +All runners implement the `IPipelineRunner` interface, where `TStepInterface` must implement `IPipelineStep`. `TStepInterface` is typically used to distinguish steps when multiple pipelines share the same context type. If your application uses only one pipeline per context, you can use the simplified `IPipelineRunner` interface, which is easier to register and resolve from the dependency injection container. -If you're not using a custom step interface, you can use the convenience interface `IPipelineRunner`. This is just a shorthand for `IPipelineRunner>`. +PipeForge provides default implementations for both interfaces. You only need a custom runner if you want to override execution behavior or manually manage dependency resolution. -```csharp title="IPipelineRunner.cs" -IPipelineRunner - where T : class - where TStepInterface : IPipelineStep -{ } +## Custom Runner Interfaces -IPipelineRunner : IPipelineRunner> - where TContext : class -{ } +When using custom step interfaces (e.g. for multiple pipelines with the same context), you can define a corresponding runner interface without implementing it—PipeForge will generate the implementation automatically in supported environments. This simplifies DI registration and resolution by avoiding generic type repetition. + +```csharp title="Create the interface" +public interface ISampleContextRunner + : IPipelineRunner { } +``` + +```csharp title="Register the interface" +builder.Services.AddPipeline(); ``` -PipeForge provides default runner implementations for `IPipelineRunner` and `IPipelineRunner`, so you don't need to write a custom runner unless you want to customize behavior or manage dependency resolution differently. +```csharp title="Resolve the interface" +public class MyService +{ + private readonly ISampleContextRunner _runner; -If you're using custom step interfaces (e.g. to support multiple pipelines), it can be helpful to define a matching custom runner. PipeForge provides a base class, `PipelineRunner`, to make this easy: + public MyService(ISampleContextRunner runner) + { + _runner = runner; + } +} +``` -```csharp -public interface ISampleContextRunner - : IPipelineRunner { } +:::tip +In .NET 5.0 and later (excluding AOT), PipeForge generates the runner implementation automatically—you only need to define the interface. + +::: + +## Custom Runner Implementation + +The following table summarizes when you need to provide a **concrete implementation**: + +| Target Environment | Interface Only | Concrete Implementation Required | +| ------------------ | -------------- | -------------------------------- | +| .NET 5.0 or higher | ✅ | ❌ | +| Native AOT | ❌ | ✅ | +| .NET Standard 2.0 | ❌ | ✅ | + +If you’re targeting .NET Standard 2.0 or Native AOT, you must provide a concrete implementation. PipeForge makes this easy by providing a base class, `PipelineRunner`: + +```csharp title="Custom interface implementation" public class SampleContextRunner - : PipelineRunner, ISampleContextRunner { } + : PipelineRunner, ISampleContextRunner +{ + public SampleContextRunner(IServiceProvider serviceProvider) : base(serviceProvider) + { + } +} ``` -This approach allows you to resolve your runner via `ISampleContextRunner`, rather than repeating the full generic signature. When using PipeForge's registration extensions, your custom runner will be registered automatically - no manual wiring required. +This approach allows you to register and resolve your runner via `ISampleContextRunner`, same as before. + +:::tip -## Custom Runners + To customize behavior (e.g. logging, diagnostics, instrumentation), implement `IPipelineRunner` directly or subclass `PipelineRunner`. -If you need to extend the default behavior (e.g. to add logging, diagnostics, or custom instrumentation), you can create your own runner by implementing `IPipelineRunner` directly, and optionally extending `PipelineRunner`. \ No newline at end of file +::: \ No newline at end of file diff --git a/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs b/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs index 18dcf3b..80f44eb 100644 --- a/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs +++ b/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs @@ -93,7 +93,7 @@ public void RegisterRunner_ReturnsFalse_WhenRunnerIsAlreadyRegisters() result.ShouldBeFalse(); } - +#if NETSTANDARD2_0 [Fact] public void RegisterRunner_Throws_WhenNoRunnerImplementationFound() { @@ -110,4 +110,28 @@ public void RegisterRunner_Throws_WhenNoRunnerImplementationFound() ex.Message.ShouldBe(string.Format(PipeForge.PipelineRegistration.MessageRunnerImplementationNotFound, runnerTypeName)); } +#else + [Fact] + public void RegisterRunner_GeneratesRunner_WhenNoRunnerImplementationFound() + { + var runnerTypeName = typeof(INotImplementedRunner).FullName ?? typeof(INotImplementedRunner).Name; + var services = new ServiceCollection(); + + services.RegisterRunner(Assemblies, ServiceLifetime.Transient, null); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetService(); + + runner.ShouldNotBeNull(); + var type = runner.GetType(); + + type.ShouldNotBeNull(); + type.IsAbstract.ShouldBeFalse(); + type.IsInterface.ShouldBeFalse(); + type.IsGenericType.ShouldBeFalse(); + + typeof(INotImplementedRunner).IsAssignableFrom(type).ShouldBeTrue(); + typeof(PipelineRunner).IsAssignableFrom(type).ShouldBeTrue(); + } +#endif } diff --git a/src/PipeForge.Tests/PipelineRunnerFactoryTests.cs b/src/PipeForge.Tests/PipelineRunnerFactoryTests.cs new file mode 100644 index 0000000..d3f56f7 --- /dev/null +++ b/src/PipeForge.Tests/PipelineRunnerFactoryTests.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests; + +public class PipelineRunnerFactoryTests +{ + private readonly Assembly[] _assemblies = [typeof(PipelineRunnerFactoryTests).Assembly]; + private readonly ILogger _logger = new LoggerFactory().CreateLogger("Tests"); + + + public interface IGeneratedRunner : IPipelineRunner { } + + [Fact] + public void Factory_Creates_Type_That_Implements_Interface_And_Inherits_Base() + { + var type = PipelineRunnerFactory.CreatePipelineRunner(); + + type.ShouldNotBeNull(); + type.IsAbstract.ShouldBeFalse(); + type.IsInterface.ShouldBeFalse(); + type.IsGenericType.ShouldBeFalse(); + + typeof(IGeneratedRunner).IsAssignableFrom(type).ShouldBeTrue(); + typeof(PipelineRunner).IsAssignableFrom(type).ShouldBeTrue(); + } +} diff --git a/src/PipeForge/PipelineRegistration.cs b/src/PipeForge/PipelineRegistration.cs index 70929d8..40805a9 100644 --- a/src/PipeForge/PipelineRegistration.cs +++ b/src/PipeForge/PipelineRegistration.cs @@ -13,6 +13,8 @@ internal static class PipelineRegistration internal static readonly string MessageNoStepsFound = "No pipeline steps found for {0} in the specified assemblies."; internal static readonly string MessageNumberStepsFound = "Discovered and registered {0} pipeline steps for {1}."; internal static readonly string MessageRunnerAlreadyRegistered = "Pipeline runner for {0} already registered. Skipping step registration."; + internal static readonly string MessageRunnerDynamicCodeNotSupported = "Dynamic code generation is not supported on this runtime."; + internal static readonly string MessageRunnerFailedDynamicCreation = "Failed to create dynamic pipeline runner for {0}."; internal static readonly string MessageRunnerImplementationNotFound = "No concrete implementation found for pipeline runner interface '{0}'. If you are using a custom runner interface, you must also provide an implementation for it."; internal static readonly string MessageRunnerRegistration = "Registering pipeline runner implementation {0} for interface {1} with {2} lifetime"; internal static readonly string MessageStepAlreadyRegistered = "Pipeline step '{0}' is already registered. Pipeline steps must be uniquely registered."; @@ -110,9 +112,33 @@ public static bool RegisterRunner( return true; } +#if NET5_0_OR_GREATER + // 4. Attempt to create a dynamic runner using the factory. + try + { + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + throw new PlatformNotSupportedException(MessageRunnerDynamicCodeNotSupported); + + var proxy = PipelineRunnerFactory.CreatePipelineRunner(); + logger?.LogDebug(MessageRunnerRegistration, proxy.GetTypeName(), runnerType.GetTypeName(), lifetime.ToString()); + services.TryAdd(ServiceDescriptor.Describe(runnerType, proxy, lifetime)); + return true; + } + catch (PlatformNotSupportedException ex) + { + logger?.LogError(ex, MessageRunnerDynamicCodeNotSupported, runnerType.GetTypeName()); + throw; + } + catch (Exception ex) + { + logger?.LogError(ex, MessageRunnerFailedDynamicCreation, runnerType.GetTypeName()); + throw; + } +#else // 4. Throw an exception when no implementation found, and the default is not valid logger?.LogWarning(MessageRunnerImplementationNotFound, runnerType.GetTypeName()); throw new InvalidOperationException(string.Format(MessageRunnerImplementationNotFound, runnerType.GetTypeName())); +#endif } public static void RegisterStep( diff --git a/src/PipeForge/PipelineRunnerFactory.cs b/src/PipeForge/PipelineRunnerFactory.cs new file mode 100644 index 0000000..ccc2b55 --- /dev/null +++ b/src/PipeForge/PipelineRunnerFactory.cs @@ -0,0 +1,55 @@ +#if NET5_0_OR_GREATER +using System.Reflection; +using System.Reflection.Emit; + +namespace PipeForge; + +internal static class PipelineRunnerFactory +{ + private static readonly AssemblyBuilder _assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("PipeForge.DynamicPipelines"), AssemblyBuilderAccess.Run); + + private static readonly ModuleBuilder _moduleBuilder = _assemblyBuilder.DefineDynamicModule("MainModule"); + + private static readonly Type[] _parameterTypes = [typeof(IServiceProvider)]; + + public static Type CreatePipelineRunner() + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + if (!typeof(TRunnerInterface).IsInterface) + throw new InvalidOperationException("TRunnerInterface must be an interface."); + + var typeBuilder = _moduleBuilder.DefineType( + $"PipeForge.DynamicPipelines.{typeof(TRunnerInterface).Name}_Proxy", + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, + typeof(PipelineRunner)); + + typeBuilder.AddInterfaceImplementation(typeof(TRunnerInterface)); + + // Implement constructor(s) from base class + ImplementConstructor(typeBuilder, typeof(PipelineRunner)); + + return typeBuilder.CreateTypeInfo()!; + } + + private static void ImplementConstructor(TypeBuilder typeBuilder, Type baseType) + { + var baseCtor = baseType.GetConstructor(_parameterTypes) + ?? throw new InvalidOperationException("Expected base constructor with IServiceProvider parameter."); + + var ctorBuilder = typeBuilder.DefineConstructor( + MethodAttributes.Public, + CallingConventions.Standard, + _parameterTypes); + + var il = ctorBuilder.GetILGenerator(); + + // Call base constructor + il.Emit(OpCodes.Ldarg_0); // this + il.Emit(OpCodes.Ldarg_1); // IServiceProvider + il.Emit(OpCodes.Call, baseCtor); + il.Emit(OpCodes.Ret); + } +} +#endif diff --git a/src/PipeForge/version.json b/src/PipeForge/version.json index 071e666..e64794e 100644 --- a/src/PipeForge/version.json +++ b/src/PipeForge/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0.0", + "version": "2.1.0", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"