diff --git a/.gitignore b/.gitignore index ecbc623..ac34bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ packages/ publish/ *.sublime-workspace docs/_build/ + +Tapeti.Cmd/Properties/launchSettings.json diff --git a/Examples/01-PublishSubscribe/01-PublishSubscribe.csproj b/Examples/01-PublishSubscribe/01-PublishSubscribe.csproj new file mode 100644 index 0000000..a2d0d06 --- /dev/null +++ b/Examples/01-PublishSubscribe/01-PublishSubscribe.csproj @@ -0,0 +1,29 @@ + + + + Exe + netcoreapp2.1 + _01_PublishSubscribe + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/01-PublishSubscribe/ExampleMessageController.cs b/Examples/01-PublishSubscribe/ExampleMessageController.cs new file mode 100644 index 0000000..6cce0a1 --- /dev/null +++ b/Examples/01-PublishSubscribe/ExampleMessageController.cs @@ -0,0 +1,27 @@ +using System; +using ExampleLib; +using Messaging.TapetiExample; +using Tapeti.Annotations; + +namespace _01_PublishSubscribe +{ + [MessageController] + [DynamicQueue("tapeti.example.01")] + public class ExampleMessageController + { + private readonly IExampleState exampleState; + + + public ExampleMessageController(IExampleState exampleState) + { + this.exampleState = exampleState; + } + + + public void HandlePublishSubscribeMessage(PublishSubscribeMessage message) + { + Console.WriteLine("Received message: " + message.Greeting); + exampleState.Done(); + } + } +} diff --git a/Examples/01-PublishSubscribe/ExamplePublisher.cs b/Examples/01-PublishSubscribe/ExamplePublisher.cs new file mode 100644 index 0000000..4ebf41b --- /dev/null +++ b/Examples/01-PublishSubscribe/ExamplePublisher.cs @@ -0,0 +1,45 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Messaging.TapetiExample; +using Tapeti; + +namespace _01_PublishSubscribe +{ + public class ExamplePublisher + { + private readonly IPublisher publisher; + + /// + /// Shows that the IPublisher is registered in the container by Tapeti + /// + /// + public ExamplePublisher(IPublisher publisher) + { + this.publisher = publisher; + } + + + public async Task SendTestMessage() + { + await publisher.Publish(new PublishSubscribeMessage + { + Greeting = "Hello world of messaging!" + }); + + + // Demonstrates what happens when DataAnnotations is enabled + // and the message is invalid + try + { + await publisher.Publish(new PublishSubscribeMessage()); + + Console.WriteLine("This is not supposed to show. Did you disable the DataAnnotations extension?"); + } + catch (ValidationException e) + { + Console.WriteLine("As expected, the DataAnnotations check failed: " + e.Message); + } + } + } +} diff --git a/Examples/01-PublishSubscribe/Program.cs b/Examples/01-PublishSubscribe/Program.cs new file mode 100644 index 0000000..a53b65b --- /dev/null +++ b/Examples/01-PublishSubscribe/Program.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Autofac; +using Castle.MicroKernel.Registration; +using Castle.Windsor; +using ExampleLib; +using Ninject; +using Tapeti; +using Tapeti.Autofac; +using Tapeti.CastleWindsor; +using Tapeti.DataAnnotations; +using Tapeti.Default; +using Tapeti.Ninject; +using Tapeti.SimpleInjector; +using Tapeti.UnityContainer; +using Unity; +using Container = SimpleInjector.Container; + +namespace _01_PublishSubscribe +{ + public class Program + { + public static void Main(string[] args) + { + var dependencyResolver = GetSimpleInjectorDependencyResolver(); + + // or use your IoC container of choice: + //var dependencyResolver = GetAutofacDependencyResolver(); + //var dependencyResolver = GetCastleWindsorDependencyResolver(); + //var dependencyResolver = GetUnityDependencyResolver(); + //var dependencyResolver = GetNinjectDependencyResolver(); + + // This helper is used because this example is not run as a service. You do not + // need it in your own applications. + var helper = new ExampleConsoleApp(dependencyResolver); + helper.Run(MainAsync); + } + + + internal static async Task MainAsync(IDependencyResolver dependencyResolver, Func waitForDone) + { + var config = new TapetiConfig(dependencyResolver) + .WithDataAnnotations() + .RegisterAllControllers() + .Build(); + + using (var connection = new TapetiConnection(config) + { + // Params is optional if you want to use the defaults, but we'll set it + // explicitly for this example + Params = new TapetiConnectionParams + { + HostName = "localhost", + Username = "guest", + Password = "guest", + + // These properties allow you to identify the connection in the RabbitMQ Management interface + ClientProperties = new Dictionary + { + { "example", "01 - Publish Subscribe" } + } + } + }) + { + // IoC containers that separate the builder from the resolver (Autofac) must be built after + // creating a TapetConnection, as it modifies the container by injecting IPublisher. + (dependencyResolver as AutofacDependencyResolver)?.Build(); + + + // Create the queues and start consuming immediately. + // If you need to do some processing before processing messages, but after the + // queues have initialized, pass false as the startConsuming parameter and store + // the returned ISubscriber. Then call Resume on it later. + await connection.Subscribe(); + + + // We could get an IPublisher from the container directly, but since you'll usually use + // it as an injected constructor parameter this shows + await dependencyResolver.Resolve().SendTestMessage(); + + + // Wait for the controller to signal that the message has been received + await waitForDone(); + } + } + + + internal static IDependencyContainer GetSimpleInjectorDependencyResolver() + { + var container = new Container(); + + container.Register(); + container.Register(); + + return new SimpleInjectorDependencyResolver(container); + } + + + internal static IDependencyContainer GetAutofacDependencyResolver() + { + var containerBuilder = new ContainerBuilder(); + + containerBuilder + .RegisterType() + .As(); + + containerBuilder + .RegisterType() + .AsSelf(); + + return new AutofacDependencyResolver(containerBuilder); + } + + + internal static IDependencyContainer GetCastleWindsorDependencyResolver() + { + var container = new WindsorContainer(); + + // This exact combination is registered by TapetiConfig when running in a console, + // and Windsor will throw an exception for that. This is specific to the WindsorDependencyResolver as it + // relies on the "first one wins" behaviour of Windsor and does not check the registrations. + // + // You can of course register another ILogger instead, like DevNullLogger. + //container.Register(Component.For().ImplementedBy()); + + container.Register(Component.For()); + + return new WindsorDependencyResolver(container); + } + + + internal static IDependencyContainer GetUnityDependencyResolver() + { + var container = new UnityContainer(); + + container.RegisterType(); + container.RegisterType(); + + return new UnityDependencyResolver(container); + } + + + internal static IDependencyContainer GetNinjectDependencyResolver() + { + var kernel = new StandardKernel(); + + kernel.Bind().To(); + kernel.Bind().ToSelf(); + + return new NinjectDependencyResolver(kernel); + } + } +} diff --git a/Examples/02-DeclareDurableQueues/02-DeclareDurableQueues.csproj b/Examples/02-DeclareDurableQueues/02-DeclareDurableQueues.csproj new file mode 100644 index 0000000..c16ddf6 --- /dev/null +++ b/Examples/02-DeclareDurableQueues/02-DeclareDurableQueues.csproj @@ -0,0 +1,21 @@ + + + + Exe + netcoreapp2.1 + _02_DeclareDurableQueues + + + + + + + + + + + + + + + diff --git a/Examples/02-DeclareDurableQueues/ExampleMessageController.cs b/Examples/02-DeclareDurableQueues/ExampleMessageController.cs new file mode 100644 index 0000000..ee5266e --- /dev/null +++ b/Examples/02-DeclareDurableQueues/ExampleMessageController.cs @@ -0,0 +1,28 @@ +using System; +using ExampleLib; +using Messaging.TapetiExample; +using Tapeti.Annotations; + +namespace _02_DeclareDurableQueues +{ + [MessageController] + [DurableQueue("tapeti.example.02")] + public class ExampleMessageController + { + private readonly IExampleState exampleState; + + + public ExampleMessageController(IExampleState exampleState) + { + this.exampleState = exampleState; + } + + + public void HandlePublishSubscribeMessage(PublishSubscribeMessage message) + { + // Note that if you run example 01 after 02, it's message will also be in this durable queue + Console.WriteLine("Received message: " + message.Greeting); + exampleState.Done(); + } + } +} diff --git a/Examples/02-DeclareDurableQueues/Program.cs b/Examples/02-DeclareDurableQueues/Program.cs new file mode 100644 index 0000000..935470a --- /dev/null +++ b/Examples/02-DeclareDurableQueues/Program.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using ExampleLib; +using Messaging.TapetiExample; +using SimpleInjector; +using Tapeti; +using Tapeti.Default; +using Tapeti.SimpleInjector; + +namespace _02_DeclareDurableQueues +{ + public class Program + { + public static void Main(string[] args) + { + var container = new Container(); + var dependencyResolver = new SimpleInjectorDependencyResolver(container); + + container.Register(); + + var helper = new ExampleConsoleApp(dependencyResolver); + helper.Run(MainAsync); + } + + + internal static async Task MainAsync(IDependencyResolver dependencyResolver, Func waitForDone) + { + var config = new TapetiConfig(dependencyResolver) + .RegisterAllControllers() + .EnableDeclareDurableQueues() + .Build(); + + using (var connection = new TapetiConnection(config)) + { + // This creates or updates the durable queue + await connection.Subscribe(); + + await dependencyResolver.Resolve().Publish(new PublishSubscribeMessage + { + Greeting = "Hello durable queue!" + }); + + // Wait for the controller to signal that the message has been received + await waitForDone(); + } + } + } +} diff --git a/Examples/03-FlowRequestResponse/03-FlowRequestResponse.csproj b/Examples/03-FlowRequestResponse/03-FlowRequestResponse.csproj new file mode 100644 index 0000000..bfa9f7e --- /dev/null +++ b/Examples/03-FlowRequestResponse/03-FlowRequestResponse.csproj @@ -0,0 +1,22 @@ + + + + Exe + netcoreapp2.1 + _03_FlowRequestResponse + + + + + + + + + + + + + + + + diff --git a/Examples/03-FlowRequestResponse/ParallelFlowController.cs b/Examples/03-FlowRequestResponse/ParallelFlowController.cs new file mode 100644 index 0000000..2193302 --- /dev/null +++ b/Examples/03-FlowRequestResponse/ParallelFlowController.cs @@ -0,0 +1,73 @@ +using System; +using ExampleLib; +using Messaging.TapetiExample; +using Tapeti.Annotations; +using Tapeti.Flow; +using Tapeti.Flow.Annotations; + +namespace _03_FlowRequestResponse +{ + [MessageController] + [DynamicQueue("tapeti.example.03")] + public class ParallelFlowController + { + private readonly IFlowProvider flowProvider; + private readonly IExampleState exampleState; + + public string FirstQuote; + public string SecondQuote; + + + public ParallelFlowController(IFlowProvider flowProvider, IExampleState exampleState) + { + this.flowProvider = flowProvider; + this.exampleState = exampleState; + } + + + [Start] + public IYieldPoint StartFlow() + { + return flowProvider.YieldWithParallelRequest() + .AddRequestSync( + new QuoteRequestMessage + { + Amount = 1 + }, + HandleFirstQuoteResponse) + .AddRequestSync( + new QuoteRequestMessage + { + Amount = 2 + }, + HandleSecondQuoteResponse) + .YieldSync(AllQuotesReceived); + } + + + [Continuation] + public void HandleFirstQuoteResponse(QuoteResponseMessage message) + { + Console.WriteLine("[ParallelFlowController] First quote response received"); + FirstQuote = message.Quote; + } + + + [Continuation] + public void HandleSecondQuoteResponse(QuoteResponseMessage message) + { + Console.WriteLine("[ParallelFlowController] Second quote response received"); + SecondQuote = message.Quote; + } + + + private IYieldPoint AllQuotesReceived() + { + Console.WriteLine("[ParallelFlowController] First quote: " + FirstQuote); + Console.WriteLine("[ParallelFlowController] Second quote: " + SecondQuote); + + exampleState.Done(); + return flowProvider.End(); + } + } +} diff --git a/Examples/03-FlowRequestResponse/Program.cs b/Examples/03-FlowRequestResponse/Program.cs new file mode 100644 index 0000000..edac429 --- /dev/null +++ b/Examples/03-FlowRequestResponse/Program.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using ExampleLib; +using SimpleInjector; +using Tapeti; +using Tapeti.DataAnnotations; +using Tapeti.Default; +using Tapeti.Flow; +using Tapeti.SimpleInjector; + +namespace _03_FlowRequestResponse +{ + public class Program + { + public static void Main(string[] args) + { + var container = new Container(); + var dependencyResolver = new SimpleInjectorDependencyResolver(container); + + container.Register(); + + var helper = new ExampleConsoleApp(dependencyResolver); + helper.Run(MainAsync); + } + + + internal static async Task MainAsync(IDependencyResolver dependencyResolver, Func waitForDone) + { + var config = new TapetiConfig(dependencyResolver) + .WithDataAnnotations() + .WithFlow() + .RegisterAllControllers() + .Build(); + + + using (var connection = new TapetiConnection(config)) + { + // Must be called before using any flow. When using a persistent repository like the + // SQL server implementation, you can run any required update scripts (for example, using DbUp) + // before calling this Load method. + // Call after creating the TapetiConnection, as it modifies the container to inject IPublisher. + await dependencyResolver.Resolve().Load(); + + + await connection.Subscribe(); + + + var flowStarter = dependencyResolver.Resolve(); + + var startData = new SimpleFlowController.StartData + { + RequestStartTime = DateTime.Now, + Amount = 1 + }; + + + await flowStarter.Start(c => c.StartFlow, startData); + await flowStarter.Start(c => c.StartFlow); + + + // Wait for the controller to signal that the message has been received + await waitForDone(); + } + } + } +} diff --git a/Examples/03-FlowRequestResponse/ReceivingMessageController.cs b/Examples/03-FlowRequestResponse/ReceivingMessageController.cs new file mode 100644 index 0000000..46a265e --- /dev/null +++ b/Examples/03-FlowRequestResponse/ReceivingMessageController.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Messaging.TapetiExample; +using Tapeti.Annotations; + +namespace _03_FlowRequestResponse +{ + [MessageController] + [DynamicQueue("tapeti.example.03")] + public class ReceivingMessageController + { + // No publisher required, responses can simply be returned + public async Task HandleQuoteRequest(QuoteRequestMessage message) + { + string quote; + + switch (message.Amount) + { + case 1: + // Well, they asked for it... :-) + quote = "'"; + break; + + case 2: + quote = "\""; + break; + + default: + // We have to return a response. + quote = null; + break; + } + + // Just gonna let them wait for a bit, to demonstrate async message handlers + await Task.Delay(1000); + + return new QuoteResponseMessage + { + Quote = quote + }; + } + } +} diff --git a/Examples/03-FlowRequestResponse/SimpleFlowController.cs b/Examples/03-FlowRequestResponse/SimpleFlowController.cs new file mode 100644 index 0000000..1726e40 --- /dev/null +++ b/Examples/03-FlowRequestResponse/SimpleFlowController.cs @@ -0,0 +1,73 @@ +using System; +using ExampleLib; +using Messaging.TapetiExample; +using Tapeti.Annotations; +using Tapeti.Flow; +using Tapeti.Flow.Annotations; + +namespace _03_FlowRequestResponse +{ + [MessageController] + [DynamicQueue("tapeti.example.03")] + public class SimpleFlowController + { + private readonly IFlowProvider flowProvider; + private readonly IExampleState exampleState; + + + // Shows how multiple values can be passed to a start method + public struct StartData + { + public DateTime RequestStartTime; + public int Amount; + } + + // Private and protected fields are lost between method calls because the controller is + // recreated when a response arrives. When using a persistent flow repository this may + // even be after a restart of the application. + private bool nonPersistentState; + + + // Public fields will be stored. + public DateTime RequestStartTime; + + + public SimpleFlowController(IFlowProvider flowProvider, IExampleState exampleState) + { + this.flowProvider = flowProvider; + this.exampleState = exampleState; + } + + + [Start] + public IYieldPoint StartFlow(StartData startData) + { + nonPersistentState = true; + RequestStartTime = startData.RequestStartTime; + + return flowProvider.YieldWithRequestSync( + new QuoteRequestMessage + { + Amount = startData.Amount + }, + HandleQuoteResponse); + } + + + [Continuation] + public IYieldPoint HandleQuoteResponse(QuoteResponseMessage message) + { + if (nonPersistentState) + Console.WriteLine("[SimpleFlowController] This is not supposed to show. NonPersistentState should not be retained. Someone please check http://www.hasthelargehadroncolliderdestroyedtheworldyet.com."); + + Console.WriteLine("[SimpleFlowController] Request start: " + RequestStartTime.ToLongTimeString()); + Console.WriteLine("[SimpleFlowController] Response time: " + DateTime.Now.ToLongTimeString()); + Console.WriteLine("[SimpleFlowController] Quote: " + message.Quote); + + + exampleState.Done(); + + return flowProvider.End(); + } + } +} diff --git a/Examples/04-Transient/04-Transient.csproj b/Examples/04-Transient/04-Transient.csproj new file mode 100644 index 0000000..bb077b6 --- /dev/null +++ b/Examples/04-Transient/04-Transient.csproj @@ -0,0 +1,22 @@ + + + + Exe + netcoreapp2.1 + _04_Transient + + + + + + + + + + + + + + + + diff --git a/Examples/04-Transient/Program.cs b/Examples/04-Transient/Program.cs new file mode 100644 index 0000000..18b84f9 --- /dev/null +++ b/Examples/04-Transient/Program.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using ExampleLib; +using Messaging.TapetiExample; +using SimpleInjector; +using Tapeti; +using Tapeti.DataAnnotations; +using Tapeti.Default; +using Tapeti.SimpleInjector; +using Tapeti.Transient; + +namespace _04_Transient +{ + public class Program + { + public static void Main(string[] args) + { + var container = new Container(); + var dependencyResolver = new SimpleInjectorDependencyResolver(container); + + container.Register(); + + var helper = new ExampleConsoleApp(dependencyResolver); + helper.Run(MainAsync); + } + + + internal static async Task MainAsync(IDependencyResolver dependencyResolver, Func waitForDone) + { + var config = new TapetiConfig(dependencyResolver) + .WithDataAnnotations() + .WithTransient(TimeSpan.FromSeconds(5), "tapeti.example.04.transient") + .RegisterAllControllers() + .Build(); + + + using (var connection = new TapetiConnection(config)) + { + await connection.Subscribe(); + + + Console.WriteLine("Sending request..."); + + var transientPublisher = dependencyResolver.Resolve(); + var response = await transientPublisher.RequestResponse( + new LoggedInUsersRequestMessage()); + + Console.WriteLine("Response: " + response.Count); + + + // Unlike the other example, there is no need to call waitForDone, once we're here the response has been handled. + } + } + } +} diff --git a/Examples/04-Transient/UsersMessageController.cs b/Examples/04-Transient/UsersMessageController.cs new file mode 100644 index 0000000..5565c49 --- /dev/null +++ b/Examples/04-Transient/UsersMessageController.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using Messaging.TapetiExample; +using Tapeti.Annotations; + +namespace _04_Transient +{ + [MessageController] + [DynamicQueue("tapeti.example.04")] + public class UsersMessageController + { + // No publisher required, responses can simply be returned + public async Task HandleQuoteRequest(LoggedInUsersRequestMessage message) + { + // Simulate the response taking some time + await Task.Delay(1000); + + return new LoggedInUsersResponseMessage + { + Count = new Random().Next(0, 100) + }; + } + } +} diff --git a/Examples/05-SpeedTest/05-SpeedTest.csproj b/Examples/05-SpeedTest/05-SpeedTest.csproj new file mode 100644 index 0000000..f85333c --- /dev/null +++ b/Examples/05-SpeedTest/05-SpeedTest.csproj @@ -0,0 +1,19 @@ + + + + Exe + netcoreapp2.1 + _05_SpeedTest + + + + + + + + + + + + + diff --git a/Examples/05-SpeedTest/IMessageCounter.cs b/Examples/05-SpeedTest/IMessageCounter.cs new file mode 100644 index 0000000..e5e9aaf --- /dev/null +++ b/Examples/05-SpeedTest/IMessageCounter.cs @@ -0,0 +1,7 @@ +namespace _05_SpeedTest +{ + public interface IMessageCounter + { + void Add(); + } +} diff --git a/Examples/05-SpeedTest/Program.cs b/Examples/05-SpeedTest/Program.cs new file mode 100644 index 0000000..6c399d7 --- /dev/null +++ b/Examples/05-SpeedTest/Program.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using ExampleLib; +using Messaging.TapetiExample; +using SimpleInjector; +using Tapeti; +using Tapeti.Default; +using Tapeti.SimpleInjector; + +namespace _05_SpeedTest +{ + public class Program + { + private const int MessageCount = 20000; + + // This does not make a massive difference, since internally Tapeti uses a single thread + // to perform all channel operations as recommended by the RabbitMQ .NET client library. + private const int ConcurrentTasks = 20; + + + public static void Main(string[] args) + { + var container = new Container(); + var dependencyResolver = new SimpleInjectorDependencyResolver(container); + + container.Register(); + + var helper = new ExampleConsoleApp(dependencyResolver); + helper.Run(MainAsync); + } + + + internal static async Task MainAsync(IDependencyResolver dependencyResolver, Func waitForDone) + { + var container = (IDependencyContainer)dependencyResolver; + container.RegisterDefaultSingleton(new MessageCounter(MessageCount, () => + { + var exampleState = dependencyResolver.Resolve(); + exampleState.Done(); + })); + + + + var config = new TapetiConfig(dependencyResolver) + // On a developer test machine, this makes the difference between 2200 messages/sec and 3000 messages/sec published. + // Interesting, but only if speed is more important than guaranteed delivery. + //.DisablePublisherConfirms() + .RegisterAllControllers() + .Build(); + + + using (var connection = new TapetiConnection(config)) + { + var subscriber = await connection.Subscribe(false); + + + var publisher = dependencyResolver.Resolve(); + Console.WriteLine($"Publishing {MessageCount} messages..."); + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + await PublishMessages(publisher); + + stopwatch.Stop(); + Console.WriteLine($"Took {stopwatch.ElapsedMilliseconds} ms, {MessageCount / (stopwatch.ElapsedMilliseconds / 1000F):F0} messages/sec"); + + + + Console.WriteLine("Consuming messages..."); + await subscriber.Resume(); + + stopwatch.Restart(); + + await waitForDone(); + + stopwatch.Stop(); + Console.WriteLine($"Took {stopwatch.ElapsedMilliseconds} ms, {MessageCount / (stopwatch.ElapsedMilliseconds / 1000F):F0} messages/sec"); + } + } + + + internal static async Task PublishMessages(IPublisher publisher) + { + var semaphore = new SemaphoreSlim(ConcurrentTasks); + var tasks = new List(); + + for (var i = 0; i < MessageCount; i++) + { + var item = i; + var task = Task.Run(async () => + { + try + { + await semaphore.WaitAsync(); + await publisher.Publish(new SpeedTestMessage + { + PublishCount = item + }); + } + finally + { + semaphore.Release(); + } + }); + + tasks.Add(task); + } + + await Task.WhenAll(tasks); + } + } + + + internal class MessageCounter : IMessageCounter + { + private readonly int max; + private readonly Action done; + private int count; + + + public MessageCounter(int max, Action done) + { + this.max = max; + this.done = done; + } + + + public void Add() + { + // With a prefetchcount > 1 the consumers are running in multiple threads, + // beware of this when using singletons. + if (Interlocked.Increment(ref count) == max) + done(); + } + } +} diff --git a/Examples/05-SpeedTest/SpeedMessageController.cs b/Examples/05-SpeedTest/SpeedMessageController.cs new file mode 100644 index 0000000..83af176 --- /dev/null +++ b/Examples/05-SpeedTest/SpeedMessageController.cs @@ -0,0 +1,23 @@ +using Messaging.TapetiExample; +using Tapeti.Annotations; + +namespace _05_SpeedTest +{ + [MessageController] + [DynamicQueue("tapeti.example.05")] + public class SpeedMessageController + { + private readonly IMessageCounter messageCounter; + + public SpeedMessageController(IMessageCounter messageCounter) + { + this.messageCounter = messageCounter; + } + + + public void HandleSpeedTestMessage(SpeedTestMessage message) + { + messageCounter.Add(); + } + } +} diff --git a/Examples/ExampleLib/ExampleConsoleApp.cs b/Examples/ExampleLib/ExampleConsoleApp.cs new file mode 100644 index 0000000..f7f4d98 --- /dev/null +++ b/Examples/ExampleLib/ExampleConsoleApp.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading.Tasks; +using Tapeti; + +namespace ExampleLib +{ + /// + /// Callback method for ExampleConsoleApp.Run + /// + /// A reference to the dependency resolver passed to the ExampleConsoleApp + /// Await this function to wait for the Done signal + public delegate Task AsyncFunc(IDependencyResolver dependencyResolver, Func waitForDone); + + + /// + /// Since the examples do not run as a service, we need to know when the example has run + /// to completion. This helper injects IExampleState into the container which + /// can be used to signal that it has finished. It also provides the Wait + /// method to wait for this signal. + /// + public class ExampleConsoleApp + { + private readonly IDependencyContainer dependencyResolver; + private readonly TaskCompletionSource doneSignal = new TaskCompletionSource(); + + + /// Uses Tapeti's IDependencyContainer interface so you can easily switch an example to your favourite IoC container + public ExampleConsoleApp(IDependencyContainer dependencyResolver) + { + this.dependencyResolver = dependencyResolver; + dependencyResolver.RegisterDefault(() => new ExampleState(this)); + } + + + /// + /// Runs the specified async method and waits for completion. Handles exceptions and waiting + /// for user input when the example application finishes. + /// + /// + public void Run(AsyncFunc asyncFunc) + { + try + { + asyncFunc(dependencyResolver, WaitAsync).Wait(); + } + catch (Exception e) + { + Console.WriteLine(UnwrapException(e)); + } + finally + { + Console.WriteLine("Press any Enter key to continue..."); + Console.ReadLine(); + } + } + + + /// + /// Returns a Task which completed when IExampleState.Done is called + /// + public async Task WaitAsync() + { + await doneSignal.Task; + + // This is a hack, because the signal is often given in a message handler before the message can be + // acknowledged, causing it to be put back on the queue because the connection is closed. + // This short delay allows consumers to finish. This is not an issue in a proper service application. + await Task.Delay(500); + } + + + internal Exception UnwrapException(Exception e) + { + while (true) + { + if (!(e is AggregateException aggregateException)) + return e; + + if (aggregateException.InnerExceptions.Count != 1) + return e; + + e = aggregateException.InnerExceptions[0]; + } + } + + internal void Done() + { + doneSignal.TrySetResult(true); + } + + + private class ExampleState : IExampleState + { + private readonly ExampleConsoleApp owner; + + + public ExampleState(ExampleConsoleApp owner) + { + this.owner = owner; + } + + + public void Done() + { + owner.Done(); + } + } + } +} diff --git a/Examples/ExampleLib/ExampleLib.csproj b/Examples/ExampleLib/ExampleLib.csproj new file mode 100644 index 0000000..d73eb4a --- /dev/null +++ b/Examples/ExampleLib/ExampleLib.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + true + + + + + + + diff --git a/Examples/ExampleLib/IExampleState.cs b/Examples/ExampleLib/IExampleState.cs new file mode 100644 index 0000000..8f47b22 --- /dev/null +++ b/Examples/ExampleLib/IExampleState.cs @@ -0,0 +1,14 @@ +namespace ExampleLib +{ + /// + /// Since the examples do not run as a service, this interface provides a way + /// for the implementation to signal that it has finished and the example can be closed. + /// + public interface IExampleState + { + /// + /// Signals the Program that the example has finished and the application can be closed. + /// + void Done(); + } +} diff --git a/Examples/Messaging.TapetiExample/LoggedInUsersRequestMessage.cs b/Examples/Messaging.TapetiExample/LoggedInUsersRequestMessage.cs new file mode 100644 index 0000000..5fe285d --- /dev/null +++ b/Examples/Messaging.TapetiExample/LoggedInUsersRequestMessage.cs @@ -0,0 +1,15 @@ +using Tapeti.Annotations; + +namespace Messaging.TapetiExample +{ + [Request(Response = typeof(LoggedInUsersResponseMessage))] + public class LoggedInUsersRequestMessage + { + } + + + public class LoggedInUsersResponseMessage + { + public int Count { get; set; } + } +} diff --git a/Examples/Messaging.TapetiExample/Messaging.TapetiExample.csproj b/Examples/Messaging.TapetiExample/Messaging.TapetiExample.csproj new file mode 100644 index 0000000..cc9631d --- /dev/null +++ b/Examples/Messaging.TapetiExample/Messaging.TapetiExample.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + + + + + + + + + + + diff --git a/Examples/Messaging.TapetiExample/PublishSubscribeMessage.cs b/Examples/Messaging.TapetiExample/PublishSubscribeMessage.cs new file mode 100644 index 0000000..b378698 --- /dev/null +++ b/Examples/Messaging.TapetiExample/PublishSubscribeMessage.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Messaging.TapetiExample +{ + /// + /// Example of a simple broadcast message used in the standard publish - subscribe pattern + /// + public class PublishSubscribeMessage + { + [Required(ErrorMessage = "Don't be impolite, supply a {0}")] + public string Greeting { get; set; } + } +} diff --git a/Examples/Messaging.TapetiExample/QuoteRequestMessage.cs b/Examples/Messaging.TapetiExample/QuoteRequestMessage.cs new file mode 100644 index 0000000..a67367c --- /dev/null +++ b/Examples/Messaging.TapetiExample/QuoteRequestMessage.cs @@ -0,0 +1,16 @@ +using Tapeti.Annotations; + +namespace Messaging.TapetiExample +{ + [Request(Response = typeof(QuoteResponseMessage))] + public class QuoteRequestMessage + { + public int Amount { get; set; } + } + + + public class QuoteResponseMessage + { + public string Quote { get; set; } + } +} diff --git a/Examples/Messaging.TapetiExample/SpeedTestMessage.cs b/Examples/Messaging.TapetiExample/SpeedTestMessage.cs new file mode 100644 index 0000000..08e03de --- /dev/null +++ b/Examples/Messaging.TapetiExample/SpeedTestMessage.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Messaging.TapetiExample +{ + public class SpeedTestMessage + { + public int PublishCount { get; set; } + } +} diff --git a/README.md b/README.md index c35e158..827d424 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,58 @@ +## Introduction +Tapeti is a wrapper for the RabbitMQ .NET Client designed for long-running microservices. It’s main goal is to minimize the amount of messaging code required, and instead focus on the higher-level flow. + +## Key features +* Consumers are declared using MVC-style controllers and are registered automatically based on annotations +* Publishing requires only the message class, no transport details such as exchange and routing key +* Flow extension (stateful request - response handling with support for parallel requests) +* No inheritance required +* Graceful recovery in case of connection issues, and in contrast to most libraries not designed for services, during startup as well +* Extensible using middleware + +## Show me the code! +Below is a bare minimum message controller from the first example project to get a feel for how messages are handled using Tapeti. +```csharp +/// +/// Example of a simple broadcast message used in the standard publish - subscribe pattern +/// +public class PublishSubscribeMessage +{ + [Required(ErrorMessage = "Don't be impolite, supply a {0}")] + public string Greeting { get; set; } +} + + +[MessageController] +[DynamicQueue("tapeti.example.01")] +public class ExampleMessageController +{ + public ExampleMessageController() { } + + public void HandlePublishSubscribeMessage(PublishSubscribeMessage message) + { + Console.WriteLine("Received message: " + message.Greeting); + } +} +``` + +More details and examples can be found in the documentation as well as the example projects included with the source. + ## Documentation The documentation for Tapeti is available on Read the Docs: -[Develop branch](http://tapeti.readthedocs.io/en/latest/)
-[![Documentation Status](https://readthedocs.org/projects/tapeti/badge/?version=latest)](http://tapeti.readthedocs.io/en/latest/?badge=latest) +[Master branch (stable release)](http://tapeti.readthedocs.io/en/stable/introduction.html)
+[![Documentation Status](https://readthedocs.org/projects/tapeti/badge/?version=stable)](http://tapeti.readthedocs.io/en/stable/introduction.html?badge=stable) + +[Develop branch](http://tapeti.readthedocs.io/en/latest/introduction.html)
+[![Documentation Status](https://readthedocs.org/projects/tapeti/badge/?version=latest)](http://tapeti.readthedocs.io/en/latest/introduction.html?badge=latest) -[Master branch](http://tapeti.readthedocs.io/en/stable/)
-[![Documentation Status](https://readthedocs.org/projects/tapeti/badge/?version=stable)](http://tapeti.readthedocs.io/en/stable/?badge=stable) ## Builds Builds are automatically run using AppVeyor, with the resulting packages being pushed to NuGet. +Master build (stable release) +[![Build status](https://ci.appveyor.com/api/projects/status/cyuo0vm7admy0d9x/branch/master?svg=true)](https://ci.appveyor.com/project/MvRens/tapeti/branch/master) Latest build -[![Build status](https://ci.appveyor.com/api/projects/status/cyuo0vm7admy0d9x?svg=true)](https://ci.appveyor.com/project/MvRens/tapeti) - -Master build -[![Build status](https://ci.appveyor.com/api/projects/status/cyuo0vm7admy0d9x/branch/master?svg=true)](https://ci.appveyor.com/project/MvRens/tapeti/branch/master) +[![Build status](https://ci.appveyor.com/api/projects/status/cyuo0vm7admy0d9x?svg=true)](https://ci.appveyor.com/project/MvRens/tapeti) \ No newline at end of file diff --git a/Tapeti.Annotations/DurableQueueAttribute.cs b/Tapeti.Annotations/DurableQueueAttribute.cs index 8971044..ae99278 100644 --- a/Tapeti.Annotations/DurableQueueAttribute.cs +++ b/Tapeti.Annotations/DurableQueueAttribute.cs @@ -8,11 +8,6 @@ namespace Tapeti.Annotations /// Binds to an existing durable queue to receive messages. Can be used /// on an entire MessageController class or on individual methods. /// - /// - /// At the moment there is no support for creating a durable queue and managing the - /// bindings. The author recommends https://git.x2software.net/pub/RabbitMetaQueue - /// for deploy-time management of durable queues (shameless plug intended). - /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [MeansImplicitUse(ImplicitUseTargetFlags.WithMembers)] public class DurableQueueAttribute : Attribute diff --git a/Tapeti.Annotations/ReSharper/JetBrains.Annotations.cs b/Tapeti.Annotations/ReSharper/JetBrains.Annotations.cs index 9ec2401..2790b2e 100644 --- a/Tapeti.Annotations/ReSharper/JetBrains.Annotations.cs +++ b/Tapeti.Annotations/ReSharper/JetBrains.Annotations.cs @@ -30,6 +30,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ using System; +// ReSharper disable InheritdocConsiderUsage #pragma warning disable 1591 // ReSharper disable UnusedMember.Global @@ -96,9 +97,9 @@ namespace JetBrains.Annotations TargetFlags = targetFlags; } - public ImplicitUseKindFlags UseKindFlags { get; private set; } + public ImplicitUseKindFlags UseKindFlags { get; } - public ImplicitUseTargetFlags TargetFlags { get; private set; } + public ImplicitUseTargetFlags TargetFlags { get; } } /// @@ -142,7 +143,7 @@ namespace JetBrains.Annotations /// InstantiatedWithFixedConstructorSignature = 4, /// Indicates implicit instantiation of a type. - InstantiatedNoFixedConstructorSignature = 8, + InstantiatedNoFixedConstructorSignature = 8 } /// @@ -174,6 +175,6 @@ namespace JetBrains.Annotations Comment = comment; } - [CanBeNull] public string Comment { get; private set; } + [CanBeNull] public string Comment { get; } } } \ No newline at end of file diff --git a/Tapeti.Annotations/RequestAttribute.cs b/Tapeti.Annotations/RequestAttribute.cs index f298c50..7f7a6a9 100644 --- a/Tapeti.Annotations/RequestAttribute.cs +++ b/Tapeti.Annotations/RequestAttribute.cs @@ -7,7 +7,7 @@ namespace Tapeti.Annotations /// Can be attached to a message class to specify that the receiver of the message must /// provide a response message of the type specified in the Response attribute. This response /// must be sent by either returning it from the message handler method or using - /// YieldWithResponse when using Tapeti Flow. These methods will respond directly + /// EndWithResponse when using Tapeti Flow. These methods will respond directly /// to the queue specified in the reply-to header automatically added by Tapeti. /// [AttributeUsage(AttributeTargets.Class)] diff --git a/Tapeti.Annotations/Tapeti.Annotations.csproj b/Tapeti.Annotations/Tapeti.Annotations.csproj index be5c9ef..5c1ef0d 100644 --- a/Tapeti.Annotations/Tapeti.Annotations.csproj +++ b/Tapeti.Annotations/Tapeti.Annotations.csproj @@ -3,6 +3,11 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 diff --git a/Tapeti.Annotations/Tapeti.Annotations.nuspec b/Tapeti.Annotations/Tapeti.Annotations.nuspec index 68e8268..87e40c7 100644 --- a/Tapeti.Annotations/Tapeti.Annotations.nuspec +++ b/Tapeti.Annotations/Tapeti.Annotations.nuspec @@ -6,7 +6,7 @@ Tapeti Annotations Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.Annotations.png false diff --git a/Tapeti.Autofac/AutofacDependencyResolver.cs b/Tapeti.Autofac/AutofacDependencyResolver.cs new file mode 100644 index 0000000..cf6cee8 --- /dev/null +++ b/Tapeti.Autofac/AutofacDependencyResolver.cs @@ -0,0 +1,148 @@ +using System; +using Autofac; +using Autofac.Builder; + +namespace Tapeti.Autofac +{ + /// + /// + /// Dependency resolver and container implementation for Autofac. + /// Since this class needs access to both the ContainerBuilder and the built IContainer, + /// either let AutofacDependencyResolver build the container by calling it's Build method, + /// or set the Container property manually. + /// + public class AutofacDependencyResolver : IDependencyContainer + { + private ContainerBuilder containerBuilder; + private IContainer container; + + + /// + /// The built container. Either set directly, or use the Build method to built the + /// update this reference. + /// + public IContainer Container + { + get => container; + set + { + container = value; + if (value != null) + containerBuilder = null; + } + } + + + /// + public AutofacDependencyResolver(ContainerBuilder containerBuilder) + { + this.containerBuilder = containerBuilder; + } + + + /// + /// Builds the container, updates the Container property and returns the newly built IContainer. + /// + public IContainer Build(ContainerBuildOptions options = ContainerBuildOptions.None) + { + CheckContainerBuilder(); + Container = containerBuilder.Build(options); + + return container; + } + + + /// + public T Resolve() where T : class + { + CheckContainer(); + return Container.Resolve(); + } + + /// + public object Resolve(Type type) + { + CheckContainer(); + return Container.Resolve(type); + } + + + /// + public void RegisterDefault() where TService : class where TImplementation : class, TService + { + CheckContainerBuilder(); + containerBuilder + .RegisterType() + .As() + .PreserveExistingDefaults(); + } + + /// + public void RegisterDefault(Func factory) where TService : class + { + CheckContainerBuilder(); + containerBuilder + .Register(context => factory()) + .As() + .PreserveExistingDefaults(); + } + + + /// + public void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService + { + CheckContainerBuilder(); + containerBuilder + .RegisterType() + .As() + .SingleInstance() + .PreserveExistingDefaults(); + } + + /// + public void RegisterDefaultSingleton(TService instance) where TService : class + { + CheckContainerBuilder(); + containerBuilder + .RegisterInstance(instance) + .As() + .SingleInstance() + .PreserveExistingDefaults(); + } + + /// + public void RegisterDefaultSingleton(Func factory) where TService : class + { + CheckContainerBuilder(); + containerBuilder + .Register(context => factory()) + .As() + .SingleInstance() + .PreserveExistingDefaults(); + } + + + /// + public void RegisterController(Type type) + { + CheckContainerBuilder(); + containerBuilder + .RegisterType(type) + .AsSelf(); + } + + + private void CheckContainer() + { + if (container == null) + throw new InvalidOperationException("Container property has not been set yet on AutofacDependencyResolver"); + } + + + private void CheckContainerBuilder() + { + if (containerBuilder == null) + throw new InvalidOperationException("Container property has already been set on AutofacDependencyResolver"); + } + } +} diff --git a/Tapeti.Autofac/Tapeti.Autofac.csproj b/Tapeti.Autofac/Tapeti.Autofac.csproj new file mode 100644 index 0000000..4aabb0c --- /dev/null +++ b/Tapeti.Autofac/Tapeti.Autofac.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + true + 2.0.0 + + + + + + + + + + + diff --git a/Tapeti.Autofac/Tapeti.Autofac.nuspec b/Tapeti.Autofac/Tapeti.Autofac.nuspec new file mode 100644 index 0000000..d788f49 --- /dev/null +++ b/Tapeti.Autofac/Tapeti.Autofac.nuspec @@ -0,0 +1,24 @@ + + + + Tapeti.Autofac + $version$ + Tapeti Autofac + Mark van Renswoude + Mark van Renswoude + Unlicense + https://github.com/MvRens/Tapeti + https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.SimpleInjector.png + false + Autofac integration package for Tapeti + + rabbitmq tapeti autofac + + + + + + + + + \ No newline at end of file diff --git a/Tapeti.CastleWindsor/Tapeti.CastleWindsor.csproj b/Tapeti.CastleWindsor/Tapeti.CastleWindsor.csproj new file mode 100644 index 0000000..2c81fe5 --- /dev/null +++ b/Tapeti.CastleWindsor/Tapeti.CastleWindsor.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + true + 2.0.0 + + + + + + + + + + + diff --git a/Tapeti.CastleWindsor/Tapeti.CastleWindsor.nuspec b/Tapeti.CastleWindsor/Tapeti.CastleWindsor.nuspec new file mode 100644 index 0000000..ac652ed --- /dev/null +++ b/Tapeti.CastleWindsor/Tapeti.CastleWindsor.nuspec @@ -0,0 +1,24 @@ + + + + Tapeti.CastleWindsor + $version$ + Tapeti Castle Windsor + Mark van Renswoude + Mark van Renswoude + Unlicense + https://github.com/MvRens/Tapeti + https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.SimpleInjector.png + false + Castle.Windsor integration package for Tapeti + + rabbitmq tapeti castle windsor + + + + + + + + + \ No newline at end of file diff --git a/Tapeti.CastleWindsor/WindsorDependencyResolver.cs b/Tapeti.CastleWindsor/WindsorDependencyResolver.cs new file mode 100644 index 0000000..419d115 --- /dev/null +++ b/Tapeti.CastleWindsor/WindsorDependencyResolver.cs @@ -0,0 +1,98 @@ +using System; +using Castle.MicroKernel.Registration; +using Castle.Windsor; + +namespace Tapeti.CastleWindsor +{ + /// + /// + /// Dependency resolver and container implementation for Castle Windsor. + /// + public class WindsorDependencyResolver : IDependencyContainer + { + private readonly IWindsorContainer container; + + + /// + public WindsorDependencyResolver(IWindsorContainer container) + { + this.container = container; + } + + + /// + public T Resolve() where T : class + { + return container.Resolve(); + } + + /// + public object Resolve(Type type) + { + return container.Resolve(type); + } + + + /// + public void RegisterDefault() where TService : class where TImplementation : class, TService + { + // No need for anything special to register as default, because "In Windsor first one wins": + // https://github.com/castleproject/Windsor/blob/master/docs/registering-components-one-by-one.md + container.Register( + Component + .For() + .ImplementedBy() + ); + } + + /// + public void RegisterDefault(Func factory) where TService : class + { + container.Register( + Component + .For() + .UsingFactoryMethod(() => factory()) + ); + } + + + /// + public void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService + { + container.Register( + Component + .For() + .ImplementedBy() + .LifestyleSingleton() + ); + } + + /// + public void RegisterDefaultSingleton(TService instance) where TService : class + { + container.Register( + Component + .For() + .Instance(instance) + ); + } + + /// + public void RegisterDefaultSingleton(Func factory) where TService : class + { + container.Register( + Component + .For() + .UsingFactoryMethod(() => factory()) + .LifestyleSingleton() + ); + } + + + /// + public void RegisterController(Type type) + { + container.Register(Component.For(type)); + } + } +} diff --git a/Tapeti.Cmd/Commands/ExportCommand.cs b/Tapeti.Cmd/Commands/ExportCommand.cs new file mode 100644 index 0000000..2f69aa4 --- /dev/null +++ b/Tapeti.Cmd/Commands/ExportCommand.cs @@ -0,0 +1,46 @@ +using RabbitMQ.Client; +using Tapeti.Cmd.Serialization; + +namespace Tapeti.Cmd.Commands +{ + public class ExportCommand + { + public IMessageSerializer MessageSerializer { get; set; } + + public string QueueName { get; set; } + public bool RemoveMessages { get; set; } + public int? MaxCount { get; set; } + + + public int Execute(IModel channel) + { + var messageCount = 0; + + while (!MaxCount.HasValue || messageCount < MaxCount.Value) + { + var result = channel.BasicGet(QueueName, false); + if (result == null) + // No more messages on the queue + break; + + messageCount++; + + MessageSerializer.Serialize(new Message + { + DeliveryTag = result.DeliveryTag, + Redelivered = result.Redelivered, + Exchange = result.Exchange, + RoutingKey = result.RoutingKey, + Queue = QueueName, + Properties = result.BasicProperties, + Body = result.Body + }); + + if (RemoveMessages) + channel.BasicAck(result.DeliveryTag, false); + } + + return messageCount; + } + } +} diff --git a/Tapeti.Cmd/Commands/ImportCommand.cs b/Tapeti.Cmd/Commands/ImportCommand.cs new file mode 100644 index 0000000..ccdf308 --- /dev/null +++ b/Tapeti.Cmd/Commands/ImportCommand.cs @@ -0,0 +1,29 @@ +using RabbitMQ.Client; +using Tapeti.Cmd.Serialization; + +namespace Tapeti.Cmd.Commands +{ + public class ImportCommand + { + public IMessageSerializer MessageSerializer { get; set; } + + public bool DirectToQueue { get; set; } + + + public int Execute(IModel channel) + { + var messageCount = 0; + + foreach (var message in MessageSerializer.Deserialize()) + { + var exchange = DirectToQueue ? "" : message.Exchange; + var routingKey = DirectToQueue ? message.Queue : message.RoutingKey; + + channel.BasicPublish(exchange, routingKey, message.Properties, message.Body); + messageCount++; + } + + return messageCount; + } + } +} diff --git a/Tapeti.Cmd/Commands/ShovelCommand.cs b/Tapeti.Cmd/Commands/ShovelCommand.cs new file mode 100644 index 0000000..9b42a3a --- /dev/null +++ b/Tapeti.Cmd/Commands/ShovelCommand.cs @@ -0,0 +1,37 @@ +using RabbitMQ.Client; + +namespace Tapeti.Cmd.Commands +{ + public class ShovelCommand + { + public string QueueName { get; set; } + public string TargetQueueName { get; set; } + public bool RemoveMessages { get; set; } + public int? MaxCount { get; set; } + + + public int Execute(IModel sourceChannel, IModel targetChannel) + { + var messageCount = 0; + + while (!MaxCount.HasValue || messageCount < MaxCount.Value) + { + var result = sourceChannel.BasicGet(QueueName, false); + if (result == null) + // No more messages on the queue + break; + + + targetChannel.BasicPublish("", TargetQueueName, result.BasicProperties, result.Body); + + + messageCount++; + + if (RemoveMessages) + sourceChannel.BasicAck(result.DeliveryTag, false); + } + + return messageCount; + } + } +} diff --git a/Tapeti.Cmd/Program.cs b/Tapeti.Cmd/Program.cs new file mode 100644 index 0000000..5088586 --- /dev/null +++ b/Tapeti.Cmd/Program.cs @@ -0,0 +1,293 @@ +using System; +using System.Diagnostics; +using CommandLine; +using RabbitMQ.Client; +using Tapeti.Cmd.Commands; +using Tapeti.Cmd.Serialization; + +namespace Tapeti.Cmd +{ + public class Program + { + public class CommonOptions + { + [Option('h', "host", HelpText = "Hostname of the RabbitMQ server.", Default = "localhost")] + public string Host { get; set; } + + [Option('p', "port", HelpText = "AMQP port of the RabbitMQ server.", Default = 5672)] + public int Port { get; set; } + + [Option('v', "virtualhost", HelpText = "Virtual host used for the RabbitMQ connection.", Default = "/")] + public string VirtualHost { get; set; } + + [Option('u', "username", HelpText = "Username used to connect to the RabbitMQ server.", Default = "guest")] + public string Username { get; set; } + + [Option('p', "password", HelpText = "Password used to connect to the RabbitMQ server.", Default = "guest")] + public string Password { get; set; } + } + + + public enum SerializationMethod + { + SingleFileJSON, + EasyNetQHosepipe + } + + + public class MessageSerializerOptions : CommonOptions + { + [Option('s', "serialization", HelpText = "The method used to serialize the message for import or export. Valid options: SingleFileJSON, EasyNetQHosepipe.", Default = SerializationMethod.SingleFileJSON)] + public SerializationMethod SerializationMethod { get; set; } + } + + + + [Verb("export", HelpText = "Fetch messages from a queue and write it to disk.")] + public class ExportOptions : MessageSerializerOptions + { + [Option('q', "queue", Required = true, HelpText = "The queue to read the messages from.")] + public string QueueName { get; set; } + + [Option('o', "output", Required = true, HelpText = "Path or filename (depending on the chosen serialization method) where the messages will be output to.")] + public string OutputPath { get; set; } + + [Option('r', "remove", HelpText = "If specified messages are acknowledged and removed from the queue. If not messages are kept.")] + public bool RemoveMessages { get; set; } + + [Option('n', "maxcount", HelpText = "(Default: all) Maximum number of messages to retrieve from the queue.")] + public int? MaxCount { get; set; } + } + + + [Verb("import", HelpText = "Read messages from disk as previously exported and publish them to a queue.")] + public class ImportOptions : MessageSerializerOptions + { + [Option('i', "input", Required = true, HelpText = "Path or filename (depending on the chosen serialization method) where the messages will be read from.")] + public string Input { get; set; } + + [Option('e', "exchange", HelpText = "If specified publishes to the originating exchange using the original routing key. By default these are ignored and the message is published directly to the originating queue.")] + public bool PublishToExchange { get; set; } + } + + + [Verb("shovel", HelpText = "Reads messages from a queue and publishes them to another queue, optionally to another RabbitMQ server.")] + public class ShovelOptions : CommonOptions + { + [Option('q', "queue", Required = true, HelpText = "The queue to read the messages from.")] + public string QueueName { get; set; } + + [Option('t', "targetqueue", HelpText = "The target queue to publish the messages to. Defaults to the source queue if a different target host, port or virtualhost is specified. Otherwise it must be different from the source queue.")] + public string TargetQueueName { get; set; } + + [Option('r', "remove", HelpText = "If specified messages are acknowledged and removed from the source queue. If not messages are kept.")] + public bool RemoveMessages { get; set; } + + [Option('n', "maxcount", HelpText = "(Default: all) Maximum number of messages to retrieve from the queue.")] + public int? MaxCount { get; set; } + + [Option("targethost", HelpText = "Hostname of the target RabbitMQ server. Defaults to the source host. Note that you may still specify a different targetusername for example.")] + public string TargetHost { get; set; } + + [Option("targetport", HelpText = "AMQP port of the target RabbitMQ server. Defaults to the source port.")] + public int? TargetPort { get; set; } + + [Option("targetvirtualhost", HelpText = "Virtual host used for the target RabbitMQ connection. Defaults to the source virtualhost.")] + public string TargetVirtualHost { get; set; } + + [Option("targetusername", HelpText = "Username used to connect to the target RabbitMQ server. Defaults to the source username.")] + public string TargetUsername { get; set; } + + [Option("targetpassword", HelpText = "Password used to connect to the target RabbitMQ server. Defaults to the source password.")] + public string TargetPassword { get; set; } + } + + + + + public static int Main(string[] args) + { + return Parser.Default.ParseArguments(args) + .MapResult( + (ExportOptions o) => ExecuteVerb(o, RunExport), + (ImportOptions o) => ExecuteVerb(o, RunImport), + (ShovelOptions o) => ExecuteVerb(o, RunShovel), + errs => + { + if (!Debugger.IsAttached) + return 1; + + Console.WriteLine("Press any Enter key to continue..."); + Console.ReadLine(); + return 1; + } + ); + } + + + private static int ExecuteVerb(T options, Action execute) where T : class + { + try + { + execute(options); + return 0; + } + catch (Exception e) + { + Console.WriteLine(e.Message); + return 1; + } + } + + + private static IConnection GetConnection(CommonOptions options) + { + var factory = new ConnectionFactory + { + HostName = options.Host, + Port = options.Port, + VirtualHost = options.VirtualHost, + UserName = options.Username, + Password = options.Password + }; + + return factory.CreateConnection(); + } + + + private static IMessageSerializer GetMessageSerializer(MessageSerializerOptions options, string path) + { + switch (options.SerializationMethod) + { + case SerializationMethod.SingleFileJSON: + return new SingleFileJSONMessageSerializer(path); + + case SerializationMethod.EasyNetQHosepipe: + return new EasyNetQMessageSerializer(path); + + default: + throw new ArgumentOutOfRangeException(nameof(options.SerializationMethod), options.SerializationMethod, "Invalid SerializationMethod"); + } + } + + + private static void RunExport(ExportOptions options) + { + int messageCount; + + using (var messageSerializer = GetMessageSerializer(options, options.OutputPath)) + using (var connection = GetConnection(options)) + using (var channel = connection.CreateModel()) + { + messageCount = new ExportCommand + { + MessageSerializer = messageSerializer, + + QueueName = options.QueueName, + RemoveMessages = options.RemoveMessages, + MaxCount = options.MaxCount + }.Execute(channel); + } + + Console.WriteLine($"{messageCount} message{(messageCount != 1 ? "s" : "")} exported."); + } + + + private static void RunImport(ImportOptions options) + { + int messageCount; + + using (var messageSerializer = GetMessageSerializer(options, options.Input)) + using (var connection = GetConnection(options)) + using (var channel = connection.CreateModel()) + { + messageCount = new ImportCommand + { + MessageSerializer = messageSerializer, + + DirectToQueue = !options.PublishToExchange + }.Execute(channel); + } + + Console.WriteLine($"{messageCount} message{(messageCount != 1 ? "s" : "")} published."); + } + + + private static void RunShovel(ShovelOptions options) + { + int messageCount; + + using (var sourceConnection = GetConnection(options)) + using (var sourceChannel = sourceConnection.CreateModel()) + { + var shovelCommand = new ShovelCommand + { + QueueName = options.QueueName, + TargetQueueName = !string.IsNullOrEmpty(options.TargetQueueName) ? options.TargetQueueName : options.QueueName, + RemoveMessages = options.RemoveMessages, + MaxCount = options.MaxCount + }; + + + if (RequiresSecondConnection(options)) + { + using (var targetConnection = GetTargetConnection(options)) + using (var targetChannel = targetConnection.CreateModel()) + { + messageCount = shovelCommand.Execute(sourceChannel, targetChannel); + } + } + else + messageCount = shovelCommand.Execute(sourceChannel, sourceChannel); + } + + Console.WriteLine($"{messageCount} message{(messageCount != 1 ? "s" : "")} shoveled."); + } + + + private static bool RequiresSecondConnection(ShovelOptions options) + { + if (!string.IsNullOrEmpty(options.TargetHost) && options.TargetHost != options.Host) + return true; + + if (options.TargetPort.HasValue && options.TargetPort.Value != options.Port) + return true; + + if (!string.IsNullOrEmpty(options.TargetVirtualHost) && options.TargetVirtualHost != options.VirtualHost) + return true; + + + // All relevant target host parameters are either omitted or the same. This means the queue must be different + // to prevent an infinite loop. + if (string.IsNullOrEmpty(options.TargetQueueName) || options.TargetQueueName == options.QueueName) + throw new ArgumentException("Target queue must be different from the source queue when shoveling within the same (virtual) host"); + + + if (!string.IsNullOrEmpty(options.TargetUsername) && options.TargetUsername != options.Username) + return true; + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (!string.IsNullOrEmpty(options.TargetPassword) && options.TargetPassword != options.Password) + return true; + + + // Everything's the same, we can use the same channel + return false; + } + + + private static IConnection GetTargetConnection(ShovelOptions options) + { + var factory = new ConnectionFactory + { + HostName = !string.IsNullOrEmpty(options.TargetHost) ? options.TargetHost : options.Host, + Port = options.TargetPort ?? options.Port, + VirtualHost = !string.IsNullOrEmpty(options.TargetVirtualHost) ? options.TargetVirtualHost : options.VirtualHost, + UserName = !string.IsNullOrEmpty(options.TargetUsername) ? options.TargetUsername : options.Username, + Password = !string.IsNullOrEmpty(options.TargetPassword) ? options.TargetPassword : options.Password, + }; + + return factory.CreateConnection(); + } + } +} diff --git a/Tapeti.Cmd/Serialization/EasyNetQMessageSerializer.cs b/Tapeti.Cmd/Serialization/EasyNetQMessageSerializer.cs new file mode 100644 index 0000000..db3f589 --- /dev/null +++ b/Tapeti.Cmd/Serialization/EasyNetQMessageSerializer.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Framing; + +namespace Tapeti.Cmd.Serialization +{ + public class EasyNetQMessageSerializer : IMessageSerializer + { + private static readonly Regex InvalidCharRegex = new Regex(@"[\\\/:\*\?\""\<\>|]", RegexOptions.Compiled); + + private readonly string path; + private readonly Lazy writablePath; + private int messageCount; + + + public EasyNetQMessageSerializer(string path) + { + this.path = path; + + writablePath = new Lazy(() => + { + Directory.CreateDirectory(path); + return path; + }); + } + + + public void Dispose() + { + } + + + public void Serialize(Message message) + { + var uniqueFileName = SanitiseQueueName(message.Queue) + "." + messageCount; + + var bodyPath = Path.Combine(writablePath.Value, uniqueFileName + ".message.txt"); + var propertiesPath = Path.Combine(writablePath.Value, uniqueFileName + ".properties.txt"); + var infoPath = Path.Combine(writablePath.Value, uniqueFileName + ".info.txt"); + + var properties = new EasyNetQMessageProperties(message.Properties); + var info = new EasyNetQMessageReceivedInfo(message); + + File.WriteAllText(bodyPath, Encoding.UTF8.GetString(message.Body)); + File.WriteAllText(propertiesPath, JsonConvert.SerializeObject(properties)); + File.WriteAllText(infoPath, JsonConvert.SerializeObject(info)); + + messageCount++; + } + + + private static string SanitiseQueueName(string queueName) + { + return InvalidCharRegex.Replace(queueName, "_"); + } + + + public IEnumerable Deserialize() + { + foreach (var file in Directory.GetFiles(path, "*.*.message.txt")) + { + const string messageTag = ".message."; + + var directoryName = Path.GetDirectoryName(file); + var fileName = Path.GetFileName(file); + var propertiesFileName = Path.Combine(directoryName, fileName.Replace(messageTag, ".properties.")); + var infoFileName = Path.Combine(directoryName, fileName.Replace(messageTag, ".info.")); + + var body = File.ReadAllText(file); + + var propertiesJson = File.ReadAllText(propertiesFileName); + var properties = JsonConvert.DeserializeObject(propertiesJson); + + var infoJson = File.ReadAllText(infoFileName); + var info = JsonConvert.DeserializeObject(infoJson); + + var message = info.ToMessage(); + message.Properties = properties.ToBasicProperties(); + message.Body = Encoding.UTF8.GetBytes(body); + + yield return message; + } + } + + + // ReSharper disable MemberCanBePrivate.Local - used by JSON deserialization + // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local + private class EasyNetQMessageProperties + { + // ReSharper disable once MemberCanBePrivate.Local - used by JSON deserialization + public EasyNetQMessageProperties() + { + } + + public EasyNetQMessageProperties(IBasicProperties basicProperties) : this() + { + if (basicProperties.IsContentTypePresent()) ContentType = basicProperties.ContentType; + if (basicProperties.IsContentEncodingPresent()) ContentEncoding = basicProperties.ContentEncoding; + if (basicProperties.IsDeliveryModePresent()) DeliveryMode = basicProperties.DeliveryMode; + if (basicProperties.IsPriorityPresent()) Priority = basicProperties.Priority; + if (basicProperties.IsCorrelationIdPresent()) CorrelationId = basicProperties.CorrelationId; + if (basicProperties.IsReplyToPresent()) ReplyTo = basicProperties.ReplyTo; + if (basicProperties.IsExpirationPresent()) Expiration = basicProperties.Expiration; + if (basicProperties.IsMessageIdPresent()) MessageId = basicProperties.MessageId; + if (basicProperties.IsTimestampPresent()) Timestamp = basicProperties.Timestamp.UnixTime; + if (basicProperties.IsTypePresent()) Type = basicProperties.Type; + if (basicProperties.IsUserIdPresent()) UserId = basicProperties.UserId; + if (basicProperties.IsAppIdPresent()) AppId = basicProperties.AppId; + if (basicProperties.IsClusterIdPresent()) ClusterId = basicProperties.ClusterId; + + if (!basicProperties.IsHeadersPresent()) + return; + + foreach (var header in basicProperties.Headers) + Headers.Add(header.Key, (byte[])header.Value); + } + + public IBasicProperties ToBasicProperties() + { + var basicProperties = new BasicProperties(); + + if (ContentTypePresent) basicProperties.ContentType = ContentType; + if (ContentEncodingPresent) basicProperties.ContentEncoding = ContentEncoding; + if (DeliveryModePresent) basicProperties.DeliveryMode = DeliveryMode; + if (PriorityPresent) basicProperties.Priority = Priority; + if (CorrelationIdPresent) basicProperties.CorrelationId = CorrelationId; + if (ReplyToPresent) basicProperties.ReplyTo = ReplyTo; + if (ExpirationPresent) basicProperties.Expiration = Expiration; + if (MessageIdPresent) basicProperties.MessageId = MessageId; + if (TimestampPresent) basicProperties.Timestamp = new AmqpTimestamp(Timestamp); + if (TypePresent) basicProperties.Type = Type; + if (UserIdPresent) basicProperties.UserId = UserId; + if (AppIdPresent) basicProperties.AppId = AppId; + if (ClusterIdPresent) basicProperties.ClusterId = ClusterId; + + if (HeadersPresent) + { + basicProperties.Headers = new Dictionary(Headers.ToDictionary(p => p.Key, p => (object)p.Value)); + } + + return basicProperties; + } + + + private string contentType; + public string ContentType + { + get => contentType; + set { contentType = value; ContentTypePresent = true; } + } + + private string contentEncoding; + public string ContentEncoding + { + get => contentEncoding; + set { contentEncoding = value; ContentEncodingPresent = true; } + } + + // The original EasyNetQ.Hosepipe defines this as an IDictionary. This causes UTF-8 headers + // to be serialized as Base64, and deserialized as string, corrupting the republished message. + // This may cause incompatibilities, but fixes it for dumped Tapeti messages. + private IDictionary headers = new Dictionary(); + public IDictionary Headers + { + get => headers; + set { headers = value; HeadersPresent = true; } + } + + private byte deliveryMode; + public byte DeliveryMode + { + get => deliveryMode; + set { deliveryMode = value; DeliveryModePresent = true; } + } + + private byte priority; + public byte Priority + { + get => priority; + set { priority = value; PriorityPresent = true; } + } + + private string correlationId; + public string CorrelationId + { + get => correlationId; + set { correlationId = value; CorrelationIdPresent = true; } + } + + private string replyTo; + public string ReplyTo + { + get => replyTo; + set { replyTo = value; ReplyToPresent = true; } + } + + private string expiration; + public string Expiration + { + get => expiration; + set { expiration = value; ExpirationPresent = true; } + } + + private string messageId; + public string MessageId + { + get => messageId; + set { messageId = value; MessageIdPresent = true; } + } + + private long timestamp; + public long Timestamp + { + get => timestamp; + set { timestamp = value; TimestampPresent = true; } + } + + private string type; + public string Type + { + get => type; + set { type = value; TypePresent = true; } + } + + private string userId; + public string UserId + { + get => userId; + set { userId = value; UserIdPresent = true; } + } + + private string appId; + public string AppId + { + get => appId; + set { appId = value; AppIdPresent = true; } + } + + private string clusterId; + public string ClusterId + { + get => clusterId; + set { clusterId = value; ClusterIdPresent = true; } + } + + public bool ContentTypePresent { get; set; } + public bool ContentEncodingPresent { get; set; } + public bool HeadersPresent { get; set; } = true; + public bool DeliveryModePresent { get; set; } + public bool PriorityPresent { get; set; } + public bool CorrelationIdPresent { get; set; } + public bool ReplyToPresent { get; set; } + public bool ExpirationPresent { get; set; } + public bool MessageIdPresent { get; set; } + public bool TimestampPresent { get; set; } + public bool TypePresent { get; set; } + public bool UserIdPresent { get; set; } + public bool AppIdPresent { get; set; } + public bool ClusterIdPresent { get; set; } + } + + + private class EasyNetQMessageReceivedInfo + { + public string ConsumerTag { get; set; } + public ulong DeliverTag { get; set; } + public bool Redelivered { get; set; } + public string Exchange { get; set; } + public string RoutingKey { get; set; } + public string Queue { get; set; } + + + // ReSharper disable once MemberCanBePrivate.Local - used by JSON deserialization + // ReSharper disable once UnusedMember.Local + // ReSharper disable once UnusedMember.Global + public EasyNetQMessageReceivedInfo() + { + } + + + public EasyNetQMessageReceivedInfo(Message fromMessage) + { + ConsumerTag = "hosepipe"; + DeliverTag = fromMessage.DeliveryTag; + Redelivered = fromMessage.Redelivered; + Exchange = fromMessage.Exchange; + RoutingKey = fromMessage.RoutingKey; + Queue = fromMessage.Queue; + } + + + public Message ToMessage() + { + return new Message + { + //ConsumerTag = + DeliveryTag = DeliverTag, + Redelivered = Redelivered, + Exchange = Exchange, + RoutingKey = RoutingKey, + Queue = Queue + }; + } + } + // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local + // ReSharper restore MemberCanBePrivate.Local + } +} \ No newline at end of file diff --git a/Tapeti.Cmd/Serialization/IMessageSerializer.cs b/Tapeti.Cmd/Serialization/IMessageSerializer.cs new file mode 100644 index 0000000..e8ce5a3 --- /dev/null +++ b/Tapeti.Cmd/Serialization/IMessageSerializer.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using RabbitMQ.Client; + +namespace Tapeti.Cmd.Serialization +{ + public class Message + { + public ulong DeliveryTag; + public bool Redelivered; + public string Exchange; + public string RoutingKey; + public string Queue; + public IBasicProperties Properties; + public byte[] Body; + } + + + public interface IMessageSerializer : IDisposable + { + void Serialize(Message message); + IEnumerable Deserialize(); + } +} diff --git a/Tapeti.Cmd/Serialization/SingleFileJSONMessageSerializer.cs b/Tapeti.Cmd/Serialization/SingleFileJSONMessageSerializer.cs new file mode 100644 index 0000000..7079c5f --- /dev/null +++ b/Tapeti.Cmd/Serialization/SingleFileJSONMessageSerializer.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RabbitMQ.Client; +using RabbitMQ.Client.Framing; + +namespace Tapeti.Cmd.Serialization +{ + public class SingleFileJSONMessageSerializer : IMessageSerializer + { + private readonly string path; + + + private static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + + private readonly Lazy exportFile; + + + public SingleFileJSONMessageSerializer(string path) + { + this.path = path; + exportFile = new Lazy(() => new StreamWriter(path, false, Encoding.UTF8)); + } + + + public void Serialize(Message message) + { + var serializableMessage = new SerializableMessage(message); + var serialized = JsonConvert.SerializeObject(serializableMessage, SerializerSettings); + exportFile.Value.WriteLine(serialized); + } + + + public IEnumerable Deserialize() + { + using (var file = new StreamReader(path)) + { + while (!file.EndOfStream) + { + var serialized = file.ReadLine(); + if (string.IsNullOrEmpty(serialized)) + continue; + + var serializableMessage = JsonConvert.DeserializeObject(serialized); + if (serializableMessage == null) + continue; + + yield return serializableMessage.ToMessage(); + } + } + } + + + public void Dispose() + { + if (exportFile.IsValueCreated) + exportFile.Value.Dispose(); + } + + + + // ReSharper disable MemberCanBePrivate.Local - used for JSON serialization + // ReSharper disable NotAccessedField.Local + // ReSharper disable FieldCanBeMadeReadOnly.Local + private class SerializableMessage + { + public ulong DeliveryTag; + public bool Redelivered; + public string Exchange; + public string RoutingKey; + public string Queue; + + // ReSharper disable once FieldCanBeMadeReadOnly.Local - must be settable by JSON deserialization + public SerializableMessageProperties Properties; + + public JObject Body; + public byte[] RawBody; + + + // ReSharper disable once UnusedMember.Global - used by JSON deserialization + // ReSharper disable once UnusedMember.Local + public SerializableMessage() + { + Properties = new SerializableMessageProperties(); + } + + + public SerializableMessage(Message fromMessage) + { + DeliveryTag = fromMessage.DeliveryTag; + Redelivered = fromMessage.Redelivered; + Exchange = fromMessage.Exchange; + RoutingKey = fromMessage.RoutingKey; + Queue = fromMessage.Queue; + Properties = new SerializableMessageProperties(fromMessage.Properties); + + // If this is detected as a JSON message, include the object directly in the JSON line so that it is easier + // to read and process in the output file. Otherwise simply include the raw data and let Newtonsoft encode it. + // This does mean the message will be rewritten. If this is an issue, feel free to add a "raw" option to this tool + // that forces the RawBody to be used. It is open-source after all :-). + if (Properties.ContentType == "application/json") + { + try + { + Body = JObject.Parse(Encoding.UTF8.GetString(fromMessage.Body)); + RawBody = null; + } + catch + { + // Fall back to using the raw body + Body = null; + RawBody = fromMessage.Body; + } + } + else + { + Body = null; + RawBody = fromMessage.Body; + } + } + + + public Message ToMessage() + { + return new Message + { + DeliveryTag = DeliveryTag, + Redelivered = Redelivered, + Exchange = Exchange, + RoutingKey = RoutingKey, + Queue = Queue, + Properties = Properties.ToBasicProperties(), + Body = Body != null + ? Encoding.UTF8.GetBytes(Body.ToString(Formatting.None)) + : RawBody + }; + } + } + + + // IBasicProperties is finicky when it comes to writing it's properties, + // so we need this normalized class to read and write it from and to JSON + private class SerializableMessageProperties + { + public string AppId; + public string ClusterId; + public string ContentEncoding; + public string ContentType; + public string CorrelationId; + public byte? DeliveryMode; + public string Expiration; + public IDictionary Headers; + public string MessageId; + public byte? Priority; + public string ReplyTo; + public long? Timestamp; + public string Type; + public string UserId; + + + public SerializableMessageProperties() + { + } + + + public SerializableMessageProperties(IBasicProperties fromProperties) + { + AppId = fromProperties.AppId; + ClusterId = fromProperties.ClusterId; + ContentEncoding = fromProperties.ContentEncoding; + ContentType = fromProperties.ContentType; + CorrelationId = fromProperties.CorrelationId; + DeliveryMode = fromProperties.IsDeliveryModePresent() ? (byte?)fromProperties.DeliveryMode : null; + Expiration = fromProperties.Expiration; + MessageId = fromProperties.MessageId; + Priority = fromProperties.IsPriorityPresent() ? (byte?) fromProperties.Priority : null; + ReplyTo = fromProperties.ReplyTo; + Timestamp = fromProperties.IsTimestampPresent() ? (long?)fromProperties.Timestamp.UnixTime : null; + Type = fromProperties.Type; + UserId = fromProperties.UserId; + + if (fromProperties.IsHeadersPresent()) + { + Headers = new Dictionary(); + + // This assumes header values are UTF-8 encoded strings. This is true for Tapeti. + foreach (var pair in fromProperties.Headers) + Headers.Add(pair.Key, Encoding.UTF8.GetString((byte[])pair.Value)); + } + else + Headers = null; + } + + + public IBasicProperties ToBasicProperties() + { + var properties = new BasicProperties(); + + if (!string.IsNullOrEmpty(AppId)) properties.AppId = AppId; + if (!string.IsNullOrEmpty(ClusterId)) properties.ClusterId = ClusterId; + if (!string.IsNullOrEmpty(ContentEncoding)) properties.ContentEncoding = ContentEncoding; + if (!string.IsNullOrEmpty(ContentType)) properties.ContentType = ContentType; + if (DeliveryMode.HasValue) properties.DeliveryMode = DeliveryMode.Value; + if (!string.IsNullOrEmpty(Expiration)) properties.Expiration = Expiration; + if (!string.IsNullOrEmpty(MessageId)) properties.MessageId = MessageId; + if (Priority.HasValue) properties.Priority = Priority.Value; + if (!string.IsNullOrEmpty(ReplyTo)) properties.ReplyTo = ReplyTo; + if (Timestamp.HasValue) properties.Timestamp = new AmqpTimestamp(Timestamp.Value); + if (!string.IsNullOrEmpty(Type)) properties.Type = Type; + if (!string.IsNullOrEmpty(UserId)) properties.UserId = UserId; + + // ReSharper disable once InvertIf + if (Headers != null) + { + properties.Headers = new Dictionary(); + + foreach (var pair in Headers) + properties.Headers.Add(pair.Key, Encoding.UTF8.GetBytes(pair.Value)); + } + + return properties; + } + } + // ReSharper restore FieldCanBeMadeReadOnly.Local + // ReSharper restore NotAccessedField.Local + // ReSharper restore MemberCanBePrivate.Local + } +} diff --git a/Tapeti.Cmd/Tapeti.Cmd.csproj b/Tapeti.Cmd/Tapeti.Cmd.csproj new file mode 100644 index 0000000..6a80ffb --- /dev/null +++ b/Tapeti.Cmd/Tapeti.Cmd.csproj @@ -0,0 +1,18 @@ + + + + Exe + netcoreapp2.2 + 2.0.0 + Mark van Renswoude + Mark van Renswoude + Tapeti Command-line Utility + + + + + + + + + diff --git a/Tapeti.Cmd/build-release.bat b/Tapeti.Cmd/build-release.bat new file mode 100644 index 0000000..c9ccd4f --- /dev/null +++ b/Tapeti.Cmd/build-release.bat @@ -0,0 +1 @@ +dotnet publish -c Release -r win-x64 --self-contained false \ No newline at end of file diff --git a/Tapeti.DataAnnotations.Extensions/RequiredGuidAttribute.cs b/Tapeti.DataAnnotations.Extensions/RequiredGuidAttribute.cs index cef8d1e..b706ccd 100644 --- a/Tapeti.DataAnnotations.Extensions/RequiredGuidAttribute.cs +++ b/Tapeti.DataAnnotations.Extensions/RequiredGuidAttribute.cs @@ -1,9 +1,11 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; + +// ReSharper disable UnusedMember.Global namespace Tapeti.DataAnnotations.Extensions { + /// /// /// Can be used on Guid fields which are supposed to be Required, as the Required attribute does /// not work for Guids and making them Nullable is counter-intuitive. @@ -13,10 +15,12 @@ namespace Tapeti.DataAnnotations.Extensions private const string DefaultErrorMessage = "'{0}' does not contain a valid guid"; private const string InvalidTypeErrorMessage = "'{0}' is not of type Guid"; + /// public RequiredGuidAttribute() : base(DefaultErrorMessage) { } + /// protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (value == null) diff --git a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj index 56cdff2..9fd102a 100644 --- a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj +++ b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj @@ -2,6 +2,12 @@ netstandard2.0 + true + 2.0.0 + + + + 1701;1702 diff --git a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.nuspec b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.nuspec index db70921..d1e1aa1 100644 --- a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.nuspec +++ b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.nuspec @@ -6,7 +6,7 @@ Tapeti DataAnnotations Extensions Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.DataAnnotations.png false diff --git a/Tapeti.DataAnnotations/ConfigExtensions.cs b/Tapeti.DataAnnotations/ConfigExtensions.cs index 3001fe9..72a3cfb 100644 --- a/Tapeti.DataAnnotations/ConfigExtensions.cs +++ b/Tapeti.DataAnnotations/ConfigExtensions.cs @@ -1,10 +1,19 @@ -namespace Tapeti.DataAnnotations +using Tapeti.Config; + +namespace Tapeti.DataAnnotations { + /// + /// Extends ITapetiConfigBuilder to enable DataAnnotations. + /// public static class ConfigExtensions { - public static TapetiConfig WithDataAnnotations(this TapetiConfig config) + /// + /// Enables the DataAnnotations validation middleware. + /// + /// + public static ITapetiConfigBuilder WithDataAnnotations(this ITapetiConfigBuilder config) { - config.Use(new DataAnnotationsMiddleware()); + config.Use(new DataAnnotationsExtension()); return config; } } diff --git a/Tapeti.DataAnnotations/DataAnnotationsMiddleware.cs b/Tapeti.DataAnnotations/DataAnnotationsExtension.cs similarity index 67% rename from Tapeti.DataAnnotations/DataAnnotationsMiddleware.cs rename to Tapeti.DataAnnotations/DataAnnotationsExtension.cs index ffbaac4..abdbc5c 100644 --- a/Tapeti.DataAnnotations/DataAnnotationsMiddleware.cs +++ b/Tapeti.DataAnnotations/DataAnnotationsExtension.cs @@ -3,12 +3,18 @@ using Tapeti.Config; namespace Tapeti.DataAnnotations { - public class DataAnnotationsMiddleware : ITapetiExtension + /// + /// + /// Provides the DataAnnotations validation middleware. + /// + public class DataAnnotationsExtension : ITapetiExtension { + /// public void RegisterDefaults(IDependencyContainer container) { } + /// public IEnumerable GetMiddleware(IDependencyResolver dependencyResolver) { return new object[] diff --git a/Tapeti.DataAnnotations/DataAnnotationsMessageMiddleware.cs b/Tapeti.DataAnnotations/DataAnnotationsMessageMiddleware.cs index 3228671..8b2ed85 100644 --- a/Tapeti.DataAnnotations/DataAnnotationsMessageMiddleware.cs +++ b/Tapeti.DataAnnotations/DataAnnotationsMessageMiddleware.cs @@ -5,14 +5,19 @@ using Tapeti.Config; namespace Tapeti.DataAnnotations { - public class DataAnnotationsMessageMiddleware : IMessageMiddleware + /// + /// + /// Validates consumed messages using System.ComponentModel.DataAnnotations + /// + internal class DataAnnotationsMessageMiddleware : IMessageMiddleware { - public Task Handle(IMessageContext context, Func next) + /// + public async Task Handle(IMessageContext context, Func next) { var validationContext = new ValidationContext(context.Message); Validator.ValidateObject(context.Message, validationContext, true); - return next(); + await next(); } } } diff --git a/Tapeti.DataAnnotations/DataAnnotationsPublishMiddleware.cs b/Tapeti.DataAnnotations/DataAnnotationsPublishMiddleware.cs index f3d70b4..514989c 100644 --- a/Tapeti.DataAnnotations/DataAnnotationsPublishMiddleware.cs +++ b/Tapeti.DataAnnotations/DataAnnotationsPublishMiddleware.cs @@ -5,14 +5,19 @@ using Tapeti.Config; namespace Tapeti.DataAnnotations { - public class DataAnnotationsPublishMiddleware : IPublishMiddleware + /// + /// + /// Validates published messages using System.ComponentModel.DataAnnotations + /// + internal class DataAnnotationsPublishMiddleware : IPublishMiddleware { - public Task Handle(IPublishContext context, Func next) + /// + public async Task Handle(IPublishContext context, Func next) { var validationContext = new ValidationContext(context.Message); Validator.ValidateObject(context.Message, validationContext, true); - return next(); + await next(); } } } diff --git a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj index 52e0d73..d0cac34 100644 --- a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj +++ b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj @@ -3,6 +3,11 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 diff --git a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.nuspec b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.nuspec index ddf90b8..02b18c4 100644 --- a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.nuspec +++ b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.nuspec @@ -6,7 +6,7 @@ Tapeti DataAnnotations Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.DataAnnotations.png false diff --git a/Tapeti.Flow.SQL/ConfigExtensions.cs b/Tapeti.Flow.SQL/ConfigExtensions.cs index 2e2e247..ef285d9 100644 --- a/Tapeti.Flow.SQL/ConfigExtensions.cs +++ b/Tapeti.Flow.SQL/ConfigExtensions.cs @@ -5,23 +5,32 @@ using Tapeti.Config; namespace Tapeti.Flow.SQL { + /// + /// Extends ITapetiConfigBuilder to enable Flow SQL. + /// public static class ConfigExtensions { - public static TapetiConfig WithFlowSqlRepository(this TapetiConfig config, string connectionString, string tableName = "Flow") + /// + /// Enables the Flow SQL repository. + /// + /// + /// + /// + public static ITapetiConfigBuilder WithFlowSqlRepository(this ITapetiConfigBuilder config, string connectionString, string tableName = "Flow") { - config.Use(new FlowSqlRepositoryBundle(connectionString, tableName)); + config.Use(new FlowSqlRepositoryExtension(connectionString, tableName)); return config; } } - internal class FlowSqlRepositoryBundle : ITapetiExtension + internal class FlowSqlRepositoryExtension : ITapetiExtension { private readonly string connectionString; private readonly string tableName; - public FlowSqlRepositoryBundle(string connectionString, string tableName) + public FlowSqlRepositoryExtension(string connectionString, string tableName) { this.connectionString = connectionString; this.tableName = tableName; diff --git a/Tapeti.Flow.SQL/SqlConnectionFlowRepository.cs b/Tapeti.Flow.SQL/SqlConnectionFlowRepository.cs index b2a1a5a..bba7085 100644 --- a/Tapeti.Flow.SQL/SqlConnectionFlowRepository.cs +++ b/Tapeti.Flow.SQL/SqlConnectionFlowRepository.cs @@ -7,25 +7,27 @@ using Newtonsoft.Json; namespace Tapeti.Flow.SQL { - /* - Assumes the following table layout (table name configurable and may include schema): - - - create table Flow - ( - FlowID uniqueidentifier not null, - CreationTime datetime2(3) not null, - StateJson nvarchar(max) null, - - constraint PK_Flow primary key clustered (FlowID) - ); - */ + /// + /// + /// IFlowRepository implementation for SQL server. + /// + /// + /// Assumes the following table layout (table name configurable and may include schema): + /// create table Flow + /// ( + /// FlowID uniqueidentifier not null, + /// CreationTime datetime2(3) not null, + /// StateJson nvarchar(max) null, + /// constraint PK_Flow primary key clustered(FlowID) + /// ); + /// public class SqlConnectionFlowRepository : IFlowRepository { private readonly string connectionString; private readonly string tableName; + /// public SqlConnectionFlowRepository(string connectionString, string tableName = "Flow") { this.connectionString = connectionString; @@ -33,6 +35,7 @@ namespace Tapeti.Flow.SQL } + /// public async Task>> GetStates() { return await SqlRetryHelper.Execute(async () => @@ -58,6 +61,7 @@ namespace Tapeti.Flow.SQL }); } + /// public async Task CreateState(Guid flowID, T state, DateTime timestamp) { await SqlRetryHelper.Execute(async () => @@ -81,6 +85,7 @@ namespace Tapeti.Flow.SQL }); } + /// public async Task UpdateState(Guid flowID, T state) { await SqlRetryHelper.Execute(async () => @@ -100,6 +105,7 @@ namespace Tapeti.Flow.SQL }); } + /// public async Task DeleteState(Guid flowID) { await SqlRetryHelper.Execute(async () => diff --git a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj index eaa2e91..428bda5 100644 --- a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj +++ b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj @@ -3,10 +3,15 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 - + diff --git a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.nuspec b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.nuspec index 81a6fe0..15b5488 100644 --- a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.nuspec +++ b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.nuspec @@ -6,7 +6,7 @@ Tapeti Flow SQL Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.Flow.SQL.png false diff --git a/Tapeti.Flow/Annotations/ContinuationAttribute.cs b/Tapeti.Flow/Annotations/ContinuationAttribute.cs index 8749bf8..2612a30 100644 --- a/Tapeti.Flow/Annotations/ContinuationAttribute.cs +++ b/Tapeti.Flow/Annotations/ContinuationAttribute.cs @@ -2,6 +2,11 @@ namespace Tapeti.Flow.Annotations { + /// + /// + /// Marks a message handler as a response message handler which continues a Tapeti Flow. + /// The method only receives direct messages, and does not create a routing key based binding to the queue. + /// [AttributeUsage(AttributeTargets.Method)] public class ContinuationAttribute : Attribute { diff --git a/Tapeti.Flow/Annotations/StartAttribute.cs b/Tapeti.Flow/Annotations/StartAttribute.cs index 3f8e767..8c1fd2e 100644 --- a/Tapeti.Flow/Annotations/StartAttribute.cs +++ b/Tapeti.Flow/Annotations/StartAttribute.cs @@ -3,6 +3,11 @@ using JetBrains.Annotations; namespace Tapeti.Flow.Annotations { + /// + /// + /// Marks this method as the start of a Tapeti Flow. Use IFlowStarter.Start to begin a new flow and + /// call this method. Must return an IYieldPoint. + /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] public class StartAttribute : Attribute diff --git a/Tapeti.Flow/ConfigExtensions.cs b/Tapeti.Flow/ConfigExtensions.cs index 127a0c2..474da6a 100644 --- a/Tapeti.Flow/ConfigExtensions.cs +++ b/Tapeti.Flow/ConfigExtensions.cs @@ -1,10 +1,21 @@ -namespace Tapeti.Flow +using Tapeti.Config; + +namespace Tapeti.Flow { + /// + /// ITapetiConfigBuilder extension for enabling Flow. + /// public static class ConfigExtensions { - public static TapetiConfig WithFlow(this TapetiConfig config, IFlowRepository flowRepository = null) + /// + /// Enables Tapeti Flow. + /// + /// + /// An optional IFlowRepository implementation to persist flow state. If not provided, flow state will be lost when the application restarts. + /// + public static ITapetiConfigBuilder WithFlow(this ITapetiConfigBuilder config, IFlowRepository flowRepository = null) { - config.Use(new FlowMiddleware(flowRepository)); + config.Use(new FlowExtension(flowRepository)); return config; } } diff --git a/Tapeti.Flow/ContextItems.cs b/Tapeti.Flow/ContextItems.cs index 452de54..8bebc23 100644 --- a/Tapeti.Flow/ContextItems.cs +++ b/Tapeti.Flow/ContextItems.cs @@ -1,7 +1,13 @@ namespace Tapeti.Flow { + /// + /// Key names as used in the message context store. For internal use. + /// public static class ContextItems { + /// + /// Key given to the FlowContext object as stored in the message context. + /// public const string FlowContext = "Tapeti.Flow.FlowContext"; } } diff --git a/Tapeti.Flow/Default/DelegateYieldPoint.cs b/Tapeti.Flow/Default/DelegateYieldPoint.cs index 2ed0926..dc1aa35 100644 --- a/Tapeti.Flow/Default/DelegateYieldPoint.cs +++ b/Tapeti.Flow/Default/DelegateYieldPoint.cs @@ -14,9 +14,9 @@ namespace Tapeti.Flow.Default } - public Task Execute(FlowContext context) + public async Task Execute(FlowContext context) { - return onExecute(context); + await onExecute(context); } } } diff --git a/Tapeti.Flow/Default/FlowBindingMiddleware.cs b/Tapeti.Flow/Default/FlowBindingMiddleware.cs index fd796c4..dbade9d 100644 --- a/Tapeti.Flow/Default/FlowBindingMiddleware.cs +++ b/Tapeti.Flow/Default/FlowBindingMiddleware.cs @@ -8,16 +8,13 @@ using Tapeti.Helpers; namespace Tapeti.Flow.Default { - internal class FlowBindingMiddleware : IBindingMiddleware + internal class FlowBindingMiddleware : IControllerBindingMiddleware { - public void Handle(IBindingContext context, Action next) + public void Handle(IControllerBindingContext context, Action next) { if (context.Method.GetCustomAttribute() != null) return; - if (context.Method.GetCustomAttribute() != null) - context.QueueBindingMode = QueueBindingMode.DirectToQueue; - RegisterYieldPointResult(context); RegisterContinuationFilter(context); @@ -27,14 +24,14 @@ namespace Tapeti.Flow.Default } - private static void RegisterContinuationFilter(IBindingContext context) + private static void RegisterContinuationFilter(IControllerBindingContext context) { var continuationAttribute = context.Method.GetCustomAttribute(); if (continuationAttribute == null) return; - context.Use(new FlowMessageFilterMiddleware()); - context.Use(new FlowMessageMiddleware()); + context.SetBindingTargetMode(BindingTargetMode.Direct); + context.Use(new FlowContinuationMiddleware()); if (context.Result.HasHandler) return; @@ -58,7 +55,7 @@ namespace Tapeti.Flow.Default } - private static void RegisterYieldPointResult(IBindingContext context) + private static void RegisterYieldPointResult(IControllerBindingContext context) { if (!context.Result.Info.ParameterType.IsTypeOrTaskOf(typeof(IYieldPoint), out var isTaskOf)) return; @@ -77,24 +74,24 @@ namespace Tapeti.Flow.Default } - private static Task HandleYieldPoint(IMessageContext context, IYieldPoint yieldPoint) + private static Task HandleYieldPoint(IControllerMessageContext context, IYieldPoint yieldPoint) { - var flowHandler = context.DependencyResolver.Resolve(); - return flowHandler.Execute(context, yieldPoint); + var flowHandler = context.Config.DependencyResolver.Resolve(); + return flowHandler.Execute(new FlowHandlerContext(context), yieldPoint); } - private static Task HandleParallelResponse(IMessageContext context) + private static Task HandleParallelResponse(IControllerMessageContext context) { - var flowHandler = context.DependencyResolver.Resolve(); - return flowHandler.Execute(context, new DelegateYieldPoint(async flowContext => + var flowHandler = context.Config.DependencyResolver.Resolve(); + return flowHandler.Execute(new FlowHandlerContext(context), new DelegateYieldPoint(async flowContext => { - await flowContext.Store(); + await flowContext.Store(context.Binding.QueueType == QueueType.Durable); })); } - private static void ValidateRequestResponse(IBindingContext context) + private static void ValidateRequestResponse(IControllerBindingContext context) { var request = context.MessageClass?.GetCustomAttribute(); if (request?.Response == null) diff --git a/Tapeti.Flow/Default/FlowCleanupMiddleware.cs b/Tapeti.Flow/Default/FlowCleanupMiddleware.cs deleted file mode 100644 index 12673ad..0000000 --- a/Tapeti.Flow/Default/FlowCleanupMiddleware.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading.Tasks; -using Tapeti.Config; - -namespace Tapeti.Flow.Default -{ - public class FlowCleanupMiddleware : ICleanupMiddleware - { - public async Task Handle(IMessageContext context, HandlingResult handlingResult) - { - if (!context.Items.TryGetValue(ContextItems.FlowContext, out var flowContextObj)) - return; - var flowContext = (FlowContext)flowContextObj; - - if (flowContext?.FlowStateLock != null) - { - if (handlingResult.ConsumeResponse == ConsumeResponse.Nack - || handlingResult.MessageAction == MessageAction.ErrorLog) - { - await flowContext.FlowStateLock.DeleteFlowState(); - } - flowContext.FlowStateLock.Dispose(); - } - } - } -} diff --git a/Tapeti.Flow/Default/FlowContext.cs b/Tapeti.Flow/Default/FlowContext.cs index dbadf08..686e2df 100644 --- a/Tapeti.Flow/Default/FlowContext.cs +++ b/Tapeti.Flow/Default/FlowContext.cs @@ -1,12 +1,11 @@ using System; using System.Threading.Tasks; -using Tapeti.Config; namespace Tapeti.Flow.Default { internal class FlowContext : IDisposable { - public IMessageContext MessageContext { get; set; } + public IFlowHandlerContext HandlerContext { get; set; } public IFlowStateLock FlowStateLock { get; set; } public FlowState FlowState { get; set; } @@ -17,16 +16,16 @@ namespace Tapeti.Flow.Default private bool deleteCalled; - public async Task Store() + public async Task Store(bool persistent) { storeCalled = true; - if (MessageContext == null) throw new ArgumentNullException(nameof(MessageContext)); + if (HandlerContext == null) throw new ArgumentNullException(nameof(HandlerContext)); if (FlowState == null) throw new ArgumentNullException(nameof(FlowState)); if (FlowStateLock == null) throw new ArgumentNullException(nameof(FlowStateLock)); - FlowState.Data = Newtonsoft.Json.JsonConvert.SerializeObject(MessageContext.Controller); - await FlowStateLock.StoreFlowState(FlowState); + FlowState.Data = Newtonsoft.Json.JsonConvert.SerializeObject(HandlerContext.Controller); + await FlowStateLock.StoreFlowState(FlowState, persistent); } public async Task Delete() diff --git a/Tapeti.Flow/Default/FlowContinuationMiddleware.cs b/Tapeti.Flow/Default/FlowContinuationMiddleware.cs new file mode 100644 index 0000000..cb6fe78 --- /dev/null +++ b/Tapeti.Flow/Default/FlowContinuationMiddleware.cs @@ -0,0 +1,130 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Tapeti.Config; +using Tapeti.Flow.FlowHelpers; + +namespace Tapeti.Flow.Default +{ + /// /> + /// + /// Handles methods marked with the Continuation attribute. + /// + internal class FlowContinuationMiddleware : IControllerFilterMiddleware, IControllerMessageMiddleware, IControllerCleanupMiddleware + { + public async Task Filter(IControllerMessageContext context, Func next) + { + var flowContext = await EnrichWithFlowContext(context); + if (flowContext?.ContinuationMetadata == null) + return; + + if (flowContext.ContinuationMetadata.MethodName != MethodSerializer.Serialize(context.Binding.Method)) + return; + + await next(); + } + + + public async Task Handle(IControllerMessageContext context, Func next) + { + if (context.Get(ContextItems.FlowContext, out FlowContext flowContext)) + { + Newtonsoft.Json.JsonConvert.PopulateObject(flowContext.FlowState.Data, context.Controller); + + // Remove Continuation now because the IYieldPoint result handler will store the new state + flowContext.FlowState.Continuations.Remove(flowContext.ContinuationID); + var converge = flowContext.FlowState.Continuations.Count == 0 && + flowContext.ContinuationMetadata.ConvergeMethodName != null; + + await next(); + + if (converge) + await CallConvergeMethod(context, + flowContext.ContinuationMetadata.ConvergeMethodName, + flowContext.ContinuationMetadata.ConvergeMethodSync); + } + else + await next(); + } + + + public async Task Cleanup(IMessageContext context, ConsumeResult consumeResult, Func next) + { + await next(); + + if (!context.Get(ContextItems.FlowContext, out FlowContext flowContext)) + return; + + if (flowContext?.FlowStateLock != null) + { + if (consumeResult == ConsumeResult.Error) + await flowContext.FlowStateLock.DeleteFlowState(); + + flowContext.FlowStateLock.Dispose(); + } + } + + + + private static async Task EnrichWithFlowContext(IControllerMessageContext context) + { + if (context.Get(ContextItems.FlowContext, out FlowContext flowContext)) + return flowContext; + + + if (context.Properties.CorrelationId == null) + return null; + + if (!Guid.TryParse(context.Properties.CorrelationId, out var continuationID)) + return null; + + var flowStore = context.Config.DependencyResolver.Resolve(); + + var flowID = await flowStore.FindFlowID(continuationID); + if (!flowID.HasValue) + return null; + + var flowStateLock = await flowStore.LockFlowState(flowID.Value); + + var flowState = await flowStateLock.GetFlowState(); + if (flowState == null) + return null; + + flowContext = new FlowContext + { + HandlerContext = new FlowHandlerContext(context), + + FlowStateLock = flowStateLock, + FlowState = flowState, + + ContinuationID = continuationID, + ContinuationMetadata = flowState.Continuations.TryGetValue(continuationID, out var continuation) ? continuation : null + }; + + // IDisposable items in the IMessageContext are automatically disposed + context.Store(ContextItems.FlowContext, flowContext); + return flowContext; + } + + + private static async Task CallConvergeMethod(IControllerMessageContext context, string methodName, bool sync) + { + IYieldPoint yieldPoint; + + var method = context.Controller.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + if (method == null) + throw new ArgumentException($"Unknown converge method in controller {context.Controller.GetType().Name}: {methodName}"); + + if (sync) + yieldPoint = (IYieldPoint)method.Invoke(context.Controller, new object[] {}); + else + yieldPoint = await (Task)method.Invoke(context.Controller, new object[] { }); + + if (yieldPoint == null) + throw new YieldPointException($"Yield point is required in controller {context.Controller.GetType().Name} for converge method {methodName}"); + + var flowHandler = context.Config.DependencyResolver.Resolve(); + await flowHandler.Execute(new FlowHandlerContext(context), yieldPoint); + } + } +} diff --git a/Tapeti.Flow/Default/FlowHandlerContext.cs b/Tapeti.Flow/Default/FlowHandlerContext.cs new file mode 100644 index 0000000..fc35b68 --- /dev/null +++ b/Tapeti.Flow/Default/FlowHandlerContext.cs @@ -0,0 +1,48 @@ +using System.Reflection; +using Tapeti.Config; + +namespace Tapeti.Flow.Default +{ + /// + /// + /// Default implementation for IFlowHandlerContext + /// + internal class FlowHandlerContext : IFlowHandlerContext + { + /// + public FlowHandlerContext() + { + } + + + /// + public FlowHandlerContext(IControllerMessageContext source) + { + if (source == null) + return; + + Config = source.Config; + Controller = source.Controller; + Method = source.Binding.Method; + ControllerMessageContext = source; + } + + + /// + public void Dispose() + { + } + + /// + public ITapetiConfig Config { get; set; } + + /// + public object Controller { get; set; } + + /// + public MethodInfo Method { get; set; } + + /// + public IControllerMessageContext ControllerMessageContext { get; set; } + } +} diff --git a/Tapeti.Flow/Default/FlowMessageFilterMiddleware.cs b/Tapeti.Flow/Default/FlowMessageFilterMiddleware.cs deleted file mode 100644 index 8df46e8..0000000 --- a/Tapeti.Flow/Default/FlowMessageFilterMiddleware.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Threading.Tasks; -using Tapeti.Config; -using Tapeti.Flow.FlowHelpers; - -namespace Tapeti.Flow.Default -{ - public class FlowMessageFilterMiddleware : IMessageFilterMiddleware - { - public async Task Handle(IMessageContext context, Func next) - { - var flowContext = await GetFlowContext(context); - if (flowContext?.ContinuationMetadata == null) - return; - - if (flowContext.ContinuationMetadata.MethodName != MethodSerializer.Serialize(context.Binding.Method)) - return; - - await next(); - } - - - private static async Task GetFlowContext(IMessageContext context) - { - if (context.Items.ContainsKey(ContextItems.FlowContext)) - return (FlowContext)context.Items[ContextItems.FlowContext]; - - if (context.Properties.CorrelationId == null) - return null; - - if (!Guid.TryParse(context.Properties.CorrelationId, out var continuationID)) - return null; - - var flowStore = context.DependencyResolver.Resolve(); - - var flowID = await flowStore.FindFlowID(continuationID); - if (!flowID.HasValue) - return null; - - var flowStateLock = await flowStore.LockFlowState(flowID.Value); - - var flowState = await flowStateLock.GetFlowState(); - if (flowState == null) - return null; - - var flowContext = new FlowContext - { - MessageContext = context, - - FlowStateLock = flowStateLock, - FlowState = flowState, - - ContinuationID = continuationID, - ContinuationMetadata = flowState.Continuations.TryGetValue(continuationID, out var continuation) ? continuation : null - }; - - // IDisposable items in the IMessageContext are automatically disposed - context.Items.Add(ContextItems.FlowContext, flowContext); - return flowContext; - } - } -} diff --git a/Tapeti.Flow/Default/FlowMessageMiddleware.cs b/Tapeti.Flow/Default/FlowMessageMiddleware.cs deleted file mode 100644 index 394ae0b..0000000 --- a/Tapeti.Flow/Default/FlowMessageMiddleware.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Reflection; -using System.Threading.Tasks; -using Tapeti.Config; - -namespace Tapeti.Flow.Default -{ - public class FlowMessageMiddleware : IMessageMiddleware - { - public async Task Handle(IMessageContext context, Func next) - { - var flowContext = (FlowContext)context.Items[ContextItems.FlowContext]; - if (flowContext != null) - { - Newtonsoft.Json.JsonConvert.PopulateObject(flowContext.FlowState.Data, context.Controller); - - // Remove Continuation now because the IYieldPoint result handler will store the new state - flowContext.FlowState.Continuations.Remove(flowContext.ContinuationID); - var converge = flowContext.FlowState.Continuations.Count == 0 && - flowContext.ContinuationMetadata.ConvergeMethodName != null; - - await next(); - - if (converge) - await CallConvergeMethod(context, - flowContext.ContinuationMetadata.ConvergeMethodName, - flowContext.ContinuationMetadata.ConvergeMethodSync); - } - else - await next(); - } - - - private static async Task CallConvergeMethod(IMessageContext context, string methodName, bool sync) - { - IYieldPoint yieldPoint; - - var method = context.Controller.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); - if (method == null) - throw new ArgumentException($"Unknown converge method in controller {context.Controller.GetType().Name}: {methodName}"); - - if (sync) - yieldPoint = (IYieldPoint)method.Invoke(context.Controller, new object[] {}); - else - yieldPoint = await (Task)method.Invoke(context.Controller, new object[] { }); - - if (yieldPoint == null) - throw new YieldPointException($"Yield point is required in controller {context.Controller.GetType().Name} for converge method {methodName}"); - - var flowHandler = context.DependencyResolver.Resolve(); - await flowHandler.Execute(context, yieldPoint); - } - } -} diff --git a/Tapeti.Flow/Default/FlowProvider.cs b/Tapeti.Flow/Default/FlowProvider.cs index 5c6d9d9..123c80b 100644 --- a/Tapeti.Flow/Default/FlowProvider.cs +++ b/Tapeti.Flow/Default/FlowProvider.cs @@ -4,49 +4,59 @@ using System.Diagnostics; using System.Linq; using System.Reflection; using System.Threading.Tasks; -using RabbitMQ.Client.Framing; using Tapeti.Annotations; using Tapeti.Config; +using Tapeti.Default; using Tapeti.Flow.Annotations; using Tapeti.Flow.FlowHelpers; namespace Tapeti.Flow.Default { + /// /> + /// + /// Default implementation for IFlowProvider. + /// public class FlowProvider : IFlowProvider, IFlowHandler { - private readonly IConfig config; + private readonly ITapetiConfig config; private readonly IInternalPublisher publisher; - public FlowProvider(IConfig config, IPublisher publisher) + /// + public FlowProvider(ITapetiConfig config, IPublisher publisher) { this.config = config; this.publisher = (IInternalPublisher)publisher; } + /// public IYieldPoint YieldWithRequest(TRequest message, Func> responseHandler) { var responseHandlerInfo = GetResponseHandlerInfo(config, message, responseHandler); return new DelegateYieldPoint(context => SendRequest(context, message, responseHandlerInfo)); } + /// public IYieldPoint YieldWithRequestSync(TRequest message, Func responseHandler) { var responseHandlerInfo = GetResponseHandlerInfo(config, message, responseHandler); return new DelegateYieldPoint(context => SendRequest(context, message, responseHandlerInfo)); } + /// public IFlowParallelRequestBuilder YieldWithParallelRequest() { return new ParallelRequestBuilder(config, SendRequest); } + /// public IYieldPoint EndWithResponse(TResponse message) { return new DelegateYieldPoint(context => SendResponse(context, message)); } + /// public IYieldPoint End() { return new DelegateYieldPoint(EndFlow); @@ -72,13 +82,13 @@ namespace Tapeti.Flow.Default ConvergeMethodSync = convergeMethodTaskSync }); - var properties = new BasicProperties + var properties = new MessageProperties { CorrelationId = continuationID.ToString(), ReplyTo = responseHandlerInfo.ReplyToQueue }; - await context.Store(); + await context.Store(responseHandlerInfo.IsDurableQueue); await publisher.Publish(message, properties, true); } @@ -87,7 +97,7 @@ namespace Tapeti.Flow.Default private async Task SendResponse(FlowContext context, object message) { var reply = context.FlowState == null - ? GetReply(context.MessageContext) + ? GetReply(context.HandlerContext) : context.FlowState.Metadata.Reply; if (reply == null) @@ -96,12 +106,10 @@ namespace Tapeti.Flow.Default if (message.GetType().FullName != reply.ResponseTypeName) throw new YieldPointException($"Flow must end with a response message of type {reply.ResponseTypeName}, {message.GetType().FullName} was returned instead"); - var properties = new BasicProperties(); - - // Only set the property if it's not null, otherwise a string reference exception can occur: - // http://rabbitmq.1065348.n5.nabble.com/SocketException-when-invoking-model-BasicPublish-td36330.html - if (reply.CorrelationId != null) - properties.CorrelationId = reply.CorrelationId; + var properties = new MessageProperties + { + CorrelationId = reply.CorrelationId + }; // TODO disallow if replyto is not specified? if (reply.ReplyTo != null) @@ -122,14 +130,17 @@ namespace Tapeti.Flow.Default } - private static ResponseHandlerInfo GetResponseHandlerInfo(IConfig config, object request, Delegate responseHandler) + private static ResponseHandlerInfo GetResponseHandlerInfo(ITapetiConfig config, object request, Delegate responseHandler) { - var binding = config.GetBinding(responseHandler); + var requestAttribute = request.GetType().GetCustomAttribute(); + if (requestAttribute?.Response == null) + throw new ArgumentException($"Request message {request.GetType().Name} must be marked with the Request attribute and a valid Response type", nameof(request)); + + var binding = config.Bindings.ForMethod(responseHandler); if (binding == null) throw new ArgumentException("responseHandler must be a registered message handler", nameof(responseHandler)); - var requestAttribute = request.GetType().GetCustomAttribute(); - if (requestAttribute?.Response != null && !binding.Accept(requestAttribute.Response)) + if (!binding.Accept(requestAttribute.Response)) throw new ArgumentException($"responseHandler must accept message of type {requestAttribute.Response}", nameof(responseHandler)); var continuationAttribute = binding.Method.GetCustomAttribute(); @@ -137,34 +148,35 @@ namespace Tapeti.Flow.Default throw new ArgumentException("responseHandler must be marked with the Continuation attribute", nameof(responseHandler)); if (binding.QueueName == null) - throw new ArgumentException("responseHandler must bind to a valid queue", nameof(responseHandler)); + throw new ArgumentException("responseHandler is not yet subscribed to a queue, TapetiConnection.Subscribe must be called before starting a flow", nameof(responseHandler)); return new ResponseHandlerInfo { MethodName = MethodSerializer.Serialize(responseHandler.Method), - ReplyToQueue = binding.QueueName + ReplyToQueue = binding.QueueName, + IsDurableQueue = binding.QueueType == QueueType.Durable }; } - private static ReplyMetadata GetReply(IMessageContext context) + private static ReplyMetadata GetReply(IFlowHandlerContext context) { - var requestAttribute = context.Message?.GetType().GetCustomAttribute(); + var requestAttribute = context.ControllerMessageContext?.Message?.GetType().GetCustomAttribute(); if (requestAttribute?.Response == null) return null; return new ReplyMetadata { - CorrelationId = context.Properties.CorrelationId, - ReplyTo = context.Properties.ReplyTo, + CorrelationId = context.ControllerMessageContext.Properties.CorrelationId, + ReplyTo = context.ControllerMessageContext.Properties.ReplyTo, ResponseTypeName = requestAttribute.Response.FullName, - Mandatory = context.Properties.Persistent + Mandatory = context.ControllerMessageContext.Properties.Persistent.GetValueOrDefault(true) }; } - private async Task CreateNewFlowState(FlowContext flowContext) + private static async Task CreateNewFlowState(FlowContext flowContext) { - var flowStore = flowContext.MessageContext.DependencyResolver.Resolve(); + var flowStore = flowContext.HandlerContext.Config.DependencyResolver.Resolve(); var flowID = Guid.NewGuid(); flowContext.FlowStateLock = await flowStore.LockFlowState(flowID); @@ -176,44 +188,55 @@ namespace Tapeti.Flow.Default { Metadata = new FlowMetadata { - Reply = GetReply(flowContext.MessageContext) + Reply = GetReply(flowContext.HandlerContext) } }; } - public async Task Execute(IMessageContext context, IYieldPoint yieldPoint) + + /// + public async Task Execute(IFlowHandlerContext context, IYieldPoint yieldPoint) { if (!(yieldPoint is DelegateYieldPoint executableYieldPoint)) - throw new YieldPointException($"Yield point is required in controller {context.Controller.GetType().Name} for method {context.Binding.Method.Name}"); - - FlowContext flowContext; - - if (!context.Items.TryGetValue(ContextItems.FlowContext, out var flowContextItem)) - { - flowContext = new FlowContext - { - MessageContext = context - }; - - context.Items.Add(ContextItems.FlowContext, flowContext); - } - else - flowContext = (FlowContext)flowContextItem; + throw new YieldPointException($"Yield point is required in controller {context.Controller.GetType().Name} for method {context.Method.Name}"); + FlowContext flowContext = null; + var disposeFlowContext = false; try { - await executableYieldPoint.Execute(flowContext); - } - catch (YieldPointException e) - { - // Useful for debugging - e.Data["Tapeti.Controller.Name"] = context.Controller.GetType().FullName; - e.Data["Tapeti.Controller.Method"] = context.Binding.Method.Name; - throw; - } + var messageContext = context.ControllerMessageContext; + if (messageContext == null || !messageContext.Get(ContextItems.FlowContext, out flowContext)) + { + flowContext = new FlowContext + { + HandlerContext = context + }; - flowContext.EnsureStoreOrDeleteIsCalled(); + // If we ended up here it is because of a Start. No point in storing the new FlowContext + // in the messageContext as the yield point is the last to execute. + disposeFlowContext = true; + } + + try + { + await executableYieldPoint.Execute(flowContext); + } + catch (YieldPointException e) + { + // Useful for debugging + e.Data["Tapeti.Controller.Name"] = context.Controller.GetType().FullName; + e.Data["Tapeti.Controller.Method"] = context.Method.Name; + throw; + } + + flowContext.EnsureStoreOrDeleteIsCalled(); + } + finally + { + if (disposeFlowContext) + flowContext.Dispose(); + } } @@ -234,12 +257,12 @@ namespace Tapeti.Flow.Default } - private readonly IConfig config; + private readonly ITapetiConfig config; private readonly SendRequestFunc sendRequest; private readonly List requests = new List(); - public ParallelRequestBuilder(IConfig config, SendRequestFunc sendRequest) + public ParallelRequestBuilder(ITapetiConfig config, SendRequestFunc sendRequest) { this.config = config; this.sendRequest = sendRequest; @@ -284,12 +307,15 @@ namespace Tapeti.Flow.Default private IYieldPoint BuildYieldPoint(Delegate convergeMethod, bool convergeMethodSync) { + if (requests.Count == 0) + throw new YieldPointException("At least one request must be added before yielding a parallel request"); + if (convergeMethod?.Method == null) throw new ArgumentNullException(nameof(convergeMethod)); return new DelegateYieldPoint(context => { - if (convergeMethod.Method.DeclaringType != context.MessageContext.Controller.GetType()) + if (convergeMethod.Method.DeclaringType != context.HandlerContext.Controller.GetType()) throw new YieldPointException("Converge method must be in the same controller class"); return Task.WhenAll(requests.Select(requestInfo => @@ -306,6 +332,7 @@ namespace Tapeti.Flow.Default { public string MethodName { get; set; } public string ReplyToQueue { get; set; } + public bool IsDurableQueue { get; set; } } } } diff --git a/Tapeti.Flow/Default/FlowStarter.cs b/Tapeti.Flow/Default/FlowStarter.cs index 306f034..68c8813 100644 --- a/Tapeti.Flow/Default/FlowStarter.cs +++ b/Tapeti.Flow/Default/FlowStarter.cs @@ -3,42 +3,47 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; using Tapeti.Config; -using Tapeti.Default; namespace Tapeti.Flow.Default { - public class FlowStarter : IFlowStarter + /// + /// + /// Default implementation for IFlowStarter. + /// + internal class FlowStarter : IFlowStarter { - private readonly IConfig config; - private readonly ILogger logger; + private readonly ITapetiConfig config; - public FlowStarter(IConfig config, ILogger logger) + /// + public FlowStarter(ITapetiConfig config) { this.config = config; - this.logger = logger; } - public Task Start(Expression>> methodSelector) where TController : class + /// + public async Task Start(Expression>> methodSelector) where TController : class { - return CallControllerMethod(GetExpressionMethod(methodSelector), value => Task.FromResult((IYieldPoint)value), new object[] { }); + await CallControllerMethod(GetExpressionMethod(methodSelector), value => Task.FromResult((IYieldPoint)value), new object[] { }); } - - public Task Start(Expression>>> methodSelector) where TController : class + /// + public async Task Start(Expression>>> methodSelector) where TController : class { - return CallControllerMethod(GetExpressionMethod(methodSelector), value => (Task)value, new object[] {}); + await CallControllerMethod(GetExpressionMethod(methodSelector), value => (Task)value, new object[] {}); } - public Task Start(Expression>> methodSelector, TParameter parameter) where TController : class + /// + public async Task Start(Expression>> methodSelector, TParameter parameter) where TController : class { - return CallControllerMethod(GetExpressionMethod(methodSelector), value => Task.FromResult((IYieldPoint)value), new object[] {parameter}); + await CallControllerMethod(GetExpressionMethod(methodSelector), value => Task.FromResult((IYieldPoint)value), new object[] {parameter}); } - public Task Start(Expression>>> methodSelector, TParameter parameter) where TController : class + /// + public async Task Start(Expression>>> methodSelector, TParameter parameter) where TController : class { - return CallControllerMethod(GetExpressionMethod(methodSelector), value => (Task)value, new object[] {parameter}); + await CallControllerMethod(GetExpressionMethod(methodSelector), value => (Task)value, new object[] {parameter}); } @@ -47,42 +52,15 @@ namespace Tapeti.Flow.Default var controller = config.DependencyResolver.Resolve(); var yieldPoint = await getYieldPointResult(method.Invoke(controller, parameters)); - var context = new MessageContext + var context = new FlowHandlerContext { - DependencyResolver = config.DependencyResolver, - Controller = controller + Config = config, + Controller = controller, + Method = method }; var flowHandler = config.DependencyResolver.Resolve(); - - HandlingResultBuilder handlingResult = new HandlingResultBuilder - { - ConsumeResponse = ConsumeResponse.Nack, - }; - try - { - await flowHandler.Execute(context, yieldPoint); - handlingResult.ConsumeResponse = ConsumeResponse.Ack; - } - finally - { - await RunCleanup(context, handlingResult.ToHandlingResult()); - } - } - - private async Task RunCleanup(MessageContext context, HandlingResult handlingResult) - { - foreach (var handler in config.CleanupMiddleware) - { - try - { - await handler.Handle(context, handlingResult); - } - catch (Exception eCleanup) - { - logger.HandlerException(eCleanup); - } - } + await flowHandler.Execute(context, yieldPoint); } @@ -98,6 +76,7 @@ namespace Tapeti.Flow.Default return method; } + private static MethodInfo GetExpressionMethod(Expression>> methodSelector) { var callExpression = (methodSelector.Body as UnaryExpression)?.Operand as MethodCallExpression; diff --git a/Tapeti.Flow/Default/FlowState.cs b/Tapeti.Flow/Default/FlowState.cs index e32430c..eb7961d 100644 --- a/Tapeti.Flow/Default/FlowState.cs +++ b/Tapeti.Flow/Default/FlowState.cs @@ -4,20 +4,34 @@ using System.Linq; namespace Tapeti.Flow.Default { + /// + /// Represents the state stored for active flows. + /// public class FlowState { private FlowMetadata metadata; private Dictionary continuations; + /// + /// Contains metadata about the flow. + /// public FlowMetadata Metadata { get => metadata ?? (metadata = new FlowMetadata()); set => metadata = value; } + + /// + /// Contains the serialized state which is restored when a flow continues. + /// public string Data { get; set; } + + /// + /// Contains metadata about continuations awaiting a response. + /// public Dictionary Continuations { get => continuations ?? (continuations = new Dictionary()); @@ -25,6 +39,9 @@ namespace Tapeti.Flow.Default } + /// + /// Creates a deep clone of this FlowState. + /// public FlowState Clone() { return new FlowState { @@ -36,11 +53,20 @@ namespace Tapeti.Flow.Default } + /// + /// Contains metadata about the flow. + /// public class FlowMetadata { + /// + /// Contains information about the expected response for this flow. + /// public ReplyMetadata Reply { get; set; } + /// + /// Creates a deep clone of this FlowMetadata. + /// public FlowMetadata Clone() { return new FlowMetadata @@ -51,15 +77,36 @@ namespace Tapeti.Flow.Default } + /// + /// Contains information about the expected response for this flow. + /// public class ReplyMetadata { + /// + /// The queue to which the response should be sent. + /// public string ReplyTo { get; set; } + + /// + /// The correlation ID included in the original request. + /// public string CorrelationId { get; set; } + + /// + /// The expected response message class. + /// public string ResponseTypeName { get; set; } + /// + /// Indicates whether the response should be sent a mandatory. + /// False for requests originating from a dynamic queue. + /// public bool Mandatory { get; set; } + /// + /// Creates a deep clone of this ReplyMetadata. + /// public ReplyMetadata Clone() { return new ReplyMetadata @@ -73,13 +120,30 @@ namespace Tapeti.Flow.Default } + /// + /// Contains metadata about a continuation awaiting a response. + /// public class ContinuationMetadata { + /// + /// The name of the method which will handle the response. + /// public string MethodName { get; set; } + + /// + /// The name of the method which is called when all responses have been processed. + /// public string ConvergeMethodName { get; set; } + + /// + /// Determines if the converge method is synchronous or asynchronous. + /// public bool ConvergeMethodSync { get; set; } + /// + /// Creates a deep clone of this ContinuationMetadata. + /// public ContinuationMetadata Clone() { return new ContinuationMetadata diff --git a/Tapeti.Flow/Default/FlowStore.cs b/Tapeti.Flow/Default/FlowStore.cs index b98923d..3b0c9db 100644 --- a/Tapeti.Flow/Default/FlowStore.cs +++ b/Tapeti.Flow/Default/FlowStore.cs @@ -1,16 +1,31 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Tapeti.Flow.FlowHelpers; namespace Tapeti.Flow.Default { + /// + /// + /// Default implementation of IFlowStore. + /// public class FlowStore : IFlowStore { - private readonly ConcurrentDictionary flowStates = new ConcurrentDictionary(); + private class CachedFlowState + { + public readonly FlowState FlowState; + public readonly bool IsPersistent; + + public CachedFlowState(FlowState flowState, bool isPersistent) + { + FlowState = flowState; + IsPersistent = isPersistent; + } + } + + private readonly ConcurrentDictionary flowStates = new ConcurrentDictionary(); private readonly ConcurrentDictionary continuationLookup = new ConcurrentDictionary(); private readonly LockCollection locks = new LockCollection(EqualityComparer.Default); @@ -19,12 +34,15 @@ namespace Tapeti.Flow.Default private volatile bool inUse; private volatile bool loaded; + + /// public FlowStore(IFlowRepository repository) { this.repository = repository; } + /// public async Task Load() { if (inUse) @@ -37,7 +55,7 @@ namespace Tapeti.Flow.Default foreach (var flowStateRecord in await repository.GetStates()) { - flowStates.TryAdd(flowStateRecord.Key, flowStateRecord.Value); + flowStates.TryAdd(flowStateRecord.Key, new CachedFlowState(flowStateRecord.Value, true)); foreach (var continuation in flowStateRecord.Value.Continuations) continuationLookup.GetOrAdd(continuation.Key, flowStateRecord.Key); @@ -47,6 +65,7 @@ namespace Tapeti.Flow.Default } + /// public Task FindFlowID(Guid continuationID) { if (!loaded) @@ -56,6 +75,7 @@ namespace Tapeti.Flow.Default } + /// public async Task LockFlowState(Guid flowID) { if (!loaded) @@ -67,21 +87,23 @@ namespace Tapeti.Flow.Default return flowStatelock; } + private class FlowStateLock : IFlowStateLock { private readonly FlowStore owner; - private readonly Guid flowID; private volatile IDisposable flowLock; - private FlowState flowState; + private CachedFlowState cachedFlowState; + + public Guid FlowID { get; } public FlowStateLock(FlowStore owner, Guid flowID, IDisposable flowLock) { this.owner = owner; - this.flowID = flowID; + FlowID = flowID; this.flowLock = flowLock; - owner.flowStates.TryGetValue(flowID, out flowState); + owner.flowStates.TryGetValue(flowID, out cachedFlowState); } public void Dispose() @@ -91,17 +113,15 @@ namespace Tapeti.Flow.Default l?.Dispose(); } - public Guid FlowID => flowID; - public Task GetFlowState() { if (flowLock == null) throw new ObjectDisposedException("FlowStateLock"); - return Task.FromResult(flowState?.Clone()); + return Task.FromResult(cachedFlowState.FlowState?.Clone()); } - public async Task StoreFlowState(FlowState newFlowState) + public async Task StoreFlowState(FlowState newFlowState, bool persistent) { if (flowLock == null) throw new ObjectDisposedException("FlowStateLock"); @@ -110,30 +130,41 @@ namespace Tapeti.Flow.Default newFlowState = newFlowState.Clone(); // Update the lookup dictionary for the ContinuationIDs - if (flowState != null) + if (cachedFlowState != null) { - foreach (var removedContinuation in flowState.Continuations.Keys.Where(k => !newFlowState.Continuations.ContainsKey(k))) + foreach (var removedContinuation in cachedFlowState.FlowState.Continuations.Keys.Where(k => !newFlowState.Continuations.ContainsKey(k))) owner.continuationLookup.TryRemove(removedContinuation, out _); } - foreach (var addedContinuation in newFlowState.Continuations.Where(c => flowState == null || !flowState.Continuations.ContainsKey(c.Key))) + foreach (var addedContinuation in newFlowState.Continuations.Where(c => cachedFlowState == null || !cachedFlowState.FlowState.Continuations.ContainsKey(c.Key))) { - owner.continuationLookup.TryAdd(addedContinuation.Key, flowID); + owner.continuationLookup.TryAdd(addedContinuation.Key, FlowID); } - var isNew = flowState == null; - flowState = newFlowState; - owner.flowStates[flowID] = newFlowState; + var isNew = cachedFlowState == null; + var wasPersistent = cachedFlowState?.IsPersistent ?? false; - // Storing the flowstate in the underlying repository - if (isNew) + cachedFlowState = new CachedFlowState(newFlowState, persistent); + owner.flowStates[FlowID] = cachedFlowState; + + if (persistent) { - var now = DateTime.UtcNow; - await owner.repository.CreateState(flowID, flowState, now); + // Storing the flowstate in the underlying repository + if (isNew) + { + var now = DateTime.UtcNow; + await owner.repository.CreateState(FlowID, cachedFlowState.FlowState, now); + } + else + { + await owner.repository.UpdateState(FlowID, cachedFlowState.FlowState); + } } - else + else if (wasPersistent) { - await owner.repository.UpdateState(flowID, flowState); + // We transitioned from a durable queue to a dynamic queue, + // remove the persistent state but keep the in-memory version + await owner.repository.DeleteState(FlowID); } } @@ -142,18 +173,16 @@ namespace Tapeti.Flow.Default if (flowLock == null) throw new ObjectDisposedException("FlowStateLock"); - if (flowState != null) + if (cachedFlowState != null) { - foreach (var removedContinuation in flowState.Continuations.Keys) + foreach (var removedContinuation in cachedFlowState.FlowState.Continuations.Keys) owner.continuationLookup.TryRemove(removedContinuation, out _); - owner.flowStates.TryRemove(flowID, out _); + owner.flowStates.TryRemove(FlowID, out var removedFlowState); + cachedFlowState = null; - if (flowState != null) - { - flowState = null; - await owner.repository.DeleteState(flowID); - } + if (removedFlowState.IsPersistent) + await owner.repository.DeleteState(FlowID); } } } diff --git a/Tapeti.Flow/Default/NonPersistentFlowRepository.cs b/Tapeti.Flow/Default/NonPersistentFlowRepository.cs index b26a8c9..b20bfd6 100644 --- a/Tapeti.Flow/Default/NonPersistentFlowRepository.cs +++ b/Tapeti.Flow/Default/NonPersistentFlowRepository.cs @@ -4,6 +4,10 @@ using System.Threading.Tasks; namespace Tapeti.Flow.Default { + /// + /// + /// Default implementation for IFlowRepository. Does not persist any state, relying on the FlowStore's cache instead. + /// public class NonPersistentFlowRepository : IFlowRepository { Task>> IFlowRepository.GetStates() @@ -11,16 +15,19 @@ namespace Tapeti.Flow.Default return Task.FromResult(new List>()); } + /// public Task CreateState(Guid flowID, T state, DateTime timestamp) { return Task.CompletedTask; } + /// public Task UpdateState(Guid flowID, T state) { return Task.CompletedTask; } + /// public Task DeleteState(Guid flowID) { return Task.CompletedTask; diff --git a/Tapeti.Flow/FlowMiddleware.cs b/Tapeti.Flow/FlowExtension.cs similarity index 75% rename from Tapeti.Flow/FlowMiddleware.cs rename to Tapeti.Flow/FlowExtension.cs index ddc1d61..05a17d2 100644 --- a/Tapeti.Flow/FlowMiddleware.cs +++ b/Tapeti.Flow/FlowExtension.cs @@ -4,15 +4,21 @@ using Tapeti.Flow.Default; namespace Tapeti.Flow { - public class FlowMiddleware : ITapetiExtension + /// + /// + /// Provides the Flow middleware. + /// + public class FlowExtension : ITapetiExtension { private readonly IFlowRepository flowRepository; - public FlowMiddleware(IFlowRepository flowRepository) + /// + public FlowExtension(IFlowRepository flowRepository) { this.flowRepository = flowRepository; } + /// public void RegisterDefaults(IDependencyContainer container) { container.RegisterDefault(); @@ -22,10 +28,10 @@ namespace Tapeti.Flow container.RegisterDefaultSingleton(); } + /// public IEnumerable GetMiddleware(IDependencyResolver dependencyResolver) { yield return new FlowBindingMiddleware(); - yield return new FlowCleanupMiddleware(); } } } diff --git a/Tapeti.Flow/FlowHelpers/LockCollection.cs b/Tapeti.Flow/FlowHelpers/LockCollection.cs index b3d2bcd..d7f929a 100644 --- a/Tapeti.Flow/FlowHelpers/LockCollection.cs +++ b/Tapeti.Flow/FlowHelpers/LockCollection.cs @@ -4,22 +4,30 @@ using System.Threading.Tasks; namespace Tapeti.Flow.FlowHelpers { + /// + /// Implementation of an asynchronous locking mechanism. + /// public class LockCollection { private readonly Dictionary locks; + /// public LockCollection(IEqualityComparer comparer) { locks = new Dictionary(comparer); } + /// + /// Waits for and acquires a lock on the specified key. Dispose the returned value to release the lock. + /// + /// public Task GetLock(T key) { // ReSharper disable once InconsistentlySynchronizedField - by design - LockItem nextLi = new LockItem(locks, key); + var nextLi = new LockItem(locks, key); try { - bool continueImmediately = false; + var continueImmediately = false; lock (locks) { if (!locks.TryGetValue(key, out var li)) @@ -45,6 +53,7 @@ namespace Tapeti.Flow.FlowHelpers return nextLi.GetTask(); } + private class LockItem : IDisposable { internal volatile LockItem Next; @@ -83,7 +92,7 @@ namespace Tapeti.Flow.FlowHelpers if (li != this) { - // Something is wrong (comparer is not stable?), but we cannot loose the completions sources + // Something is wrong (comparer is not stable?), but we cannot lose the completions sources while (li.Next != null) li = li.Next; li.Next = Next; diff --git a/Tapeti.Flow/FlowHelpers/MethodSerializer.cs b/Tapeti.Flow/FlowHelpers/MethodSerializer.cs index 5635249..e8a099e 100644 --- a/Tapeti.Flow/FlowHelpers/MethodSerializer.cs +++ b/Tapeti.Flow/FlowHelpers/MethodSerializer.cs @@ -2,8 +2,15 @@ namespace Tapeti.Flow.FlowHelpers { + /// + /// Converts a method into a unique string representation. + /// public static class MethodSerializer { + /// + /// Converts a method into a unique string representation. + /// + /// public static string Serialize(MethodInfo method) { return method.Name + '@' + method.DeclaringType?.Assembly.GetName().Name + ':' + method.DeclaringType?.FullName; diff --git a/Tapeti.Flow/IFlowHandlerContext.cs b/Tapeti.Flow/IFlowHandlerContext.cs new file mode 100644 index 0000000..08cce12 --- /dev/null +++ b/Tapeti.Flow/IFlowHandlerContext.cs @@ -0,0 +1,37 @@ +using System; +using System.Reflection; +using Tapeti.Config; + +namespace Tapeti.Flow +{ + /// + /// + /// Provides information about the handler for the flow. + /// + public interface IFlowHandlerContext : IDisposable + { + /// + /// Provides access to the Tapeti config. + /// + ITapetiConfig Config { get; } + + + /// + /// An instance of the controller which starts or continues the flow. + /// + object Controller { get; } + + + /// + /// Information about the method which starts or continues the flow. + /// + MethodInfo Method { get; } + + + /// + /// Access to the controller message context if this is a continuated flow. + /// Will be null when in a starting flow. + /// + IControllerMessageContext ControllerMessageContext { get; } + } +} diff --git a/Tapeti.Flow/IFlowProvider.cs b/Tapeti.Flow/IFlowProvider.cs index c8f6982..fa5f0a6 100644 --- a/Tapeti.Flow/IFlowProvider.cs +++ b/Tapeti.Flow/IFlowProvider.cs @@ -1,55 +1,167 @@ using System; using System.Linq.Expressions; using System.Threading.Tasks; -using Tapeti.Config; // ReSharper disable UnusedMember.Global namespace Tapeti.Flow { + /// + /// Provides methods to build an IYieldPoint to indicate if and how Flow should continue. + /// public interface IFlowProvider { + /// + /// Publish a request message and continue the flow when the response arrives. + /// The request message must be marked with the [Request] attribute, and the + /// Response type must match. Used for asynchronous response handlers. + /// + /// + /// + /// + /// IYieldPoint YieldWithRequest(TRequest message, Func> responseHandler); - // One does not simply overload methods with Task vs non-Task Funcs. "Ambiguous call". - // Apparantly this is because a return type of a method is not part of its signature, - // according to: http://stackoverflow.com/questions/18715979/ambiguity-with-action-and-func-parameter + + /// + /// Publish a request message and continue the flow when the response arrives. + /// The request message must be marked with the [Request] attribute, and the + /// Response type must match. Used for synchronous response handlers. + /// + /// + /// The reason why this requires the extra 'Sync' in the name: one does not simply overload methods + /// with Task vs non-Task Funcs. "Ambiguous call". Apparantly this is because a return type + /// of a method is not part of its signature,according to: + /// http://stackoverflow.com/questions/18715979/ambiguity-with-action-and-func-parameter + /// + /// + /// + /// + /// + /// IYieldPoint YieldWithRequestSync(TRequest message, Func responseHandler); + + /// + /// Create a request builder to publish one or more requests messages. Call Yield on the resulting builder + /// to acquire an IYieldPoint. + /// IFlowParallelRequestBuilder YieldWithParallelRequest(); + + /// + /// End the flow by publishing the specified response message. Only allowed, and required, when the + /// current flow was started by a message handler for a Request message. + /// + /// + /// IYieldPoint EndWithResponse(TResponse message); + + + /// + /// End the flow and dispose any state. + /// IYieldPoint End(); } + /// /// Allows starting a flow outside of a message handler. /// public interface IFlowStarter { + /// + /// Starts a new flow. + /// + /// Task Start(Expression>> methodSelector) where TController : class; + + /// + /// Starts a new flow. + /// + /// Task Start(Expression>>> methodSelector) where TController : class; + + /// + /// Starts a new flow and passes the parameter to the method. + /// + /// + /// Task Start(Expression>> methodSelector, TParameter parameter) where TController : class; + + /// + /// Starts a new flow and passes the parameter to the method. + /// + /// + /// Task Start(Expression>>> methodSelector, TParameter parameter) where TController : class; } + /// /// Internal interface. Do not call directly. /// public interface IFlowHandler { - Task Execute(IMessageContext context, IYieldPoint yieldPoint); + /// + /// Executes the YieldPoint for the given message context. + /// + /// + /// + Task Execute(IFlowHandlerContext context, IYieldPoint yieldPoint); } + + /// + /// Builder to publish one or more request messages and continuing the flow when the responses arrive. + /// public interface IFlowParallelRequestBuilder { + /// + /// Publish a request message and continue the flow when the response arrives. + /// Note that the response handler can not influence the flow as it does not return a YieldPoint. + /// It can instead store state in the controller for the continuation passed to the Yield method. + /// Used for asynchronous response handlers. + /// + /// + /// IFlowParallelRequestBuilder AddRequest(TRequest message, Func responseHandler); + + /// + /// Publish a request message and continue the flow when the response arrives. + /// Note that the response handler can not influence the flow as it does not return a YieldPoint. + /// It can instead store state in the controller for the continuation passed to the Yield method. + /// Used for synchronous response handlers. + /// + /// + /// IFlowParallelRequestBuilder AddRequestSync(TRequest message, Action responseHandler); + /// + /// Constructs an IYieldPoint to continue the flow when responses arrive. + /// The continuation method is called when all responses have arrived. + /// Response handlers and the continuation method are guaranteed thread-safe access to the + /// controller and can store state. + /// Used for asynchronous continuation methods. + /// + /// IYieldPoint Yield(Func> continuation); + + /// + /// Constructs an IYieldPoint to continue the flow when responses arrive. + /// The continuation method is called when all responses have arrived. + /// Response handlers and the continuation method are guaranteed thread-safe access to the + /// controller and can store state. + /// Used for synchronous continuation methods. + /// + /// IYieldPoint YieldSync(Func continuation); } + + /// + /// Defines if and how the Flow should continue. Construct using any of the IFlowProvider methods. + /// public interface IYieldPoint { } diff --git a/Tapeti.Flow/IFlowRepository.cs b/Tapeti.Flow/IFlowRepository.cs index f1a29c1..f147684 100644 --- a/Tapeti.Flow/IFlowRepository.cs +++ b/Tapeti.Flow/IFlowRepository.cs @@ -4,11 +4,39 @@ using System.Threading.Tasks; namespace Tapeti.Flow { + /// + /// Provides persistency for flow states. + /// public interface IFlowRepository { + /// + /// Load the previously persisted flow states. + /// + /// A list of flow states, where the key is the unique Flow ID and the value is the deserialized T. Task>> GetStates(); + + /// + /// Stores a new flow state. Guaranteed to be run in a lock for the specified flow ID. + /// + /// + /// + /// + /// Task CreateState(Guid flowID, T state, DateTime timestamp); + + /// + /// Updates an existing flow state. Guaranteed to be run in a lock for the specified flow ID. + /// + /// + /// + /// + /// Task UpdateState(Guid flowID, T state); + + /// + /// Delete a flow state. Guaranteed to be run in a lock for the specified flow ID. + /// + /// Task DeleteState(Guid flowID); } } diff --git a/Tapeti.Flow/IFlowStore.cs b/Tapeti.Flow/IFlowStore.cs index 17da5e8..21c4337 100644 --- a/Tapeti.Flow/IFlowStore.cs +++ b/Tapeti.Flow/IFlowStore.cs @@ -6,19 +6,58 @@ using Tapeti.Flow.Default; namespace Tapeti.Flow { + /// + /// Provides a way to store and load flow state. + /// public interface IFlowStore { + /// + /// Must be called during application startup before subscribing or starting a flow. + /// If using an IFlowRepository that requires an update (such as creating tables) make + /// sure it is called before calling Load. + /// Task Load(); + + /// + /// Looks up the FlowID corresponding to a ContinuationID. For internal use. + /// + /// Task FindFlowID(Guid continuationID); + + /// + /// Acquires a lock on the flow with the specified FlowID. + /// + /// Task LockFlowState(Guid flowID); } + + /// + /// + /// Represents a lock on the flow state, to provide thread safety. + /// public interface IFlowStateLock : IDisposable { + /// + /// The unique ID of the flow state. + /// Guid FlowID { get; } + /// + /// Acquires a copy of the flow state. + /// Task GetFlowState(); - Task StoreFlowState(FlowState flowState); + + /// + /// Stores the new flow state. + /// + /// + /// + Task StoreFlowState(FlowState flowState, bool persistent); + + /// + /// Disposes of the flow state corresponding to this Flow ID. + /// Task DeleteFlowState(); } } diff --git a/Tapeti.Flow/ReSharper/JetBrains.Annotations.cs b/Tapeti.Flow/ReSharper/JetBrains.Annotations.cs index 9ec2401..ef47d40 100644 --- a/Tapeti.Flow/ReSharper/JetBrains.Annotations.cs +++ b/Tapeti.Flow/ReSharper/JetBrains.Annotations.cs @@ -38,6 +38,7 @@ using System; // ReSharper disable IntroduceOptionalParameters.Global // ReSharper disable MemberCanBeProtected.Global // ReSharper disable InconsistentNaming +// ReSharper disable InheritdocConsiderUsage // ReSharper disable once CheckNamespace namespace JetBrains.Annotations @@ -96,9 +97,9 @@ namespace JetBrains.Annotations TargetFlags = targetFlags; } - public ImplicitUseKindFlags UseKindFlags { get; private set; } + public ImplicitUseKindFlags UseKindFlags { get; } - public ImplicitUseTargetFlags TargetFlags { get; private set; } + public ImplicitUseTargetFlags TargetFlags { get; } } /// @@ -142,7 +143,7 @@ namespace JetBrains.Annotations /// InstantiatedWithFixedConstructorSignature = 4, /// Indicates implicit instantiation of a type. - InstantiatedNoFixedConstructorSignature = 8, + InstantiatedNoFixedConstructorSignature = 8 } /// @@ -174,6 +175,6 @@ namespace JetBrains.Annotations Comment = comment; } - [CanBeNull] public string Comment { get; private set; } + [CanBeNull] public string Comment { get; } } } \ No newline at end of file diff --git a/Tapeti.Flow/ResponseExpectedException.cs b/Tapeti.Flow/ResponseExpectedException.cs index c636be3..07d66b1 100644 --- a/Tapeti.Flow/ResponseExpectedException.cs +++ b/Tapeti.Flow/ResponseExpectedException.cs @@ -2,8 +2,13 @@ namespace Tapeti.Flow { + /// + /// + /// Raised when a response is expected to end a flow, but none was provided. + /// public class ResponseExpectedException : Exception { + /// public ResponseExpectedException(string message) : base(message) { } } } diff --git a/Tapeti.Flow/Tapeti.Flow.csproj b/Tapeti.Flow/Tapeti.Flow.csproj index 105aa14..d2939b4 100644 --- a/Tapeti.Flow/Tapeti.Flow.csproj +++ b/Tapeti.Flow/Tapeti.Flow.csproj @@ -3,6 +3,11 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 diff --git a/Tapeti.Flow/Tapeti.Flow.nuspec b/Tapeti.Flow/Tapeti.Flow.nuspec index 27e8251..87504d0 100644 --- a/Tapeti.Flow/Tapeti.Flow.nuspec +++ b/Tapeti.Flow/Tapeti.Flow.nuspec @@ -6,7 +6,7 @@ Tapeti Flow Menno van Lavieren, Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.Flow.png false diff --git a/Tapeti.Flow/YieldPointException.cs b/Tapeti.Flow/YieldPointException.cs index 3d07a32..2d135d0 100644 --- a/Tapeti.Flow/YieldPointException.cs +++ b/Tapeti.Flow/YieldPointException.cs @@ -2,8 +2,13 @@ namespace Tapeti.Flow { + /// + /// + /// Raised when an invalid yield point is returned. + /// public class YieldPointException : Exception { + /// public YieldPointException(string message) : base(message) { } } } diff --git a/Tapeti.Ninject/NinjectDependencyResolver.cs b/Tapeti.Ninject/NinjectDependencyResolver.cs new file mode 100644 index 0000000..7a2ac17 --- /dev/null +++ b/Tapeti.Ninject/NinjectDependencyResolver.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using Ninject; + +namespace Tapeti.Ninject +{ + /// + /// + /// Dependency resolver and container implementation for Ninject. + /// + public class NinjectDependencyResolver : IDependencyContainer + { + private readonly IKernel kernel; + + + /// + public NinjectDependencyResolver(IKernel kernel) + { + this.kernel = kernel; + } + + + /// + public T Resolve() where T : class + { + return kernel.Get(); + } + + /// + public object Resolve(Type type) + { + return kernel.Get(type); + } + + + /// + public void RegisterDefault() where TService : class where TImplementation : class, TService + { + if (kernel.GetBindings(typeof(TService)).Any()) + return; + + kernel.Bind().To(); + } + + /// + public void RegisterDefault(Func factory) where TService : class + { + if (kernel.GetBindings(typeof(TService)).Any()) + return; + + kernel.Bind().ToMethod(context => factory()); + } + + + /// + public void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService + { + if (kernel.GetBindings(typeof(TService)).Any()) + return; + + kernel.Bind().To().InSingletonScope(); + } + + /// + public void RegisterDefaultSingleton(TService instance) where TService : class + { + if (kernel.GetBindings(typeof(TService)).Any()) + return; + + kernel.Bind().ToConstant(instance); + } + + /// + public void RegisterDefaultSingleton(Func factory) where TService : class + { + if (kernel.GetBindings(typeof(TService)).Any()) + return; + + kernel.Bind().ToMethod(context => factory()).InSingletonScope(); + } + + + /// + public void RegisterController(Type type) + { + kernel.Bind(type).ToSelf(); + } + } +} diff --git a/Tapeti.Ninject/Tapeti.Ninject.csproj b/Tapeti.Ninject/Tapeti.Ninject.csproj new file mode 100644 index 0000000..914e276 --- /dev/null +++ b/Tapeti.Ninject/Tapeti.Ninject.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + true + 2.0.0 + + + + + + + + + + + diff --git a/Tapeti.Ninject/Tapeti.Ninject.nuspec b/Tapeti.Ninject/Tapeti.Ninject.nuspec new file mode 100644 index 0000000..c0709e1 --- /dev/null +++ b/Tapeti.Ninject/Tapeti.Ninject.nuspec @@ -0,0 +1,24 @@ + + + + Tapeti.Ninject + $version$ + Tapeti Ninject + Mark van Renswoude + Mark van Renswoude + Unlicense + https://github.com/MvRens/Tapeti + https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.SimpleInjector.png + false + Ninject integration package for Tapeti + + rabbitmq tapeti ninject + + + + + + + + + \ No newline at end of file diff --git a/Tapeti.Serilog/Tapeti.Serilog.csproj b/Tapeti.Serilog/Tapeti.Serilog.csproj index b33e71b..20e9996 100644 --- a/Tapeti.Serilog/Tapeti.Serilog.csproj +++ b/Tapeti.Serilog/Tapeti.Serilog.csproj @@ -3,10 +3,15 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 - + diff --git a/Tapeti.Serilog/Tapeti.Serilog.nuspec b/Tapeti.Serilog/Tapeti.Serilog.nuspec index 26fd39c..451ec08 100644 --- a/Tapeti.Serilog/Tapeti.Serilog.nuspec +++ b/Tapeti.Serilog/Tapeti.Serilog.nuspec @@ -6,7 +6,7 @@ Tapeti Serilog Hans Mulder Hans Mulder - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.Serilog.png false diff --git a/Tapeti.Serilog/TapetiSeriLogger.cs b/Tapeti.Serilog/TapetiSeriLogger.cs index 5968966..af5690b 100644 --- a/Tapeti.Serilog/TapetiSeriLogger.cs +++ b/Tapeti.Serilog/TapetiSeriLogger.cs @@ -1,46 +1,94 @@ using System; -using ISeriLogger = Serilog.ILogger; +using Tapeti.Config; +using ISerilogLogger = Serilog.ILogger; // ReSharper disable UnusedMember.Global namespace Tapeti.Serilog { + /// + /// + /// Implements the Tapeti ILogger interface for Serilog output. + /// public class TapetiSeriLogger: ILogger { - private readonly ISeriLogger seriLogger; + private readonly ISerilogLogger seriLogger; - public TapetiSeriLogger(ISeriLogger seriLogger) + + /// + public TapetiSeriLogger(ISerilogLogger seriLogger) { this.seriLogger = seriLogger; } - public void Connect(TapetiConnectionParams connectionParams) + + /// + public void Connect(IConnectContext connectContext) { - seriLogger.Information("Tapeti: trying to connect to {host}:{port}/{virtualHost}", - connectionParams.HostName, - connectionParams.Port, - connectionParams.VirtualHost); + seriLogger + .ForContext("isReconnect", connectContext.IsReconnect) + .Information("Tapeti: trying to connect to {host}:{port}/{virtualHost}", + connectContext.ConnectionParams.HostName, + connectContext.ConnectionParams.Port, + connectContext.ConnectionParams.VirtualHost); } - public void ConnectFailed(TapetiConnectionParams connectionParams, Exception exception) + /// + public void ConnectFailed(IConnectFailedContext connectContext) { - seriLogger.Error(exception, "Tapeti: could not connect to {host}:{port}/{virtualHost}", - connectionParams.HostName, - connectionParams.Port, - connectionParams.VirtualHost); + seriLogger.Error(connectContext.Exception, "Tapeti: could not connect to {host}:{port}/{virtualHost}", + connectContext.ConnectionParams.HostName, + connectContext.ConnectionParams.Port, + connectContext.ConnectionParams.VirtualHost); } - public void ConnectSuccess(TapetiConnectionParams connectionParams) + /// + public void ConnectSuccess(IConnectSuccessContext connectContext) { - seriLogger.Information("Tapeti: successfully connected to {host}:{port}/{virtualHost}", - connectionParams.HostName, - connectionParams.Port, - connectionParams.VirtualHost); + seriLogger + .ForContext("isReconnect", connectContext.IsReconnect) + .Information("Tapeti: successfully connected to {host}:{port}/{virtualHost} on local port {localPort}", + connectContext.ConnectionParams.HostName, + connectContext.ConnectionParams.Port, + connectContext.ConnectionParams.VirtualHost, + connectContext.LocalPort); } - - public void HandlerException(Exception e) + + /// + public void Disconnect(IDisconnectContext disconnectContext) { - seriLogger.Error(e, "Tapeti: exception in message handler"); + seriLogger + .Information("Tapeti: connection closed, reply text = {replyText}, reply code = {replyCode}", + disconnectContext.ReplyText, + disconnectContext.ReplyCode); + } + + /// + public void ConsumeException(Exception exception, IMessageContext messageContext, ConsumeResult consumeResult) + { + var contextLogger = seriLogger + .ForContext("consumeResult", consumeResult) + .ForContext("exchange", messageContext.Exchange) + .ForContext("queue", messageContext.Queue) + .ForContext("routingKey", messageContext.RoutingKey); + + if (messageContext is IControllerMessageContext controllerMessageContext) + { + contextLogger = contextLogger + .ForContext("controller", controllerMessageContext.Binding.Controller.FullName) + .ForContext("method", controllerMessageContext.Binding.Method.Name); + } + + contextLogger.Error(exception, "Tapeti: exception in message handler"); + } + + /// + public void QueueObsolete(string queueName, bool deleted, uint messageCount) + { + if (deleted) + seriLogger.Information("Tapeti: obsolete queue {queue} has been deleted", queueName); + else + seriLogger.Information("Tapeti: obsolete queue {queue} has been unbound but not yet deleted, {messageCount} messages remaining", queueName, messageCount); } } } diff --git a/Tapeti.SimpleInjector/SimpleInjectorDependencyResolver.cs b/Tapeti.SimpleInjector/SimpleInjectorDependencyResolver.cs index 7daacaf..1cfbaef 100644 --- a/Tapeti.SimpleInjector/SimpleInjectorDependencyResolver.cs +++ b/Tapeti.SimpleInjector/SimpleInjectorDependencyResolver.cs @@ -4,12 +4,17 @@ using SimpleInjector; namespace Tapeti.SimpleInjector { + /// + /// + /// Dependency resolver and container implementation for SimpleInjector. + /// public class SimpleInjectorDependencyResolver : IDependencyContainer { private readonly Container container; private readonly Lifestyle defaultsLifestyle; private readonly Lifestyle controllersLifestyle; + /// public SimpleInjectorDependencyResolver(Container container, Lifestyle defaultsLifestyle = null, Lifestyle controllersLifestyle = null) { this.container = container; @@ -17,17 +22,21 @@ namespace Tapeti.SimpleInjector this.controllersLifestyle = controllersLifestyle; } + + /// public T Resolve() where T : class { return container.GetInstance(); } + /// public object Resolve(Type type) { return container.GetInstance(type); } + /// public void RegisterDefault() where TService : class where TImplementation : class, TService { if (!CanRegisterDefault()) @@ -39,6 +48,7 @@ namespace Tapeti.SimpleInjector container.Register(); } + /// public void RegisterDefault(Func factory) where TService : class { if (!CanRegisterDefault()) @@ -50,24 +60,29 @@ namespace Tapeti.SimpleInjector container.Register(factory); } + + /// public void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService { if (CanRegisterDefault()) container.RegisterSingleton(); } + /// public void RegisterDefaultSingleton(TService instance) where TService : class { if (CanRegisterDefault()) container.RegisterInstance(instance); } + /// public void RegisterDefaultSingleton(Func factory) where TService : class { if (CanRegisterDefault()) container.RegisterSingleton(factory); } + /// public void RegisterController(Type type) { if (controllersLifestyle != null) diff --git a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj index ed72a19..e9fa456 100644 --- a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj +++ b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj @@ -3,10 +3,15 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 - + diff --git a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.nuspec b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.nuspec index 8bfbe3d..7005734 100644 --- a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.nuspec +++ b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.nuspec @@ -6,7 +6,7 @@ Tapeti SimpleInjector Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.SimpleInjector.png false diff --git a/Tapeti.Tests/TypeNameRoutingKeyStrategyTests.cs b/Tapeti.Tests/Default/TypeNameRoutingKeyStrategyTests.cs similarity index 98% rename from Tapeti.Tests/TypeNameRoutingKeyStrategyTests.cs rename to Tapeti.Tests/Default/TypeNameRoutingKeyStrategyTests.cs index d42ca26..d61f0b2 100644 --- a/Tapeti.Tests/TypeNameRoutingKeyStrategyTests.cs +++ b/Tapeti.Tests/Default/TypeNameRoutingKeyStrategyTests.cs @@ -2,7 +2,7 @@ using Tapeti.Default; using Xunit; -namespace Tapeti.Tests +namespace Tapeti.Tests.Default { // ReSharper disable InconsistentNaming public class TypeNameRoutingKeyStrategyTests diff --git a/Tapeti.Tests/ConnectionStringParser.cs b/Tapeti.Tests/Helpers/ConnectionStringParser.cs similarity index 98% rename from Tapeti.Tests/ConnectionStringParser.cs rename to Tapeti.Tests/Helpers/ConnectionStringParser.cs index d32240b..dfd942f 100644 --- a/Tapeti.Tests/ConnectionStringParser.cs +++ b/Tapeti.Tests/Helpers/ConnectionStringParser.cs @@ -1,9 +1,8 @@ using Tapeti.Helpers; using Xunit; -namespace Tapeti.Tests +namespace Tapeti.Tests.Helpers { - // ReSharper disable InconsistentNaming public class ConnectionStringParserTest { [Fact] @@ -127,7 +126,7 @@ namespace Tapeti.Tests AssertConnectionString("HostName=rabbit.com;Username=\"myApplication\"", new TapetiConnectionParams { HostName = "rabbit.com", - Username = "myApplication", + Username = "myApplication" }); } diff --git a/Tapeti.Tests/Tapeti.Tests.csproj b/Tapeti.Tests/Tapeti.Tests.csproj index 674cbd2..f9fa098 100644 --- a/Tapeti.Tests/Tapeti.Tests.csproj +++ b/Tapeti.Tests/Tapeti.Tests.csproj @@ -4,14 +4,25 @@ netcoreapp2.1 + + 1701;1702 + + - - - + + + + all + runtime; build; native; contentfiles; analyzers + + + + + diff --git a/Tapeti.Tests/TransientFilterMiddleware.cs b/Tapeti.Tests/TransientFilterMiddleware.cs deleted file mode 100644 index d311f03..0000000 --- a/Tapeti.Tests/TransientFilterMiddleware.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Threading.Tasks; -using Tapeti.Config; - -namespace Tapeti.Tests -{ - public class TransientFilterMiddleware : IMessageFilterMiddleware - { - public Task Handle(IMessageContext context, Func next) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/Tapeti.Transient/ConfigExtensions.cs b/Tapeti.Transient/ConfigExtensions.cs new file mode 100644 index 0000000..aba7641 --- /dev/null +++ b/Tapeti.Transient/ConfigExtensions.cs @@ -0,0 +1,24 @@ +using System; +using Tapeti.Config; + +namespace Tapeti.Transient +{ + /// + /// ITapetiConfigBuilder extension to register Tapeti.Transient + /// + public static class ConfigExtensions + { + /// + /// Registers the transient publisher and required middleware + /// + /// + /// + /// + /// + public static ITapetiConfigBuilder WithTransient(this ITapetiConfigBuilder config, TimeSpan defaultTimeout, string dynamicQueuePrefix = "transient") + { + config.Use(new TransientExtension(defaultTimeout, dynamicQueuePrefix)); + return config; + } + } +} \ No newline at end of file diff --git a/Tapeti.Transient/ConfigExtentions.cs b/Tapeti.Transient/ConfigExtentions.cs deleted file mode 100644 index 7401578..0000000 --- a/Tapeti.Transient/ConfigExtentions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace Tapeti.Transient -{ - public static class ConfigExtensions - { - public static TapetiConfig WithTransient(this TapetiConfig config, TimeSpan defaultTimeout, string dynamicQueuePrefix = "transient") - { - config.Use(new TransientMiddleware(defaultTimeout, dynamicQueuePrefix)); - return config; - } - } -} \ No newline at end of file diff --git a/Tapeti.Transient/ITransientPublisher.cs b/Tapeti.Transient/ITransientPublisher.cs index 2765259..7c01409 100644 --- a/Tapeti.Transient/ITransientPublisher.cs +++ b/Tapeti.Transient/ITransientPublisher.cs @@ -1,9 +1,20 @@ using System.Threading.Tasks; +// ReSharper disable UnusedMember.Global + namespace Tapeti.Transient { + /// + /// Provides a publisher which can send request messages, and await the response on a dynamic queue. + /// public interface ITransientPublisher { + /// + /// Sends a request and waits for the response with the timeout specified in the WithTransient config call. + /// + /// + /// + /// Task RequestResponse(TRequest request); } } \ No newline at end of file diff --git a/Tapeti.Transient/Tapeti.Transient.csproj b/Tapeti.Transient/Tapeti.Transient.csproj index f3cca6f..21f80d7 100644 --- a/Tapeti.Transient/Tapeti.Transient.csproj +++ b/Tapeti.Transient/Tapeti.Transient.csproj @@ -3,6 +3,11 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 diff --git a/Tapeti.Transient/Tapeti.Transient.nuspec b/Tapeti.Transient/Tapeti.Transient.nuspec index ad29af0..41e1fa2 100644 --- a/Tapeti.Transient/Tapeti.Transient.nuspec +++ b/Tapeti.Transient/Tapeti.Transient.nuspec @@ -6,7 +6,7 @@ Tapeti Transient Menno van Lavieren, Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.Flow.png false diff --git a/Tapeti.Transient/TransientMiddleware.cs b/Tapeti.Transient/TransientExtension.cs similarity index 56% rename from Tapeti.Transient/TransientMiddleware.cs rename to Tapeti.Transient/TransientExtension.cs index 5077fa5..2ce8477 100644 --- a/Tapeti.Transient/TransientMiddleware.cs +++ b/Tapeti.Transient/TransientExtension.cs @@ -4,29 +4,38 @@ using Tapeti.Config; namespace Tapeti.Transient { - public class TransientMiddleware : ITapetiExtension, ITapetiExtentionBinding + /// + public class TransientExtension : ITapetiExtensionBinding { - private string dynamicQueuePrefix; + private readonly string dynamicQueuePrefix; private readonly TransientRouter router; - public TransientMiddleware(TimeSpan defaultTimeout, string dynamicQueuePrefix) + + /// + public TransientExtension(TimeSpan defaultTimeout, string dynamicQueuePrefix) { this.dynamicQueuePrefix = dynamicQueuePrefix; - this.router = new TransientRouter(defaultTimeout); + router = new TransientRouter(defaultTimeout); } + + /// public void RegisterDefaults(IDependencyContainer container) { container.RegisterDefaultSingleton(router); container.RegisterDefault(); } + + /// public IEnumerable GetMiddleware(IDependencyResolver dependencyResolver) { - return new object[0]; + return null; } - public IEnumerable GetBindings(IDependencyResolver dependencyResolver) + + /// + public IEnumerable GetBindings(IDependencyResolver dependencyResolver) { yield return new TransientGenericBinding(router, dynamicQueuePrefix); } diff --git a/Tapeti.Transient/TransientGenericBinding.cs b/Tapeti.Transient/TransientGenericBinding.cs index f28643d..94c6e6b 100644 --- a/Tapeti.Transient/TransientGenericBinding.cs +++ b/Tapeti.Transient/TransientGenericBinding.cs @@ -1,52 +1,61 @@ using System; -using System.Reflection; using System.Threading.Tasks; using Tapeti.Config; namespace Tapeti.Transient { - public class TransientGenericBinding : ICustomBinding + /// + /// + /// Implements a binding for transient request response messages. + /// Register this binding using the WithTransient config extension method. + /// + internal class TransientGenericBinding : IBinding { private readonly TransientRouter router; + private readonly string dynamicQueuePrefix; + /// + public string QueueName { get; private set; } + + /// + public QueueType QueueType => QueueType.Dynamic; + + + /// public TransientGenericBinding(TransientRouter router, string dynamicQueuePrefix) { this.router = router; - DynamicQueuePrefix = dynamicQueuePrefix; - Method = typeof(TransientRouter).GetMethod("GenericHandleResponse"); + this.dynamicQueuePrefix = dynamicQueuePrefix; } - public Type Controller => typeof(TransientRouter); - public MethodInfo Method { get; } + /// + public async Task Apply(IBindingTarget target) + { + QueueName = await target.BindDynamicDirect(dynamicQueuePrefix); + router.TransientResponseQueueName = QueueName; + } - public QueueBindingMode QueueBindingMode => QueueBindingMode.DirectToQueue; - - public string StaticQueueName => null; - - public string DynamicQueuePrefix { get; } - - public Type MessageClass => null; + /// public bool Accept(Type messageClass) { return true; } - public bool Accept(IMessageContext context, object message) - { - return true; - } - public Task Invoke(IMessageContext context, object message) + /// + public Task Invoke(IMessageContext context) { - router.GenericHandleResponse(message, context); + router.HandleMessage(context); return Task.CompletedTask; } - public void SetQueueName(string queueName) + + /// + public Task Cleanup(IMessageContext context, ConsumeResult consumeResult) { - router.TransientResponseQueueName = queueName; + return Task.CompletedTask; } } } \ No newline at end of file diff --git a/Tapeti.Transient/TransientPublisher.cs b/Tapeti.Transient/TransientPublisher.cs index 62715e7..525e887 100644 --- a/Tapeti.Transient/TransientPublisher.cs +++ b/Tapeti.Transient/TransientPublisher.cs @@ -1,24 +1,29 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; namespace Tapeti.Transient { - public class TransientPublisher : ITransientPublisher + /// + /// + /// Default implementation of ITransientPublisher + /// + internal class TransientPublisher : ITransientPublisher { private readonly TransientRouter router; private readonly IPublisher publisher; + + /// public TransientPublisher(TransientRouter router, IPublisher publisher) { this.router = router; this.publisher = publisher; } + + /// public async Task RequestResponse(TRequest request) { - return (TResponse)(await router.RequestResponse(publisher, request)); + return (TResponse)await router.RequestResponse(publisher, request); } } } diff --git a/Tapeti.Transient/TransientRouter.cs b/Tapeti.Transient/TransientRouter.cs index 775576b..82e8eb3 100644 --- a/Tapeti.Transient/TransientRouter.cs +++ b/Tapeti.Transient/TransientRouter.cs @@ -2,26 +2,37 @@ using System.Collections.Concurrent; using System.Threading.Tasks; using System.Threading; -using RabbitMQ.Client.Framing; using Tapeti.Config; +using Tapeti.Default; namespace Tapeti.Transient { - public class TransientRouter + /// + /// Manages active requests and responses. For internal use. + /// + internal class TransientRouter { private readonly int defaultTimeoutMs; - private readonly ConcurrentDictionary> map = new ConcurrentDictionary>(); - + /// + /// The generated name of the dynamic queue to which responses should be sent. + /// public string TransientResponseQueueName { get; set; } + + /// public TransientRouter(TimeSpan defaultTimeout) { defaultTimeoutMs = (int)defaultTimeout.TotalMilliseconds; } - public void GenericHandleResponse(object response, IMessageContext context) + + /// + /// Processes incoming messages to complete the corresponding request task. + /// + /// + public void HandleMessage(IMessageContext context) { if (context.Properties.CorrelationId == null) return; @@ -30,9 +41,16 @@ namespace Tapeti.Transient return; if (map.TryRemove(continuationID, out var tcs)) - tcs.SetResult(response); + tcs.SetResult(context.Message); } + + /// + /// Sends a request and waits for the response. Do not call directly, instead use ITransientPublisher.RequestResponse. + /// + /// + /// + /// public async Task RequestResponse(IPublisher publisher, object request) { var correlation = Guid.NewGuid(); @@ -40,7 +58,7 @@ namespace Tapeti.Transient try { - var properties = new BasicProperties + var properties = new MessageProperties { CorrelationId = correlation.ToString(), ReplyTo = TransientResponseQueueName, @@ -64,6 +82,7 @@ namespace Tapeti.Transient } } + private void TimeoutResponse(object tcs) { ((TaskCompletionSource)tcs).SetException(new TimeoutException("Transient RequestResponse timed out at (ms) " + defaultTimeoutMs)); diff --git a/Tapeti.UnityContainer/Tapeti.UnityContainer.csproj b/Tapeti.UnityContainer/Tapeti.UnityContainer.csproj new file mode 100644 index 0000000..fe9aab8 --- /dev/null +++ b/Tapeti.UnityContainer/Tapeti.UnityContainer.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + true + 2.0.0 + + + + + + + + + + + diff --git a/Tapeti.UnityContainer/Tapeti.UnityContainer.nuspec b/Tapeti.UnityContainer/Tapeti.UnityContainer.nuspec new file mode 100644 index 0000000..356278a --- /dev/null +++ b/Tapeti.UnityContainer/Tapeti.UnityContainer.nuspec @@ -0,0 +1,24 @@ + + + + Tapeti.UnityContainer + $version$ + Tapeti UnityContainer + Mark van Renswoude + Mark van Renswoude + Unlicense + https://github.com/MvRens/Tapeti + https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.SimpleInjector.png + false + Unity container integration package for Tapeti + + rabbitmq tapeti unity + + + + + + + + + \ No newline at end of file diff --git a/Tapeti.UnityContainer/UnityDependencyResolver.cs b/Tapeti.UnityContainer/UnityDependencyResolver.cs new file mode 100644 index 0000000..98db82b --- /dev/null +++ b/Tapeti.UnityContainer/UnityDependencyResolver.cs @@ -0,0 +1,89 @@ +using System; +using Unity; +using Unity.Lifetime; + +namespace Tapeti.UnityContainer +{ + /// + /// + /// Dependency resolver and container implementation for SimpleInjector. + /// + public class UnityDependencyResolver : IDependencyContainer + { + private readonly IUnityContainer container; + + + /// + public UnityDependencyResolver(IUnityContainer container) + { + this.container = container; + } + + + /// + public T Resolve() where T : class + { + return container.Resolve(); + } + + /// + public object Resolve(Type type) + { + return container.Resolve(type); + } + + + /// + public void RegisterDefault() where TService : class where TImplementation : class, TService + { + if (container.IsRegistered(typeof(TService))) + return; + + container.RegisterType(); + } + + /// + public void RegisterDefault(Func factory) where TService : class + { + if (container.IsRegistered(typeof(TService))) + return; + + container.RegisterFactory(c => factory()); + } + + + /// + public void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService + { + if (container.IsRegistered(typeof(TService))) + return; + + container.RegisterSingleton(); + } + + /// + public void RegisterDefaultSingleton(TService instance) where TService : class + { + if (container.IsRegistered(typeof(TService))) + return; + + container.RegisterInstance(instance); + } + + /// + public void RegisterDefaultSingleton(Func factory) where TService : class + { + if (container.IsRegistered(typeof(TService))) + return; + + container.RegisterFactory(c => factory(), new SingletonLifetimeManager()); + } + + + /// + public void RegisterController(Type type) + { + container.RegisterType(type); + } + } +} diff --git a/Tapeti.png b/Tapeti.png new file mode 100644 index 0000000..b4b8350 Binary files /dev/null and b/Tapeti.png differ diff --git a/Tapeti.sln b/Tapeti.sln index d870188..c3e5cf3 100644 --- a/Tapeti.sln +++ b/Tapeti.sln @@ -15,15 +15,47 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Flow.SQL", "Tapeti.F EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.SimpleInjector", "Tapeti.SimpleInjector\Tapeti.SimpleInjector.csproj", "{A190C736-E95A-4BDA-AA80-6211226DFCAD}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Test", "Test\Test.csproj", "{1A4B7136-B7DF-41EA-BEA2-E87B4607D420}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Tests", "Tapeti.Tests\Tapeti.Tests.csproj", "{334F3715-63CF-4D13-B09A-38E2A616D4F5}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Serilog", "Tapeti.Serilog\Tapeti.Serilog.csproj", "{43AA5DF3-49D5-4795-A290-D6511502B564}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Transient", "Tapeti.Transient\Tapeti.Transient.csproj", "{A6355E63-19AB-47EA-91FA-49B5E9B41F88}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tapeti.DataAnnotations.Extensions", "Tapeti.DataAnnotations.Extensions\Tapeti.DataAnnotations.Extensions.csproj", "{1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.DataAnnotations.Extensions", "Tapeti.DataAnnotations.Extensions\Tapeti.DataAnnotations.Extensions.csproj", "{1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{266B9B94-A4D2-41C2-860C-24A7C3B63B56}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "01-PublishSubscribe", "Examples\01-PublishSubscribe\01-PublishSubscribe.csproj", "{8350A0AB-F0EE-48CF-9CA6-6019467101CF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleLib", "Examples\ExampleLib\ExampleLib.csproj", "{F3B38753-06B4-4932-84B4-A07692AD802D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Messaging.TapetiExample", "Examples\Messaging.TapetiExample\Messaging.TapetiExample.csproj", "{D24120D4-50A2-44B6-A4EA-6ADAAEBABA84}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "02-DeclareDurableQueues", "Examples\02-DeclareDurableQueues\02-DeclareDurableQueues.csproj", "{85511282-EF91-4B56-B7DC-9E8706556D6E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "03-FlowRequestResponse", "Examples\03-FlowRequestResponse\03-FlowRequestResponse.csproj", "{463A12CE-E221-450D-ADEA-91A599612DFA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "04-Transient", "Examples\04-Transient\04-Transient.csproj", "{46DFC131-A398-435F-A7DF-3C41B656BF11}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "05-SpeedTest", "Examples\05-SpeedTest\05-SpeedTest.csproj", "{330D05CE-5321-4C7D-8017-2070B891289E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IoC", "IoC", "{99380F97-AD1A-459F-8AB3-D404E1E6AD4F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{8E757FF7-F6D7-42B1-827F-26FA95D97803}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{57996ADC-18C5-4991-9F95-58D58D442461}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.CastleWindsor", "Tapeti.CastleWindsor\Tapeti.CastleWindsor.csproj", "{374AAE64-598B-4F67-8870-4A05168FF987}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Autofac", "Tapeti.Autofac\Tapeti.Autofac.csproj", "{B3802005-C941-41B6-A9A5-20573A7C24AE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.UnityContainer", "Tapeti.UnityContainer\Tapeti.UnityContainer.csproj", "{BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tapeti.Ninject", "Tapeti.Ninject\Tapeti.Ninject.csproj", "{29478B10-FC53-4E93-ADEF-A775D9408131}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{62002327-46B0-4B72-B95A-594CE7F8C80D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tapeti.Cmd", "Tapeti.Cmd\Tapeti.Cmd.csproj", "{C8728BFC-7F97-41BC-956B-690A57B634EC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -55,10 +87,6 @@ Global {A190C736-E95A-4BDA-AA80-6211226DFCAD}.Debug|Any CPU.Build.0 = Debug|Any CPU {A190C736-E95A-4BDA-AA80-6211226DFCAD}.Release|Any CPU.ActiveCfg = Release|Any CPU {A190C736-E95A-4BDA-AA80-6211226DFCAD}.Release|Any CPU.Build.0 = Release|Any CPU - {1A4B7136-B7DF-41EA-BEA2-E87B4607D420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1A4B7136-B7DF-41EA-BEA2-E87B4607D420}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A4B7136-B7DF-41EA-BEA2-E87B4607D420}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1A4B7136-B7DF-41EA-BEA2-E87B4607D420}.Release|Any CPU.Build.0 = Release|Any CPU {334F3715-63CF-4D13-B09A-38E2A616D4F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {334F3715-63CF-4D13-B09A-38E2A616D4F5}.Debug|Any CPU.Build.0 = Debug|Any CPU {334F3715-63CF-4D13-B09A-38E2A616D4F5}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -75,10 +103,81 @@ Global {1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831}.Debug|Any CPU.Build.0 = Debug|Any CPU {1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831}.Release|Any CPU.ActiveCfg = Release|Any CPU {1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831}.Release|Any CPU.Build.0 = Release|Any CPU + {8350A0AB-F0EE-48CF-9CA6-6019467101CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8350A0AB-F0EE-48CF-9CA6-6019467101CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8350A0AB-F0EE-48CF-9CA6-6019467101CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8350A0AB-F0EE-48CF-9CA6-6019467101CF}.Release|Any CPU.Build.0 = Release|Any CPU + {F3B38753-06B4-4932-84B4-A07692AD802D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3B38753-06B4-4932-84B4-A07692AD802D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3B38753-06B4-4932-84B4-A07692AD802D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3B38753-06B4-4932-84B4-A07692AD802D}.Release|Any CPU.Build.0 = Release|Any CPU + {D24120D4-50A2-44B6-A4EA-6ADAAEBABA84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D24120D4-50A2-44B6-A4EA-6ADAAEBABA84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D24120D4-50A2-44B6-A4EA-6ADAAEBABA84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D24120D4-50A2-44B6-A4EA-6ADAAEBABA84}.Release|Any CPU.Build.0 = Release|Any CPU + {85511282-EF91-4B56-B7DC-9E8706556D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85511282-EF91-4B56-B7DC-9E8706556D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85511282-EF91-4B56-B7DC-9E8706556D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85511282-EF91-4B56-B7DC-9E8706556D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {463A12CE-E221-450D-ADEA-91A599612DFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {463A12CE-E221-450D-ADEA-91A599612DFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {463A12CE-E221-450D-ADEA-91A599612DFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {463A12CE-E221-450D-ADEA-91A599612DFA}.Release|Any CPU.Build.0 = Release|Any CPU + {46DFC131-A398-435F-A7DF-3C41B656BF11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46DFC131-A398-435F-A7DF-3C41B656BF11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46DFC131-A398-435F-A7DF-3C41B656BF11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46DFC131-A398-435F-A7DF-3C41B656BF11}.Release|Any CPU.Build.0 = Release|Any CPU + {330D05CE-5321-4C7D-8017-2070B891289E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {330D05CE-5321-4C7D-8017-2070B891289E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {330D05CE-5321-4C7D-8017-2070B891289E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {330D05CE-5321-4C7D-8017-2070B891289E}.Release|Any CPU.Build.0 = Release|Any CPU + {374AAE64-598B-4F67-8870-4A05168FF987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {374AAE64-598B-4F67-8870-4A05168FF987}.Debug|Any CPU.Build.0 = Debug|Any CPU + {374AAE64-598B-4F67-8870-4A05168FF987}.Release|Any CPU.ActiveCfg = Release|Any CPU + {374AAE64-598B-4F67-8870-4A05168FF987}.Release|Any CPU.Build.0 = Release|Any CPU + {B3802005-C941-41B6-A9A5-20573A7C24AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3802005-C941-41B6-A9A5-20573A7C24AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3802005-C941-41B6-A9A5-20573A7C24AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3802005-C941-41B6-A9A5-20573A7C24AE}.Release|Any CPU.Build.0 = Release|Any CPU + {BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E}.Release|Any CPU.Build.0 = Release|Any CPU + {29478B10-FC53-4E93-ADEF-A775D9408131}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29478B10-FC53-4E93-ADEF-A775D9408131}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29478B10-FC53-4E93-ADEF-A775D9408131}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29478B10-FC53-4E93-ADEF-A775D9408131}.Release|Any CPU.Build.0 = Release|Any CPU + {C8728BFC-7F97-41BC-956B-690A57B634EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8728BFC-7F97-41BC-956B-690A57B634EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8728BFC-7F97-41BC-956B-690A57B634EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8728BFC-7F97-41BC-956B-690A57B634EC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4B742AB2-59DD-4792-8E0F-D80B5366B844} = {8E757FF7-F6D7-42B1-827F-26FA95D97803} + {2952B141-C54D-4E6F-8108-CAD735B0279F} = {8E757FF7-F6D7-42B1-827F-26FA95D97803} + {6504D430-AB4A-4DE3-AE76-0384591BEEE7} = {57996ADC-18C5-4991-9F95-58D58D442461} + {14CF8F01-570B-4B84-AB4A-E0C3EC117F89} = {57996ADC-18C5-4991-9F95-58D58D442461} + {775CAB72-F443-442E-8E10-313B2548EDF8} = {57996ADC-18C5-4991-9F95-58D58D442461} + {A190C736-E95A-4BDA-AA80-6211226DFCAD} = {99380F97-AD1A-459F-8AB3-D404E1E6AD4F} + {43AA5DF3-49D5-4795-A290-D6511502B564} = {57996ADC-18C5-4991-9F95-58D58D442461} + {A6355E63-19AB-47EA-91FA-49B5E9B41F88} = {57996ADC-18C5-4991-9F95-58D58D442461} + {1AAA5A2C-EAA8-4C49-96A6-673EA1EEE831} = {57996ADC-18C5-4991-9F95-58D58D442461} + {8350A0AB-F0EE-48CF-9CA6-6019467101CF} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {F3B38753-06B4-4932-84B4-A07692AD802D} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {D24120D4-50A2-44B6-A4EA-6ADAAEBABA84} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {85511282-EF91-4B56-B7DC-9E8706556D6E} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {463A12CE-E221-450D-ADEA-91A599612DFA} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {46DFC131-A398-435F-A7DF-3C41B656BF11} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {330D05CE-5321-4C7D-8017-2070B891289E} = {266B9B94-A4D2-41C2-860C-24A7C3B63B56} + {374AAE64-598B-4F67-8870-4A05168FF987} = {99380F97-AD1A-459F-8AB3-D404E1E6AD4F} + {B3802005-C941-41B6-A9A5-20573A7C24AE} = {99380F97-AD1A-459F-8AB3-D404E1E6AD4F} + {BA8CA9A2-BAFF-42BB-8439-3DD9D1F6C32E} = {99380F97-AD1A-459F-8AB3-D404E1E6AD4F} + {29478B10-FC53-4E93-ADEF-A775D9408131} = {99380F97-AD1A-459F-8AB3-D404E1E6AD4F} + {C8728BFC-7F97-41BC-956B-690A57B634EC} = {62002327-46B0-4B72-B95A-594CE7F8C80D} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B09CC2BF-B2AF-4CB6-8728-5D1D8E5C50FA} EndGlobalSection diff --git a/Tapeti.sln.DotSettings b/Tapeti.sln.DotSettings index 31b78dd..4a2b131 100644 --- a/Tapeti.sln.DotSettings +++ b/Tapeti.sln.DotSettings @@ -1,6 +1,12 @@ - + + False + API ID + JSON KV SQL <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - \ No newline at end of file + True + True + True + True \ No newline at end of file diff --git a/Tapeti/Config/IBinding.cs b/Tapeti/Config/IBinding.cs new file mode 100644 index 0000000..7d924fa --- /dev/null +++ b/Tapeti/Config/IBinding.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + /// + /// Determines the type of queue the binding registers + /// + public enum QueueType + { + /// + /// The consumed queue is durable + /// + Durable, + + /// + /// The consumed queue is dynamic + /// + Dynamic + } + + + /// + /// Represents a registered binding to handle incoming messages. + /// + public interface IBinding + { + /// + /// The name of the queue the binding is consuming. May change after a reconnect for dynamic queues. + /// + string QueueName { get; } + + + /// + /// Determines the type of queue the binding registers + /// + QueueType QueueType { get; } + + + /// + /// Called after a connection is established to set up the binding. + /// + /// + Task Apply(IBindingTarget target); + + + /// + /// Determines if the message as specified by the message class can be handled by this binding. + /// + /// + bool Accept(Type messageClass); + + + /// + /// Invokes the handler for the message as specified by the context. + /// + /// + Task Invoke(IMessageContext context); + + + /// + /// Called after the handler is invoked and any exception handling has been done. + /// + /// + /// + /// + Task Cleanup(IMessageContext context, ConsumeResult consumeResult); + } + + + + /// + /// Allows the binding to specify to which queue it should bind to and how. + /// At most one of these methods can be called, calling a second method will result in an exception. + /// + public interface IBindingTarget + { + /// + /// Binds the messageClass to the specified durable queue. + /// + /// The message class to be bound to the queue + /// The name of the durable queue + Task BindDurable(Type messageClass, string queueName); + + /// + /// Binds the messageClass to a dynamic auto-delete queue. + /// + /// + /// Dynamic bindings for different messageClasses will be bundled into a single dynamic queue. + /// Specifying a different queuePrefix is a way to force bindings into separate queues. + /// + /// The message class to be bound to the queue + /// An optional prefix for the dynamic queue's name. If not provided, RabbitMQ's default logic will be used to create an amq.gen queue. + /// The generated name of the dynamic queue + Task BindDynamic(Type messageClass, string queuePrefix = null); + + /// + /// Declares a durable queue but does not add a binding for a messageClass' routing key. + /// Used for direct-to-queue messages. + /// + /// The name of the durable queue + Task BindDurableDirect(string queueName); + + /// + /// Declares a dynamic queue but does not add a binding for a messageClass' routing key. + /// Used for direct-to-queue messages. The messageClass is used to ensure each queue only handles unique message types. + /// + /// The message class which will be handled on the queue. It is not actually bound to the queue. + /// An optional prefix for the dynamic queue's name. If not provided, RabbitMQ's default logic will be used to create an amq.gen queue. + /// The generated name of the dynamic queue + Task BindDynamicDirect(Type messageClass = null, string queuePrefix = null); + + /// + /// Declares a dynamic queue but does not add a binding for a messageClass' routing key. + /// Used for direct-to-queue messages. Guarantees a unique queue. + /// + /// An optional prefix for the dynamic queue's name. If not provided, RabbitMQ's default logic will be used to create an amq.gen queue. + /// The generated name of the dynamic queue + Task BindDynamicDirect(string queuePrefix = null); + + /// + /// Marks the specified durable queue as having an obsolete binding. If after all bindings have subscribed, the queue only contains obsolete + /// bindings and is empty, it will be removed. + /// + /// The name of the durable queue + Task BindDurableObsolete(string queueName); + } +} diff --git a/Tapeti/Config/IBindingContext.cs b/Tapeti/Config/IBindingContext.cs deleted file mode 100644 index b5cc3b7..0000000 --- a/Tapeti/Config/IBindingContext.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; - -namespace Tapeti.Config -{ - public delegate object ValueFactory(IMessageContext context); - public delegate Task ResultHandler(IMessageContext context, object value); - - - public enum QueueBindingMode - { - /// - /// Allow binding of the routing key from the message's source exchange to the queue - /// - RoutingKey, - - /// - /// Do not bind, rely on the direct-to-queue exchange - /// - DirectToQueue - } - - - public interface IBindingContext - { - Type MessageClass { get; set; } - - MethodInfo Method { get; } - IReadOnlyList Parameters { get; } - IBindingResult Result { get; } - - QueueBindingMode QueueBindingMode { get; set; } - - void Use(IMessageFilterMiddleware filterMiddleware); - void Use(IMessageMiddleware middleware); - } - - - public interface IBindingParameter - { - ParameterInfo Info { get; } - bool HasBinding { get; } - - void SetBinding(ValueFactory valueFactory); - } - - - public interface IBindingResult - { - ParameterInfo Info { get; } - bool HasHandler { get; } - - void SetHandler(ResultHandler resultHandler); - } -} diff --git a/Tapeti/Config/IBindingMiddleware.cs b/Tapeti/Config/IBindingMiddleware.cs deleted file mode 100644 index e2d977c..0000000 --- a/Tapeti/Config/IBindingMiddleware.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Tapeti.Config -{ - public interface IBindingMiddleware - { - void Handle(IBindingContext context, Action next); - } -} diff --git a/Tapeti/Config/ICleanupMiddleware.cs b/Tapeti/Config/ICleanupMiddleware.cs deleted file mode 100644 index 132991b..0000000 --- a/Tapeti/Config/ICleanupMiddleware.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Threading.Tasks; - -namespace Tapeti.Config -{ - public interface ICleanupMiddleware - { - Task Handle(IMessageContext context, HandlingResult handlingResult); - } -} diff --git a/Tapeti/Config/IConfig.cs b/Tapeti/Config/IConfig.cs deleted file mode 100644 index 1cdaad7..0000000 --- a/Tapeti/Config/IConfig.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; - -namespace Tapeti.Config -{ - public interface IConfig - { - bool UsePublisherConfirms { get; } - - IDependencyResolver DependencyResolver { get; } - IReadOnlyList MessageMiddleware { get; } - IReadOnlyList CleanupMiddleware { get; } - IReadOnlyList PublishMiddleware { get; } - IEnumerable Queues { get; } - - IBinding GetBinding(Delegate method); - } - - - public interface IQueue - { - bool Dynamic { get; } - string Name { get; } - - IEnumerable Bindings { get; } - } - - - public interface IDynamicQueue : IQueue - { - string GetDeclareQueueName(); - void SetName(string name); - } - - - public interface IBinding - { - Type Controller { get; } - MethodInfo Method { get; } - Type MessageClass { get; } - string QueueName { get; } - QueueBindingMode QueueBindingMode { get; set; } - - IReadOnlyList MessageFilterMiddleware { get; } - IReadOnlyList MessageMiddleware { get; } - - bool Accept(Type messageClass); - bool Accept(IMessageContext context, object message); - Task Invoke(IMessageContext context, object message); - } - - - public interface IBuildBinding : IBinding - { - void SetQueueName(string queueName); - } -} diff --git a/Tapeti/Config/IControllerBindingContext.cs b/Tapeti/Config/IControllerBindingContext.cs new file mode 100644 index 0000000..1c21cd7 --- /dev/null +++ b/Tapeti/Config/IControllerBindingContext.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +// ReSharper disable UnusedMember.Global + +namespace Tapeti.Config +{ + /// + /// Injects a value for a controller method parameter. + /// + /// + public delegate object ValueFactory(IControllerMessageContext context); + + + /// + /// Handles the return value of a controller method. + /// + /// + /// + public delegate Task ResultHandler(IControllerMessageContext context, object value); + + + /// + /// Determines how the binding target is configured. + /// + public enum BindingTargetMode + { + /// + /// Bind to a queue using the message's routing key + /// + Default, + + /// + /// Bind to a queue without registering the message's routing key + /// + Direct + } + + + /// + /// Provides information about the controller and method being registered. + /// + public interface IControllerBindingContext + { + /// + /// The message class for this method. Can be null if not yet set by the default MessageBinding or other middleware. + /// If required, call next first to ensure it is available. + /// + Type MessageClass { get; } + + /// + /// Determines if SetMessageClass has already been called. + /// + bool HasMessageClass { get; } + + /// + /// The controller class for this binding. + /// + Type Controller { get; } + + /// + /// The method for this binding. + /// + MethodInfo Method { get; } + + /// + /// The list of parameters passed to the method. + /// + IReadOnlyList Parameters { get; } + + /// + /// The return type of the method. + /// + IBindingResult Result { get; } + + + /// + /// Sets the message class for this method. Can only be called once, which is normally done by the default MessageBinding. + /// + /// + void SetMessageClass(Type messageClass); + + + /// + /// Determines how the binding target is configured. Can only be called once. Defaults to 'Default'. + /// + /// + void SetBindingTargetMode(BindingTargetMode mode); + + + /// + /// Add middleware specific to this method. + /// + /// + void Use(IControllerMiddlewareBase handler); + } + + + /// + /// Information about a method parameter and how it gets it's value. + /// + public interface IBindingParameter + { + /// + /// Reference to the reflection info for this parameter. + /// + ParameterInfo Info { get; } + + /// + /// Determines if a binding has been set. + /// + bool HasBinding { get; } + + /// + /// Sets the binding for this parameter. Can only be called once. + /// + /// + void SetBinding(ValueFactory valueFactory); + } + + + /// + /// Information about the return type of a method. + /// + public interface IBindingResult + { + /// + /// Reference to the reflection info for this return value. + /// + ParameterInfo Info { get; } + + /// + /// Determines if a handler has been set. + /// + bool HasHandler { get; } + + /// + /// Sets the handler for this return type. Can only be called once. + /// + /// + void SetHandler(ResultHandler resultHandler); + } +} diff --git a/Tapeti/Config/IControllerBindingMiddleware.cs b/Tapeti/Config/IControllerBindingMiddleware.cs new file mode 100644 index 0000000..d88c951 --- /dev/null +++ b/Tapeti/Config/IControllerBindingMiddleware.cs @@ -0,0 +1,19 @@ +using System; + +namespace Tapeti.Config +{ + /// + /// + /// Called when a Controller method is registered. + /// + public interface IControllerBindingMiddleware : IControllerMiddlewareBase + { + /// + /// Called before a Controller method is registered. Can change the way parameters and return values are handled, + /// and can inject message middleware specific to a method. + /// + /// + /// Must be called to activate the new layer of middleware. + void Handle(IControllerBindingContext context, Action next); + } +} diff --git a/Tapeti/Config/IControllerCleanupMiddleware.cs b/Tapeti/Config/IControllerCleanupMiddleware.cs new file mode 100644 index 0000000..c089b82 --- /dev/null +++ b/Tapeti/Config/IControllerCleanupMiddleware.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + /// + /// Denotes middleware that runs after controller methods. + /// + public interface IControllerCleanupMiddleware + { + /// + /// Called after the message handler method, even if exceptions occured. + /// + /// + /// + /// Always call to allow the next in the chain to clean up + Task Cleanup(IMessageContext context, ConsumeResult consumeResult, Func next); + } +} diff --git a/Tapeti/Config/IControllerFilterMiddleware.cs b/Tapeti/Config/IControllerFilterMiddleware.cs new file mode 100644 index 0000000..ec8391a --- /dev/null +++ b/Tapeti/Config/IControllerFilterMiddleware.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + /// + /// + /// Denotes middleware that runs before the controller is instantiated. + /// + public interface IControllerFilterMiddleware : IControllerMiddlewareBase + { + /// + /// Called before the + /// + /// + /// + /// + Task Filter(IControllerMessageContext context, Func next); + } +} diff --git a/Tapeti/Config/IControllerMessageContext.cs b/Tapeti/Config/IControllerMessageContext.cs new file mode 100644 index 0000000..3eec194 --- /dev/null +++ b/Tapeti/Config/IControllerMessageContext.cs @@ -0,0 +1,20 @@ +namespace Tapeti.Config +{ + /// + /// + /// Extends the message context with information about the controller. + /// + public interface IControllerMessageContext : IMessageContext + { + /// + /// An instance of the controller referenced by the binding. + /// + object Controller { get; } + + + /// + /// Provides access to the binding which is currently processing the message. + /// + new IControllerMethodBinding Binding { get; } + } +} diff --git a/Tapeti/Config/IControllerMessageMiddleware.cs b/Tapeti/Config/IControllerMessageMiddleware.cs new file mode 100644 index 0000000..a252081 --- /dev/null +++ b/Tapeti/Config/IControllerMessageMiddleware.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + /// + /// Denotes middleware that runs for controller methods. + /// + public interface IControllerMessageMiddleware + { + /// + /// Called after the message has passed any filter middleware and the controller has been instantiated, + /// but before the method has been called. + /// + /// + /// Call to pass the message to the next handler in the chain or call the controller method + Task Handle(IControllerMessageContext context, Func next); + } +} diff --git a/Tapeti/Config/IControllerMethodBinding.cs b/Tapeti/Config/IControllerMethodBinding.cs new file mode 100644 index 0000000..0fb4ce5 --- /dev/null +++ b/Tapeti/Config/IControllerMethodBinding.cs @@ -0,0 +1,22 @@ +using System; +using System.Reflection; + +namespace Tapeti.Config +{ + /// + /// + /// Represents a binding to a method in a controller class to handle incoming messages. + /// + public interface IControllerMethodBinding : IBinding + { + /// + /// The controller class. + /// + Type Controller { get; } + + /// + /// The method on the Controller class to which this binding is bound. + /// + MethodInfo Method { get; } + } +} diff --git a/Tapeti/Config/IControllerMiddlewareBase.cs b/Tapeti/Config/IControllerMiddlewareBase.cs new file mode 100644 index 0000000..acb5de1 --- /dev/null +++ b/Tapeti/Config/IControllerMiddlewareBase.cs @@ -0,0 +1,9 @@ +namespace Tapeti.Config +{ + /// + /// Base interface for all controller middleware. Implement at least one of the descendant interfaces to be able to register. + /// + public interface IControllerMiddlewareBase + { + } +} diff --git a/Tapeti/Config/ICustomBinding.cs b/Tapeti/Config/ICustomBinding.cs deleted file mode 100644 index 8b39247..0000000 --- a/Tapeti/Config/ICustomBinding.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace Tapeti.Config -{ - public interface ICustomBinding - { - Type Controller { get; } - - MethodInfo Method { get; } - - QueueBindingMode QueueBindingMode { get; } - - string StaticQueueName { get; } - - string DynamicQueuePrefix { get; } - - Type MessageClass { get; } // Needed to get routing key information when QueueBindingMode = RoutingKey - - bool Accept(Type messageClass); - - bool Accept(IMessageContext context, object message); - - Task Invoke(IMessageContext context, object message); - - void SetQueueName(string queueName); - } -} diff --git a/Tapeti/Config/IExceptionStrategyContext.cs b/Tapeti/Config/IExceptionStrategyContext.cs index 4aae1fd..e418a96 100644 --- a/Tapeti/Config/IExceptionStrategyContext.cs +++ b/Tapeti/Config/IExceptionStrategyContext.cs @@ -4,12 +4,26 @@ namespace Tapeti.Config { + /// + /// Provides access to information about the message being consumed. + /// Allows the strategy to determine how the exception should be handled. + /// public interface IExceptionStrategyContext { + /// + /// Provides access to the message context. + /// IMessageContext MessageContext { get; } + /// + /// Contains the exception being handled. + /// Exception Exception { get; } - HandlingResultBuilder HandlingResult { get; set; } + /// + /// Determines how the message has been handled. Defaults to Error. + /// + /// + void SetConsumeResult(ConsumeResult consumeResult); } } diff --git a/Tapeti/Config/IMessageContext.cs b/Tapeti/Config/IMessageContext.cs index 658636c..c3314c4 100644 --- a/Tapeti/Config/IMessageContext.cs +++ b/Tapeti/Config/IMessageContext.cs @@ -1,29 +1,63 @@ using System; -using System.Collections.Generic; -using RabbitMQ.Client; - -// ReSharper disable UnusedMember.Global namespace Tapeti.Config { + /// + /// + /// Provides information about the message currently being handled. + /// public interface IMessageContext : IDisposable { - IDependencyResolver DependencyResolver { get; } + /// + /// Provides access to the Tapeti config. + /// + ITapetiConfig Config { get; } + /// + /// Contains the name of the queue the message was consumed from. + /// string Queue { get; } - string RoutingKey { get; } - object Message { get; } - IBasicProperties Properties { get; } - IDictionary Items { get; } + /// + /// Contains the exchange to which the message was published. + /// + string Exchange { get; } + + /// + /// Contains the routing key as provided when the message was published. + /// + string RoutingKey { get; } + + /// + /// Contains the decoded message instance. + /// + object Message { get; } + + /// + /// Provides access to the message metadata. + /// + IMessageProperties Properties { get; } /// - /// Controller will be null when passed to a IMessageFilterMiddleware + /// Provides access to the binding which is currently processing the message. /// - object Controller { get; } - IBinding Binding { get; } - IMessageContext SetupNestedContext(); + + /// + /// Stores a key-value pair in the context for passing information between the various + /// middleware stages (mostly for IControllerMiddlewareBase descendants). + /// + /// A unique key. It is recommended to prefix it with the package name which hosts the middleware to prevent conflicts + /// Will be disposed if the value implements IDisposable + void Store(string key, object value); + + /// + /// Retrieves a previously stored value. + /// + /// + /// + /// True if the value was found, False otherwise + bool Get(string key, out T value) where T : class; } } diff --git a/Tapeti/Config/IMessageFilterMiddleware.cs b/Tapeti/Config/IMessageFilterMiddleware.cs deleted file mode 100644 index 497909c..0000000 --- a/Tapeti/Config/IMessageFilterMiddleware.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace Tapeti.Config -{ - public interface IMessageFilterMiddleware - { - Task Handle(IMessageContext context, Func next); - } -} diff --git a/Tapeti/Config/IMessageMiddleware.cs b/Tapeti/Config/IMessageMiddleware.cs index 38ee22b..134b5de 100644 --- a/Tapeti/Config/IMessageMiddleware.cs +++ b/Tapeti/Config/IMessageMiddleware.cs @@ -3,8 +3,16 @@ using System.Threading.Tasks; namespace Tapeti.Config { + /// + /// Denotes middleware that processes all messages. + /// public interface IMessageMiddleware { + /// + /// Called for all bindings when a message needs to be handled. + /// + /// + /// Call to pass the message to the next handler in the chain Task Handle(IMessageContext context, Func next); } } diff --git a/Tapeti/Config/IMessageProperties.cs b/Tapeti/Config/IMessageProperties.cs new file mode 100644 index 0000000..31c203b --- /dev/null +++ b/Tapeti/Config/IMessageProperties.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace Tapeti.Config +{ + /// + /// Metadata properties attached to a message, equivalent to the RabbitMQ Client's IBasicProperties. + /// + public interface IMessageProperties + { + /// + string ContentType { get; set; } + + /// + string CorrelationId { get; set; } + + /// + string ReplyTo { get; set; } + + /// + bool? Persistent { get; set; } + + /// + DateTime? Timestamp { get; set; } + + + /// + /// Writes a custom header. + /// + /// + /// + void SetHeader(string name, string value); + + + /// + /// Retrieves the value of a custom header field. + /// + /// + /// The value if found, null otherwise + string GetHeader(string name); + + + /// + /// Retrieves all custom headers. + /// + IEnumerable> GetHeaders(); + } +} diff --git a/Tapeti/Config/IPublishContext.cs b/Tapeti/Config/IPublishContext.cs index 4bb4ff8..30a51b6 100644 --- a/Tapeti/Config/IPublishContext.cs +++ b/Tapeti/Config/IPublishContext.cs @@ -1,16 +1,35 @@ -using RabbitMQ.Client; - -// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedMember.Global namespace Tapeti.Config { + /// + /// Provides access to information about the message being published. + /// public interface IPublishContext { - IDependencyResolver DependencyResolver { get; } + /// + /// Provides access to the Tapeti config. + /// + ITapetiConfig Config { get; } - string Exchange { get; } + /// + /// The exchange to which the message will be published. + /// + string Exchange { get; set; } + + /// + /// The routing key which will be included with the message. + /// string RoutingKey { get; } + + /// + /// The instance of the message class. + /// object Message { get; } - IBasicProperties Properties { get; } + + /// + /// Provides access to the message metadata. + /// + IMessageProperties Properties { get; } } } diff --git a/Tapeti/Config/IPublishMiddleware.cs b/Tapeti/Config/IPublishMiddleware.cs index 9a0eccc..c8069e3 100644 --- a/Tapeti/Config/IPublishMiddleware.cs +++ b/Tapeti/Config/IPublishMiddleware.cs @@ -3,8 +3,16 @@ using System.Threading.Tasks; namespace Tapeti.Config { + /// + /// Denotes middleware that processes all published messages. + /// public interface IPublishMiddleware { + /// + /// Called when a message is published. + /// + /// + /// Call to pass the message to the next handler in the chain Task Handle(IPublishContext context, Func next); } } diff --git a/Tapeti/Config/ITapetiConfig.cs b/Tapeti/Config/ITapetiConfig.cs new file mode 100644 index 0000000..b1108f7 --- /dev/null +++ b/Tapeti/Config/ITapetiConfig.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; + +namespace Tapeti.Config +{ + /// + /// Provides access to the Tapeti configuration. + /// + public interface ITapetiConfig + { + /// + /// Reference to the wrapper for an IoC container, to provide dependency injection to Tapeti. + /// + IDependencyResolver DependencyResolver { get; } + + /// + /// Various Tapeti features which can be turned on or off. + /// + ITapetiConfigFeatues Features { get; } + + /// + /// Provides access to the different kinds of registered middleware. + /// + ITapetiConfigMiddleware Middleware { get; } + + /// + /// A list of all registered bindings. + /// + ITapetiConfigBindings Bindings { get; } + } + + + /// + /// Various Tapeti features which can be turned on or off. + /// + public interface ITapetiConfigFeatues + { + /// + /// Determines whether 'publisher confirms' are used. This RabbitMQ features allows Tapeti to + /// be notified if a message has no route, and guarantees delivery for request-response style + /// messages and those marked with the Mandatory attribute. On by default, can only be turned + /// off by explicitly calling DisablePublisherConfirms, which is not recommended. + /// + bool PublisherConfirms { get; } + + /// + /// If enabled, durable queues will be created at startup and their bindings will be updated + /// with the currently registered message handlers. If not enabled all durable queues must + /// already be present when the connection is made. + /// + bool DeclareDurableQueues { get; } + } + + + /// + /// Provides access to the different kinds of registered middleware. + /// + public interface ITapetiConfigMiddleware + { + /// + /// A list of message middleware which is called when a message is being consumed. + /// + IReadOnlyList Message { get; } + + /// + /// A list of publish middleware which is called when a message is being published. + /// + IReadOnlyList Publish { get; } + } + + + /// + /// + /// Contains a list of registered bindings, with a few added helpers. + /// + public interface ITapetiConfigBindings : IReadOnlyList + { + /// + /// Searches for a binding linked to the specified method. + /// + /// + /// The binding if found, null otherwise + IControllerMethodBinding ForMethod(Delegate method); + } + + + /* + public interface IBinding + { + Type Controller { get; } + MethodInfo Method { get; } + Type MessageClass { get; } + string QueueName { get; } + QueueBindingMode QueueBindingMode { get; set; } + + IReadOnlyList MessageFilterMiddleware { get; } + IReadOnlyList MessageMiddleware { get; } + + bool Accept(Type messageClass); + bool Accept(IMessageContext context, object message); + Task Invoke(IMessageContext context, object message); + } + */ + + + /* + public interface IBuildBinding : IBinding + { + void SetQueueName(string queueName); + } + */ +} diff --git a/Tapeti/Config/ITapetiConfigBuilder.cs b/Tapeti/Config/ITapetiConfigBuilder.cs new file mode 100644 index 0000000..781648e --- /dev/null +++ b/Tapeti/Config/ITapetiConfigBuilder.cs @@ -0,0 +1,117 @@ +using System; + +// ReSharper disable UnusedMember.Global + +namespace Tapeti.Config +{ + /// + /// Configures Tapeti. Every method other than Build returns the builder instance + /// for method chaining. + /// + public interface ITapetiConfigBuilder + { + /// + /// Returns a locked version of the configuration which can be used to establish a connection. + /// + ITapetiConfig Build(); + + + /// + /// Registers binding middleware which is called when a binding is created for a controller method. + /// + /// + ITapetiConfigBuilder Use(IControllerBindingMiddleware handler); + + /// + /// Registers message middleware which is called to handle an incoming message. + /// + /// + ITapetiConfigBuilder Use(IMessageMiddleware handler); + + /// + /// Registers publish middleware which is called when a message is published. + /// + /// + ITapetiConfigBuilder Use(IPublishMiddleware handler); + + + /// + /// Registers a Tapeti extension, which is a bundling mechanism for features that require more than one middleware and + /// optionally other dependency injected implementations. + /// + /// + ITapetiConfigBuilder Use(ITapetiExtension extension); + + + /// + /// Registers a binding which can accept messages. In most cases this method should not be called outside + /// of Tapeti. Instead use the RegisterAllControllers extension method to automatically create bindings. + /// + /// + void RegisterBinding(IBinding binding); + + + /// + /// Disables 'publisher confirms'. This RabbitMQ features allows Tapeti to be notified if a message + /// has no route, and guarantees delivery for request-response style messages and those marked with + /// the Mandatory attribute. On by default. + /// + /// WARNING: disabling publisher confirms means there is no guarantee that a Publish succeeds, + /// and disables Tapeti.Flow from verifying if a request/response can be routed. This may + /// result in never-ending flows. Only disable if you can accept those consequences. + /// + ITapetiConfigBuilder DisablePublisherConfirms(); + + + /// + /// Configures 'publisher confirms'. This RabbitMQ features allows Tapeti to be notified if a message + /// has no route, and guarantees delivery for request-response style messages and those marked with + /// the Mandatory attribute. On by default. + /// + /// WARNING: disabling publisher confirms means there is no guarantee that a Publish succeeds, + /// and disables Tapeti.Flow from verifying if a request/response can be routed. This may + /// result in never-ending flows. Only disable if you can accept those consequences. + /// + ITapetiConfigBuilder SetPublisherConfirms(bool enabled); + + + /// + /// Enables the automatic creation of durable queues and updating of their bindings. + /// + /// + /// Note that access to the RabbitMQ Management plugin's REST API is required for this + /// feature to work, since AMQP does not provide a way to query existing bindings. + /// + ITapetiConfigBuilder EnableDeclareDurableQueues(); + + /// + /// Configures the automatic creation of durable queues and updating of their bindings. + /// + /// + /// Note that access to the RabbitMQ Management plugin's REST API is required for this + /// feature to work, since AMQP does not provide a way to query existing bindings. + /// + ITapetiConfigBuilder SetDeclareDurableQueues(bool enabled); + } + + + /// + /// Access interface for ITapetiConfigBuilder extension methods. Allows access to the registered middleware + /// before the configuration is built. Implementations of ITapetiConfigBuilder should also implement this interface. + /// Should not be used outside of Tapeti packages. + /// + public interface ITapetiConfigBuilderAccess + { + /// + /// Provides access to the dependency resolver. + /// + IDependencyResolver DependencyResolver { get; } + + /// + /// Applies the currently registered binding middleware to the specified context. + /// + /// + /// + void ApplyBindingMiddleware(IControllerBindingContext context, Action lastHandler); + } +} diff --git a/Tapeti/Config/ITapetiExtension.cs b/Tapeti/Config/ITapetiExtension.cs index 6bc6f6c..24ffc06 100644 --- a/Tapeti/Config/ITapetiExtension.cs +++ b/Tapeti/Config/ITapetiExtension.cs @@ -2,10 +2,23 @@ namespace Tapeti.Config { + /// + /// A bundling mechanism for Tapeti extension packages. Allows the calling application to + /// pass all the necessary components to TapetiConfig.Use in one call. + /// public interface ITapetiExtension { + /// + /// Allows the extension to register default implementations into the IoC container. + /// + /// void RegisterDefaults(IDependencyContainer container); + /// + /// Produces a list of middleware implementations to be passed to the TapetiConfig.Use method. + /// + /// + /// A list of middleware implementations or null if no middleware needs to be registered IEnumerable GetMiddleware(IDependencyResolver dependencyResolver); } } diff --git a/Tapeti/Config/ITapetiExtensionBinding.cs b/Tapeti/Config/ITapetiExtensionBinding.cs new file mode 100644 index 0000000..33b064e --- /dev/null +++ b/Tapeti/Config/ITapetiExtensionBinding.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Tapeti.Config +{ + /// + /// + /// Provides a way for Tapeti extensions to register custom bindings. + /// + public interface ITapetiExtensionBinding : ITapetiExtension + { + /// + /// Produces a list of bindings to be registered. + /// + /// + /// A list of bindings or null if no bindings need to be registered + IEnumerable GetBindings(IDependencyResolver dependencyResolver); + } +} \ No newline at end of file diff --git a/Tapeti/Config/ITapetiExtentionBinding.cs b/Tapeti/Config/ITapetiExtentionBinding.cs deleted file mode 100644 index 5eee3a4..0000000 --- a/Tapeti/Config/ITapetiExtentionBinding.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace Tapeti.Config -{ - public interface ITapetiExtentionBinding - { - IEnumerable GetBindings(IDependencyResolver dependencyResolver); - - } -} \ No newline at end of file diff --git a/Tapeti/Connection/IConnectionEventListener.cs b/Tapeti/Connection/IConnectionEventListener.cs index d86feab..db602ad 100644 --- a/Tapeti/Connection/IConnectionEventListener.cs +++ b/Tapeti/Connection/IConnectionEventListener.cs @@ -1,16 +1,25 @@ namespace Tapeti.Connection { - public class DisconnectedEventArgs - { - public ushort ReplyCode; - public string ReplyText; - } - - + /// + /// Receives notifications on the state of the connection. + /// public interface IConnectionEventListener { - void Connected(); - void Reconnected(); + /// + /// Called when a connection to RabbitMQ has been established. + /// + void Connected(ConnectedEventArgs e); + + + /// + /// Called when the connection to RabbitMQ has been lost. + /// + void Reconnected(ConnectedEventArgs e); + + + /// + /// Called when the connection to RabbitMQ has been recovered after an unexpected disconnect. + /// void Disconnected(DisconnectedEventArgs e); } } diff --git a/Tapeti/Connection/ITapetiClient.cs b/Tapeti/Connection/ITapetiClient.cs new file mode 100644 index 0000000..4add519 --- /dev/null +++ b/Tapeti/Connection/ITapetiClient.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tapeti.Config; + +namespace Tapeti.Connection +{ + /// + /// + /// Defines a queue binding to an exchange using a routing key + /// + public struct QueueBinding : IEquatable + { + /// + public readonly string Exchange; + + /// + public readonly string RoutingKey; + + + /// + /// Initializes a new QueueBinding + /// + /// + /// + public QueueBinding(string exchange, string routingKey) + { + Exchange = exchange; + RoutingKey = routingKey; + } + + + /// + public bool Equals(QueueBinding other) + { + return string.Equals(Exchange, other.Exchange) && string.Equals(RoutingKey, other.RoutingKey); + } + + /// + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + return obj is QueueBinding other && Equals(other); + } + + /// + public override int GetHashCode() + { + unchecked + { + return ((Exchange != null ? Exchange.GetHashCode() : 0) * 397) ^ (RoutingKey != null ? RoutingKey.GetHashCode() : 0); + } + } + } + + + /// + /// Provides a bridge between Tapeti and the actual RabbitMQ client + /// + public interface ITapetiClient + { + /// + /// Publishes a message. The exchange and routing key are determined by the registered strategies. + /// + /// The raw message data to publish + /// Metadata to include in the message + /// The exchange to publish the message to, or empty to send it directly to a queue + /// The routing key for the message, or queue name if exchange is empty + /// If true, an exception will be raised if the message can not be delivered to at least one queue + Task Publish(byte[] body, IMessageProperties properties, string exchange, string routingKey, bool mandatory); + + + /// + /// Starts a consumer for the specified queue, using the provided bindings to handle messages. + /// + /// Cancelled when the connection is lost + /// + /// The consumer implementation which will receive the messages from the queue + Task Consume(CancellationToken cancellationToken, string queueName, IConsumer consumer); + + + /// + /// Creates a durable queue if it does not already exist, and updates the bindings. + /// + /// Cancelled when the connection is lost + /// The name of the queue to create + /// A list of bindings. Any bindings already on the queue which are not in this list will be removed + Task DurableQueueDeclare(CancellationToken cancellationToken, string queueName, IEnumerable bindings); + + /// + /// Verifies a durable queue exists. Will raise an exception if it does not. + /// + /// Cancelled when the connection is lost + /// The name of the queue to verify + Task DurableQueueVerify(CancellationToken cancellationToken, string queueName); + + /// + /// Deletes a durable queue. + /// + /// Cancelled when the connection is lost + /// The name of the queue to delete + /// If true, the queue will only be deleted if it is empty otherwise all bindings will be removed. If false, the queue is deleted even if there are queued messages. + Task DurableQueueDelete(CancellationToken cancellationToken, string queueName, bool onlyIfEmpty = true); + + /// + /// Creates a dynamic queue. + /// + /// Cancelled when the connection is lost + /// An optional prefix for the dynamic queue's name. If not provided, RabbitMQ's default logic will be used to create an amq.gen queue. + Task DynamicQueueDeclare(CancellationToken cancellationToken, string queuePrefix = null); + + /// + /// Add a binding to a dynamic queue. + /// + /// Cancelled when the connection is lost + /// The name of the dynamic queue previously created using DynamicQueueDeclare + /// The binding to add to the dynamic queue + Task DynamicQueueBind(CancellationToken cancellationToken, string queueName, QueueBinding binding); + + + /// + /// Closes the connection to RabbitMQ gracefully. + /// + Task Close(); + } +} \ No newline at end of file diff --git a/Tapeti/Connection/TapetiBasicConsumer.cs b/Tapeti/Connection/TapetiBasicConsumer.cs new file mode 100644 index 0000000..92fec5a --- /dev/null +++ b/Tapeti/Connection/TapetiBasicConsumer.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using RabbitMQ.Client; +using Tapeti.Default; + +namespace Tapeti.Connection +{ + /// + /// + /// Implements the bridge between the RabbitMQ Client consumer and a Tapeti Consumer + /// + internal class TapetiBasicConsumer : DefaultBasicConsumer + { + private readonly IConsumer consumer; + private readonly Func onRespond; + + + /// + public TapetiBasicConsumer(IConsumer consumer, Func onRespond) + { + this.consumer = consumer; + this.onRespond = onRespond; + } + + + /// + public override void HandleBasicDeliver(string consumerTag, ulong deliveryTag, bool redelivered, string exchange, string routingKey, IBasicProperties properties, byte[] body) + { + Task.Run(async () => + { + try + { + var response = await consumer.Consume(exchange, routingKey, new RabbitMQMessageProperties(properties), body); + await onRespond(deliveryTag, response); + } + catch + { + await onRespond(deliveryTag, ConsumeResult.Error); + } + }); + } + } +} diff --git a/Tapeti/Connection/TapetiClient.cs b/Tapeti/Connection/TapetiClient.cs new file mode 100644 index 0000000..9a91ed0 --- /dev/null +++ b/Tapeti/Connection/TapetiClient.cs @@ -0,0 +1,859 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using RabbitMQ.Client.Exceptions; +using Tapeti.Config; +using Tapeti.Default; +using Tapeti.Exceptions; +using Tapeti.Tasks; + +namespace Tapeti.Connection +{ + /// + /// + /// Implementation of ITapetiClient for the RabbitMQ Client library + /// + internal class TapetiClient : ITapetiClient + { + private const int ReconnectDelay = 5000; + private const int MandatoryReturnTimeout = 30000; + private const int MinimumConnectedReconnectDelay = 1000; + + private readonly TapetiConnectionParams connectionParams; + + private readonly ITapetiConfig config; + private readonly ILogger logger; + + + /// + /// Receives events when the connection state changes. + /// + public IConnectionEventListener ConnectionEventListener { get; set; } + + + private readonly Lazy taskQueue = new Lazy(); + + + // These fields are for use in the taskQueue only! + private RabbitMQ.Client.IConnection connection; + private bool isClosing; + private bool isReconnect; + private IModel channelInstance; + private ulong lastDeliveryTag; + private DateTime connectedDateTime; + private readonly HttpClient managementClient; + private readonly HashSet deletedQueues = new HashSet(); + + // These fields must be locked, since the callbacks for BasicAck/BasicReturn can run in a different thread + private readonly object confirmLock = new object(); + private readonly Dictionary confirmMessages = new Dictionary(); + private readonly Dictionary returnRoutingKeys = new Dictionary(); + + + private class ConfirmMessageInfo + { + public string ReturnKey; + public TaskCompletionSource CompletionSource; + } + + + private class ReturnInfo + { + public uint RefCount; + public int FirstReplyCode; + } + + + /// + public TapetiClient(ITapetiConfig config, TapetiConnectionParams connectionParams) + { + this.config = config; + this.connectionParams = connectionParams; + + logger = config.DependencyResolver.Resolve(); + + + var handler = new HttpClientHandler + { + Credentials = new NetworkCredential(connectionParams.Username, connectionParams.Password) + }; + + managementClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + managementClient.DefaultRequestHeaders.Add("Connection", "close"); + } + + + /// + public async Task Publish(byte[] body, IMessageProperties properties, string exchange, string routingKey, bool mandatory) + { + if (string.IsNullOrEmpty(routingKey)) + throw new ArgumentNullException(nameof(routingKey)); + + await taskQueue.Value.Add(async () => + { + Task publishResultTask = null; + var messageInfo = new ConfirmMessageInfo + { + ReturnKey = GetReturnKey(exchange, routingKey), + CompletionSource = new TaskCompletionSource() + }; + + + WithRetryableChannel(channel => + { + DeclareExchange(channel, exchange); + + // The delivery tag is lost after a reconnect, register under the new tag + if (config.Features.PublisherConfirms) + { + lastDeliveryTag++; + + Monitor.Enter(confirmLock); + try + { + confirmMessages.Add(lastDeliveryTag, messageInfo); + } + finally + { + Monitor.Exit(confirmLock); + } + + publishResultTask = messageInfo.CompletionSource.Task; + } + else + mandatory = false; + + try + { + var publishProperties = new RabbitMQMessageProperties(channel.CreateBasicProperties(), properties); + channel.BasicPublish(exchange ?? "", routingKey, mandatory, publishProperties.BasicProperties, body); + } + catch + { + messageInfo.CompletionSource.SetCanceled(); + publishResultTask = null; + + throw; + } + }); + + + if (publishResultTask == null) + return; + + var delayCancellationTokenSource = new CancellationTokenSource(); + var signalledTask = await Task.WhenAny( + publishResultTask, + Task.Delay(MandatoryReturnTimeout, delayCancellationTokenSource.Token)); + + if (signalledTask != publishResultTask) + throw new TimeoutException( + $"Timeout while waiting for basic.return for message with exchange '{exchange}' and routing key '{routingKey}'"); + + delayCancellationTokenSource.Cancel(); + + if (publishResultTask.IsCanceled) + throw new NackException( + $"Mandatory message with with exchange '{exchange}' and routing key '{routingKey}' was nacked"); + + var replyCode = publishResultTask.Result; + + // There is no RabbitMQ.Client.Framing.Constants value for this "No route" reply code + // at the time of writing... + if (replyCode == 312) + throw new NoRouteException( + $"Mandatory message with exchange '{exchange}' and routing key '{routingKey}' does not have a route"); + + if (replyCode > 0) + throw new NoRouteException( + $"Mandatory message with exchange '{exchange}' and routing key '{routingKey}' could not be delivered, reply code: {replyCode}"); + }); + } + + + /// + public async Task Consume(CancellationToken cancellationToken, string queueName, IConsumer consumer) + { + if (deletedQueues.Contains(queueName)) + return; + + if (string.IsNullOrEmpty(queueName)) + throw new ArgumentNullException(nameof(queueName)); + + + await QueueWithRetryableChannel(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + var basicConsumer = new TapetiBasicConsumer(consumer, Respond); + channel.BasicConsume(queueName, false, basicConsumer); + }); + } + + + private async Task Respond(ulong deliveryTag, ConsumeResult result) + { + await taskQueue.Value.Add(() => + { + // No need for a retryable channel here, if the connection is lost we can't + // use the deliveryTag anymore. + switch (result) + { + case ConsumeResult.Success: + case ConsumeResult.ExternalRequeue: + GetChannel().BasicAck(deliveryTag, false); + break; + + case ConsumeResult.Error: + GetChannel().BasicNack(deliveryTag, false, false); + break; + + case ConsumeResult.Requeue: + GetChannel().BasicNack(deliveryTag, false, true); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + }); + } + + + /// + public async Task DurableQueueDeclare(CancellationToken cancellationToken, string queueName, IEnumerable bindings) + { + var existingBindings = (await GetQueueBindings(queueName)).ToList(); + var currentBindings = bindings.ToList(); + + await Queue(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + channel.QueueDeclare(queueName, true, false, false); + + foreach (var binding in currentBindings.Except(existingBindings)) + { + DeclareExchange(channel, binding.Exchange); + channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); + } + + foreach (var deletedBinding in existingBindings.Except(currentBindings)) + channel.QueueUnbind(queueName, deletedBinding.Exchange, deletedBinding.RoutingKey); + }); + } + + /// + public async Task DurableQueueVerify(CancellationToken cancellationToken, string queueName) + { + await Queue(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + channel.QueueDeclarePassive(queueName); + }); + } + + + /// + public async Task DurableQueueDelete(CancellationToken cancellationToken, string queueName, bool onlyIfEmpty = true) + { + if (!onlyIfEmpty) + { + uint deletedMessages = 0; + + await Queue(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + deletedMessages = channel.QueueDelete(queueName); + }); + + deletedQueues.Add(queueName); + logger.QueueObsolete(queueName, true, deletedMessages); + return; + } + + + await taskQueue.Value.Add(async () => + { + bool retry; + do + { + if (cancellationToken.IsCancellationRequested) + break; + + retry = false; + + // Get queue information from the Management API, since the AMQP operations will + // throw an error if the queue does not exist or still contains messages and resets + // the connection. The resulting reconnect will cause subscribers to reset. + var queueInfo = await GetQueueInfo(queueName); + if (queueInfo == null) + { + deletedQueues.Add(queueName); + return; + } + + if (queueInfo.Messages == 0) + { + // Still pass onlyIfEmpty to prevent concurrency issues if a message arrived between + // the call to the Management API and deleting the queue. Because the QueueWithRetryableChannel + // includes the GetQueueInfo, the next time around it should have Messages > 0 + try + { + var channel = GetChannel(); + channel.QueueDelete(queueName, false, true); + + deletedQueues.Add(queueName); + logger.QueueObsolete(queueName, true, 0); + } + catch (OperationInterruptedException e) + { + if (e.ShutdownReason.ReplyCode == RabbitMQ.Client.Framing.Constants.PreconditionFailed) + retry = true; + else + throw; + } + } + else + { + // Remove all bindings instead + var existingBindings = (await GetQueueBindings(queueName)).ToList(); + + if (existingBindings.Count > 0) + { + var channel = GetChannel(); + + foreach (var binding in existingBindings) + channel.QueueUnbind(queueName, binding.Exchange, binding.RoutingKey); + } + + logger.QueueObsolete(queueName, false, queueInfo.Messages); + } + } while (retry); + }); + } + + + /// + public async Task DynamicQueueDeclare(CancellationToken cancellationToken, string queuePrefix = null) + { + string queueName = null; + + await Queue(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + if (!string.IsNullOrEmpty(queuePrefix)) + { + queueName = queuePrefix + "." + Guid.NewGuid().ToString("N"); + channel.QueueDeclare(queueName); + } + else + queueName = channel.QueueDeclare().QueueName; + }); + + return queueName; + } + + /// + public async Task DynamicQueueBind(CancellationToken cancellationToken, string queueName, QueueBinding binding) + { + await Queue(channel => + { + if (cancellationToken.IsCancellationRequested) + return; + + DeclareExchange(channel, binding.Exchange); + channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); + }); + } + + + /// + public async Task Close() + { + if (!taskQueue.IsValueCreated) + return; + + await taskQueue.Value.Add(() => + { + isClosing = true; + + if (channelInstance != null) + { + channelInstance.Dispose(); + channelInstance = null; + } + + // ReSharper disable once InvertIf + if (connection != null) + { + connection.Dispose(); + connection = null; + } + + taskQueue.Value.Dispose(); + }); + } + + + private static readonly List TransientStatusCodes = new List + { + HttpStatusCode.GatewayTimeout, + HttpStatusCode.RequestTimeout, + HttpStatusCode.ServiceUnavailable + }; + + + private class ManagementQueueInfo + { + [JsonProperty("messages")] + public uint Messages { get; set; } + } + + + + private async Task GetQueueInfo(string queueName) + { + var virtualHostPath = Uri.EscapeDataString(connectionParams.VirtualHost); + var queuePath = Uri.EscapeDataString(queueName); + + return await WithRetryableManagementAPI($"queues/{virtualHostPath}/{queuePath}", async response => + { + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(content); + }); + } + + + private class ManagementBinding + { + [JsonProperty("source")] + public string Source { get; set; } + + [JsonProperty("vhost")] + public string Vhost { get; set; } + + [JsonProperty("destination")] + public string Destination { get; set; } + + [JsonProperty("destination_type")] + public string DestinationType { get; set; } + + [JsonProperty("routing_key")] + public string RoutingKey { get; set; } + + [JsonProperty("arguments")] + public Dictionary Arguments { get; set; } + + [JsonProperty("properties_key")] + public string PropertiesKey { get; set; } + } + + + private async Task> GetQueueBindings(string queueName) + { + var virtualHostPath = Uri.EscapeDataString(connectionParams.VirtualHost); + var queuePath = Uri.EscapeDataString(queueName); + + return await WithRetryableManagementAPI($"queues/{virtualHostPath}/{queuePath}/bindings", async response => + { + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var bindings = JsonConvert.DeserializeObject>(content); + + // Filter out the binding to an empty source, which is always present for direct-to-queue routing + return bindings + .Where(binding => !string.IsNullOrEmpty(binding.Source)) + .Select(binding => new QueueBinding(binding.Source, binding.RoutingKey)); + }); + } + + + private static readonly TimeSpan[] ExponentialBackoff = + { + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(8), + TimeSpan.FromSeconds(13), + TimeSpan.FromSeconds(21), + TimeSpan.FromSeconds(34), + TimeSpan.FromSeconds(55) + }; + + + private async Task WithRetryableManagementAPI(string path, Func> handleResponse) + { + var requestUri = new Uri($"http://{connectionParams.HostName}:{connectionParams.ManagementPort}/api/{path}"); + + using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri)) + { + var retryDelayIndex = 0; + + while (true) + { + try + { + var response = await managementClient.SendAsync(request); + return await handleResponse(response); + } + catch (TimeoutException) + { + } + catch (WebException e) + { + if (!(e.Response is HttpWebResponse response)) + throw; + + if (!TransientStatusCodes.Contains(response.StatusCode)) + throw; + } + + await Task.Delay(ExponentialBackoff[retryDelayIndex]); + + if (retryDelayIndex < ExponentialBackoff.Length - 1) + retryDelayIndex++; + } + } + } + + + private readonly HashSet declaredExchanges = new HashSet(); + + private void DeclareExchange(IModel channel, string exchange) + { + if (string.IsNullOrEmpty(exchange)) + return; + + if (declaredExchanges.Contains(exchange)) + return; + + channel.ExchangeDeclare(exchange, "topic", true); + declaredExchanges.Add(exchange); + } + + + private async Task Queue(Action operation) + { + await taskQueue.Value.Add(() => + { + var channel = GetChannel(); + operation(channel); + }); + } + + + private async Task QueueWithRetryableChannel(Action operation) + { + await taskQueue.Value.Add(() => + { + WithRetryableChannel(operation); + }); + } + + + /// + /// Only call this from a task in the taskQueue to ensure IModel is only used + /// by a single thread, as is recommended in the RabbitMQ .NET Client documentation. + /// + private void WithRetryableChannel(Action operation) + { + while (true) + { + try + { + operation(GetChannel()); + break; + } + catch (AlreadyClosedException) + { + } + } + } + + + /// + /// Only call this from a task in the taskQueue to ensure IModel is only used + /// by a single thread, as is recommended in the RabbitMQ .NET Client documentation. + /// + private IModel GetChannel() + { + if (channelInstance != null && channelInstance.IsOpen) + return channelInstance; + + // If the Disconnect quickly follows the Connect (when an error occurs that is reported back by RabbitMQ + // not related to the connection), wait for a bit to avoid spamming the connection + if ((DateTime.UtcNow - connectedDateTime).TotalMilliseconds <= MinimumConnectedReconnectDelay) + Thread.Sleep(ReconnectDelay); + + + var connectionFactory = new ConnectionFactory + { + HostName = connectionParams.HostName, + Port = connectionParams.Port, + VirtualHost = connectionParams.VirtualHost, + UserName = connectionParams.Username, + Password = connectionParams.Password, + AutomaticRecoveryEnabled = false, + TopologyRecoveryEnabled = false, + RequestedHeartbeat = 30 + }; + + if (connectionParams.ClientProperties != null) + foreach (var pair in connectionParams.ClientProperties) + { + if (connectionFactory.ClientProperties.ContainsKey(pair.Key)) + connectionFactory.ClientProperties[pair.Key] = Encoding.UTF8.GetBytes(pair.Value); + else + connectionFactory.ClientProperties.Add(pair.Key, Encoding.UTF8.GetBytes(pair.Value)); + } + + + while (true) + { + try + { + logger.Connect(new ConnectContext(connectionParams, isReconnect)); + + connection = connectionFactory.CreateConnection(); + channelInstance = connection.CreateModel(); + + if (channelInstance == null) + throw new BrokerUnreachableException(null); + + if (config.Features.PublisherConfirms) + { + lastDeliveryTag = 0; + + Monitor.Enter(confirmLock); + try + { + foreach (var pair in confirmMessages) + pair.Value.CompletionSource.SetCanceled(); + + confirmMessages.Clear(); + } + finally + { + Monitor.Exit(confirmLock); + } + + channelInstance.ConfirmSelect(); + } + + if (connectionParams.PrefetchCount > 0) + channelInstance.BasicQos(0, connectionParams.PrefetchCount, false); + + channelInstance.ModelShutdown += (sender, e) => + { + ConnectionEventListener?.Disconnected(new DisconnectedEventArgs + { + ReplyCode = e.ReplyCode, + ReplyText = e.ReplyText + }); + + logger.Disconnect(new DisconnectContext(connectionParams, e.ReplyCode, e.ReplyText)); + + channelInstance = null; + + if (!isClosing) + taskQueue.Value.Add(() => WithRetryableChannel(channel => { })); + }; + + channelInstance.BasicReturn += HandleBasicReturn; + channelInstance.BasicAcks += HandleBasicAck; + channelInstance.BasicNacks += HandleBasicNack; + + connectedDateTime = DateTime.UtcNow; + + var connectedEventArgs = new ConnectedEventArgs + { + ConnectionParams = connectionParams, + LocalPort = connection.LocalPort + }; + + if (isReconnect) + ConnectionEventListener?.Reconnected(connectedEventArgs); + else + ConnectionEventListener?.Connected(connectedEventArgs); + + logger.ConnectSuccess(new ConnectContext(connectionParams, isReconnect, connection.LocalPort)); + isReconnect = true; + + break; + } + catch (BrokerUnreachableException e) + { + logger.ConnectFailed(new ConnectContext(connectionParams, isReconnect, exception: e)); + Thread.Sleep(ReconnectDelay); + } + } + + return channelInstance; + } + + + private void HandleBasicReturn(object sender, BasicReturnEventArgs e) + { + /* + * "If the message is also published as mandatory, the basic.return is sent to the client before basic.ack." + * - https://www.rabbitmq.com/confirms.html + * + * Because there is no delivery tag included in the basic.return message. This solution is modeled after + * user OhJeez' answer on StackOverflow: + * + * "Since all messages with the same routing key are routed the same way. I assumed that once I get a + * basic.return about a specific routing key, all messages with this routing key can be considered undelivered" + * https://stackoverflow.com/questions/21336659/how-to-tell-which-amqp-message-was-not-routed-from-basic-return-response + */ + var key = GetReturnKey(e.Exchange, e.RoutingKey); + + if (!returnRoutingKeys.TryGetValue(key, out var returnInfo)) + { + returnInfo = new ReturnInfo + { + RefCount = 0, + FirstReplyCode = e.ReplyCode + }; + + returnRoutingKeys.Add(key, returnInfo); + } + + returnInfo.RefCount++; + } + + + private void HandleBasicAck(object sender, BasicAckEventArgs e) + { + Monitor.Enter(confirmLock); + try + { + foreach (var deliveryTag in GetDeliveryTags(e)) + { + if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) + continue; + + if (returnRoutingKeys.TryGetValue(messageInfo.ReturnKey, out var returnInfo)) + { + messageInfo.CompletionSource.SetResult(returnInfo.FirstReplyCode); + + returnInfo.RefCount--; + if (returnInfo.RefCount == 0) + returnRoutingKeys.Remove(messageInfo.ReturnKey); + } + + messageInfo.CompletionSource.SetResult(0); + confirmMessages.Remove(deliveryTag); + } + } + finally + { + Monitor.Exit(confirmLock); + } + } + + + private void HandleBasicNack(object sender, BasicNackEventArgs e) + { + Monitor.Enter(confirmLock); + try + { + foreach (var deliveryTag in GetDeliveryTags(e)) + { + if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) + continue; + + messageInfo.CompletionSource.SetCanceled(); + confirmMessages.Remove(e.DeliveryTag); + } + } + finally + { + Monitor.Exit(confirmLock); + } + } + + + private IEnumerable GetDeliveryTags(BasicAckEventArgs e) + { + return e.Multiple + ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() + : new[] { e.DeliveryTag }; + } + + + private IEnumerable GetDeliveryTags(BasicNackEventArgs e) + { + return e.Multiple + ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() + : new[] { e.DeliveryTag }; + } + + + private static string GetReturnKey(string exchange, string routingKey) + { + return exchange + ':' + routingKey; + } + + + + private class ConnectContext : IConnectSuccessContext, IConnectFailedContext + { + public TapetiConnectionParams ConnectionParams { get; } + public bool IsReconnect { get; } + public int LocalPort { get; } + public Exception Exception { get; } + + + public ConnectContext(TapetiConnectionParams connectionParams, bool isReconnect, int localPort = 0, Exception exception = null) + { + ConnectionParams = connectionParams; + IsReconnect = isReconnect; + LocalPort = localPort; + Exception = exception; + } + } + + + private class DisconnectContext : IDisconnectContext + { + public TapetiConnectionParams ConnectionParams { get; } + public ushort ReplyCode { get; } + public string ReplyText { get; } + + + public DisconnectContext(TapetiConnectionParams connectionParams, ushort replyCode, string replyText) + { + ConnectionParams = connectionParams; + ReplyCode = replyCode; + ReplyText = replyText; + } + } + } +} diff --git a/Tapeti/Connection/TapetiConsumer.cs b/Tapeti/Connection/TapetiConsumer.cs index 06f87aa..3ab9bb3 100644 --- a/Tapeti/Connection/TapetiConsumer.cs +++ b/Tapeti/Connection/TapetiConsumer.cs @@ -1,309 +1,161 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.ExceptionServices; -using RabbitMQ.Client; using Tapeti.Config; using Tapeti.Default; using System.Threading.Tasks; +using Tapeti.Helpers; namespace Tapeti.Connection { - public class TapetiConsumer : DefaultBasicConsumer + /// + /// + /// Implements a RabbitMQ consumer to pass messages to the Tapeti middleware. + /// + internal class TapetiConsumer : IConsumer { - private readonly TapetiWorker worker; + private readonly ITapetiConfig config; private readonly string queueName; - private readonly IDependencyResolver dependencyResolver; - private readonly IReadOnlyList messageMiddleware; - private readonly IReadOnlyList cleanupMiddleware; private readonly List bindings; private readonly ILogger logger; private readonly IExceptionStrategy exceptionStrategy; + private readonly IMessageSerializer messageSerializer; - public TapetiConsumer(TapetiWorker worker, string queueName, IDependencyResolver dependencyResolver, IEnumerable bindings, IReadOnlyList messageMiddleware, IReadOnlyList cleanupMiddleware) + /// + public TapetiConsumer(ITapetiConfig config, string queueName, IEnumerable bindings) { - this.worker = worker; + this.config = config; this.queueName = queueName; - this.dependencyResolver = dependencyResolver; - this.messageMiddleware = messageMiddleware; - this.cleanupMiddleware = cleanupMiddleware; this.bindings = bindings.ToList(); - logger = dependencyResolver.Resolve(); - exceptionStrategy = dependencyResolver.Resolve(); + logger = config.DependencyResolver.Resolve(); + exceptionStrategy = config.DependencyResolver.Resolve(); + messageSerializer = config.DependencyResolver.Resolve(); } - public override void HandleBasicDeliver(string consumerTag, ulong deliveryTag, bool redelivered, string exchange, string routingKey, - IBasicProperties properties, byte[] body) + /// + public async Task Consume(string exchange, string routingKey, IMessageProperties properties, byte[] body) { - Task.Run(async () => + object message = null; + try { - MessageContext context = null; - HandlingResult handlingResult = null; - try + message = messageSerializer.Deserialize(body, properties); + if (message == null) + throw new ArgumentException("Message body could not be deserialized into a message object", nameof(body)); + + return await DispatchMessage(message, new MessageContextData { - try - { - context = new MessageContext - { - DependencyResolver = dependencyResolver, - Queue = queueName, - RoutingKey = routingKey, - Properties = properties - }; - - await DispatchMesage(context, body); - - handlingResult = new HandlingResult - { - ConsumeResponse = ConsumeResponse.Ack, - MessageAction = MessageAction.None - }; - } - catch (Exception eDispatch) - { - var exception = ExceptionDispatchInfo.Capture(UnwrapException(eDispatch)); - logger.HandlerException(eDispatch); - try - { - var exceptionStrategyContext = new ExceptionStrategyContext(context, exception.SourceException); - - exceptionStrategy.HandleException(exceptionStrategyContext); - - handlingResult = exceptionStrategyContext.HandlingResult.ToHandlingResult(); - } - catch (Exception eStrategy) - { - logger.HandlerException(eStrategy); - } - } - try - { - if (handlingResult == null) - { - handlingResult = new HandlingResult - { - ConsumeResponse = ConsumeResponse.Nack, - MessageAction = MessageAction.None - }; - } - await RunCleanup(context, handlingResult); - } - catch (Exception eCleanup) - { - logger.HandlerException(eCleanup); - } - } - finally - { - try - { - if (handlingResult == null) - { - handlingResult = new HandlingResult - { - ConsumeResponse = ConsumeResponse.Nack, - MessageAction = MessageAction.None - }; - } - await worker.Respond(deliveryTag, handlingResult.ConsumeResponse); - } - catch (Exception eRespond) - { - logger.HandlerException(eRespond); - } - try - { - context?.Dispose(); - } - catch (Exception eDispose) - { - logger.HandlerException(eDispose); - } - } - }); - } - - private async Task RunCleanup(MessageContext context, HandlingResult handlingResult) - { - foreach(var handler in cleanupMiddleware) + Exchange = exchange, + RoutingKey = routingKey, + Properties = properties + }); + } + catch (Exception dispatchException) { - try + using (var emptyContext = new MessageContext { - await handler.Handle(context, handlingResult); - } - catch (Exception eCleanup) + Config = config, + Queue = queueName, + Exchange = exchange, + RoutingKey = routingKey, + Message = message, + Properties = properties, + Binding = null + }) { - logger.HandlerException(eCleanup); + var exceptionContext = new ExceptionStrategyContext(emptyContext, dispatchException); + HandleException(exceptionContext); + return exceptionContext.ConsumeResult; } } } - private async Task DispatchMesage(MessageContext context, byte[] body) + + private async Task DispatchMessage(object message, MessageContextData messageContextData) { - var message = dependencyResolver.Resolve().Deserialize(body, context.Properties); - if (message == null) - throw new ArgumentException("Empty message"); - - context.Message = message; - + var returnResult = ConsumeResult.Success; + var messageType = message.GetType(); var validMessageType = false; foreach (var binding in bindings) { - if (binding.Accept(context, message)) - { - await InvokeUsingBinding(context, binding, message); + if (!binding.Accept(messageType)) + continue; - validMessageType = true; - } + var consumeResult = await InvokeUsingBinding(message, messageContextData, binding); + validMessageType = true; + + if (consumeResult != ConsumeResult.Success) + returnResult = consumeResult; } if (!validMessageType) - throw new ArgumentException($"Unsupported message type: {message.GetType().FullName}"); + throw new ArgumentException($"No binding found for message type: {message.GetType().FullName}"); + + return returnResult; } - private Task InvokeUsingBinding(MessageContext context, IBinding binding, object message) + + private async Task InvokeUsingBinding(object message, MessageContextData messageContextData, IBinding binding) { - context.Binding = binding; - - RecursiveCaller firstCaller = null; - RecursiveCaller currentCaller = null; - - void AddHandler(Handler handle) + using (var context = new MessageContext { - var caller = new RecursiveCaller(handle); - if (currentCaller == null) - firstCaller = caller; - else - currentCaller.Next = caller; - currentCaller = caller; - } - - if (binding.MessageFilterMiddleware != null) - { - foreach (var m in binding.MessageFilterMiddleware) - { - AddHandler(m.Handle); - } - } - - AddHandler(async (c, next) => - { - c.Controller = dependencyResolver.Resolve(binding.Controller); - await next(); - }); - - foreach (var m in messageMiddleware) - { - AddHandler(m.Handle); - } - - if (binding.MessageMiddleware != null) - { - foreach (var m in binding.MessageMiddleware) - { - AddHandler(m.Handle); - } - } - - AddHandler(async (c, next) => - { - await binding.Invoke(c, message); - }); - - return firstCaller.Call(context); - } - - private static Exception UnwrapException(Exception exception) - { - // In async/await style code this is handled similarly. For synchronous - // code using Tasks we have to unwrap these ourselves to get the proper - // exception directly instead of "Errors occured". We might lose - // some stack traces in the process though. - while (true) - { - var aggregateException = exception as AggregateException; - if (aggregateException == null || aggregateException.InnerExceptions.Count != 1) - return exception; - - exception = aggregateException.InnerExceptions[0]; - } - } - } - - public delegate Task Handler(MessageContext context, Func next); - - public class RecursiveCaller - { - private readonly Handler handle; - private MessageContext currentContext; - private MessageContext nextContext; - - public RecursiveCaller Next; - - public RecursiveCaller(Handler handle) - { - this.handle = handle; - } - - internal async Task Call(MessageContext context) - { - if (currentContext != null) - throw new InvalidOperationException("Cannot simultaneously call 'next' in Middleware."); - - try - { - currentContext = context; - - context.UseNestedContext = Next == null ? (Action)null : UseNestedContext; - - await handle(context, CallNext); - } - finally - { - currentContext = null; - } - } - - private async Task CallNext() - { - if (Next == null) - return; - if (nextContext != null) - { - await Next.Call(nextContext); - }else + Config = config, + Queue = queueName, + Exchange = messageContextData.Exchange, + RoutingKey = messageContextData.RoutingKey, + Message = message, + Properties = messageContextData.Properties, + Binding = binding + }) { try { - await Next.Call(currentContext); + await MiddlewareHelper.GoAsync(config.Middleware.Message, + async (handler, next) => await handler.Handle(context, next), + async () => { await binding.Invoke(context); }); + + await binding.Cleanup(context, ConsumeResult.Success); + return ConsumeResult.Success; } - finally + catch (Exception invokeException) { - currentContext.UseNestedContext = UseNestedContext; + var exceptionContext = new ExceptionStrategyContext(context, invokeException); + HandleException(exceptionContext); + + await binding.Cleanup(context, exceptionContext.ConsumeResult); + return exceptionContext.ConsumeResult; } } } - void UseNestedContext(MessageContext context) - { - if (nextContext != null) - throw new InvalidOperationException("Previous nested context was not yet disposed."); - context.OnContextDisposed = OnContextDisposed; - nextContext = context; + private void HandleException(ExceptionStrategyContext exceptionContext) + { + try + { + exceptionStrategy.HandleException(exceptionContext); + } + catch (Exception strategyException) + { + // Exception in the exception strategy. Oh dear. + exceptionContext.SetConsumeResult(ConsumeResult.Error); + logger.ConsumeException(strategyException, exceptionContext.MessageContext, ConsumeResult.Error); + } + + logger.ConsumeException(exceptionContext.Exception, exceptionContext.MessageContext, exceptionContext.ConsumeResult); } - void OnContextDisposed(MessageContext context) + + + private struct MessageContextData { - context.OnContextDisposed = null; - if (nextContext == context) - nextContext = null; + public string Exchange; + public string RoutingKey; + public IMessageProperties Properties; } } - } diff --git a/Tapeti/Connection/TapetiPublisher.cs b/Tapeti/Connection/TapetiPublisher.cs index 8887b85..53f0077 100644 --- a/Tapeti/Connection/TapetiPublisher.cs +++ b/Tapeti/Connection/TapetiPublisher.cs @@ -1,37 +1,88 @@ using System; using System.Reflection; using System.Threading.Tasks; -using RabbitMQ.Client; using Tapeti.Annotations; +using Tapeti.Config; +using Tapeti.Default; +using Tapeti.Helpers; namespace Tapeti.Connection { - public class TapetiPublisher : IInternalPublisher + /// + internal class TapetiPublisher : IInternalPublisher { - private readonly Func workerFactory; + private readonly ITapetiConfig config; + private readonly Func clientFactory; + private readonly IExchangeStrategy exchangeStrategy; + private readonly IRoutingKeyStrategy routingKeyStrategy; + private readonly IMessageSerializer messageSerializer; - public TapetiPublisher(Func workerFactory) + /// + public TapetiPublisher(ITapetiConfig config, Func clientFactory) { - this.workerFactory = workerFactory; + this.config = config; + this.clientFactory = clientFactory; + + exchangeStrategy = config.DependencyResolver.Resolve(); + routingKeyStrategy = config.DependencyResolver.Resolve(); + messageSerializer = config.DependencyResolver.Resolve(); } - public Task Publish(object message) + /// + public async Task Publish(object message) { - return workerFactory().Publish(message, null, IsMandatory(message)); + await Publish(message, null, IsMandatory(message)); } - public Task Publish(object message, IBasicProperties properties, bool mandatory) + /// + public async Task Publish(object message, IMessageProperties properties, bool mandatory) { - return workerFactory().Publish(message, properties, mandatory); + var messageClass = message.GetType(); + var exchange = exchangeStrategy.GetExchange(messageClass); + var routingKey = routingKeyStrategy.GetRoutingKey(messageClass); + + await Publish(message, properties, exchange, routingKey, mandatory); } - public Task PublishDirect(object message, string queueName, IBasicProperties properties, bool mandatory) + /// + public async Task PublishDirect(object message, string queueName, IMessageProperties properties, bool mandatory) { - return workerFactory().PublishDirect(message, queueName, properties, mandatory); + await Publish(message, properties, null, queueName, mandatory); + } + + + private async Task Publish(object message, IMessageProperties properties, string exchange, string routingKey, bool mandatory) + { + var writableProperties = new MessageProperties(properties); + + if (!writableProperties.Timestamp.HasValue) + writableProperties.Timestamp = DateTime.UtcNow; + + writableProperties.Persistent = true; + + + var context = new PublishContext + { + Config = config, + Exchange = exchange, + RoutingKey = routingKey, + Message = message, + Properties = writableProperties + }; + + + await MiddlewareHelper.GoAsync( + config.Middleware.Publish, + async (handler, next) => await handler.Handle(context, next), + async () => + { + var body = messageSerializer.Serialize(message, writableProperties); + await clientFactory().Publish(body, writableProperties, exchange, routingKey, mandatory); + }); } @@ -39,5 +90,15 @@ namespace Tapeti.Connection { return message.GetType().GetCustomAttribute() != null; } + + + private class PublishContext : IPublishContext + { + public ITapetiConfig Config { get; set; } + public string Exchange { get; set; } + public string RoutingKey { get; set; } + public object Message { get; set; } + public IMessageProperties Properties { get; set; } + } } } diff --git a/Tapeti/Connection/TapetiSubscriber.cs b/Tapeti/Connection/TapetiSubscriber.cs index af5ca5d..c57a424 100644 --- a/Tapeti/Connection/TapetiSubscriber.cs +++ b/Tapeti/Connection/TapetiSubscriber.cs @@ -1,47 +1,362 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Tapeti.Config; namespace Tapeti.Connection { - public class TapetiSubscriber : ISubscriber + /// + internal class TapetiSubscriber : ISubscriber { - private readonly Func workerFactory; - private readonly List queues; + private readonly Func clientFactory; + private readonly ITapetiConfig config; private bool consuming; + private CancellationTokenSource initializeCancellationTokenSource; - public TapetiSubscriber(Func workerFactory, IEnumerable queues) + + /// + public TapetiSubscriber(Func clientFactory, ITapetiConfig config) { - this.workerFactory = workerFactory; - this.queues = queues.ToList(); + this.clientFactory = clientFactory; + this.config = config; } - public Task BindQueues() - { - return Task.WhenAll(queues.Select(queue => workerFactory().Subscribe(queue)).ToList()); - } - - - public async Task RebindQueues() + public void Dispose() { - await BindQueues(); - - if (consuming) - await Task.WhenAll(queues.Select(queue => workerFactory().Consume(queue.Name, queue.Bindings)).ToList()); } - public Task Resume() + + /// + /// Applies the configured bindings and declares the queues in RabbitMQ. For internal use only. + /// + /// + public async Task ApplyBindings() + { + initializeCancellationTokenSource = new CancellationTokenSource(); + await ApplyBindings(initializeCancellationTokenSource.Token); + } + + + /// + /// Called after the connection is lost. For internal use only. + /// Guaranteed to be called from within the taskQueue thread. + /// + public void Disconnect() + { + initializeCancellationTokenSource?.Cancel(); + initializeCancellationTokenSource = null; + } + + + /// + /// Called after the connection is lost and regained. Reapplies the bindings and if Resume + /// has already been called, restarts the consumers. For internal use only. + /// Guaranteed to be called from within the taskQueue thread. + /// + public void Reconnect() + { + CancellationToken cancellationToken; + + initializeCancellationTokenSource?.Cancel(); + initializeCancellationTokenSource = new CancellationTokenSource(); + + cancellationToken = initializeCancellationTokenSource.Token; + + // ReSharper disable once MethodSupportsCancellation + Task.Run(async () => + { + await ApplyBindings(cancellationToken); + + if (consuming && !cancellationToken.IsCancellationRequested) + await ConsumeQueues(cancellationToken); + }); + } + + + /// + public async Task Resume() { if (consuming) - return Task.CompletedTask; + return; consuming = true; - return Task.WhenAll(queues.Select(queue => workerFactory().Consume(queue.Name, queue.Bindings)).ToList()); + initializeCancellationTokenSource = new CancellationTokenSource(); + + await ConsumeQueues(initializeCancellationTokenSource.Token); + } + + + + private async Task ApplyBindings(CancellationToken cancellationToken) + { + var routingKeyStrategy = config.DependencyResolver.Resolve(); + var exchangeStrategy = config.DependencyResolver.Resolve(); + + var bindingTarget = config.Features.DeclareDurableQueues + ? (CustomBindingTarget)new DeclareDurableQueuesBindingTarget(clientFactory, routingKeyStrategy, exchangeStrategy, cancellationToken) + : new PassiveDurableQueuesBindingTarget(clientFactory, routingKeyStrategy, exchangeStrategy, cancellationToken); + + await Task.WhenAll(config.Bindings.Select(binding => binding.Apply(bindingTarget))); + await bindingTarget.Apply(); + } + + + private async Task ConsumeQueues(CancellationToken cancellationToken) + { + var queues = config.Bindings.GroupBy(binding => binding.QueueName); + + await Task.WhenAll(queues.Select(async group => + { + var queueName = group.Key; + var consumer = new TapetiConsumer(config, queueName, group); + + await clientFactory().Consume(cancellationToken, queueName, consumer); + })); + } + + + private abstract class CustomBindingTarget : IBindingTarget + { + protected readonly Func ClientFactory; + protected readonly IRoutingKeyStrategy RoutingKeyStrategy; + protected readonly IExchangeStrategy ExchangeStrategy; + protected readonly CancellationToken CancellationToken; + + private struct DynamicQueueInfo + { + public string QueueName; + public List MessageClasses; + } + + private readonly Dictionary> dynamicQueues = new Dictionary>(); + + + protected CustomBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy, CancellationToken cancellationToken) + { + ClientFactory = clientFactory; + RoutingKeyStrategy = routingKeyStrategy; + ExchangeStrategy = exchangeStrategy; + CancellationToken = cancellationToken; + } + + + public virtual Task Apply() + { + return Task.CompletedTask; + } + + + public abstract Task BindDurable(Type messageClass, string queueName); + public abstract Task BindDurableDirect(string queueName); + public abstract Task BindDurableObsolete(string queueName); + + + public async Task BindDynamic(Type messageClass, string queuePrefix = null) + { + var result = await DeclareDynamicQueue(messageClass, queuePrefix); + if (!result.IsNewMessageClass) + return result.QueueName; + + var routingKey = RoutingKeyStrategy.GetRoutingKey(messageClass); + var exchange = ExchangeStrategy.GetExchange(messageClass); + + await ClientFactory().DynamicQueueBind(CancellationToken, result.QueueName, new QueueBinding(exchange, routingKey)); + + return result.QueueName; + } + + + public async Task BindDynamicDirect(Type messageClass, string queuePrefix = null) + { + var result = await DeclareDynamicQueue(messageClass, queuePrefix); + return result.QueueName; + } + + + public async Task BindDynamicDirect(string queuePrefix = null) + { + // If we don't know the routing key, always create a new queue to ensure there is no overlap. + // Keep it out of the dynamicQueues dictionary, so it can't be re-used later on either. + return await ClientFactory().DynamicQueueDeclare(CancellationToken, queuePrefix); + } + + + private struct DeclareDynamicQueueResult + { + public string QueueName; + public bool IsNewMessageClass; + } + + private async Task DeclareDynamicQueue(Type messageClass, string queuePrefix) + { + // Group by prefix + var key = queuePrefix ?? ""; + if (!dynamicQueues.TryGetValue(key, out var prefixQueues)) + { + prefixQueues = new List(); + dynamicQueues.Add(key, prefixQueues); + } + + // Ensure routing keys are unique per dynamic queue, so that a requeue + // will not cause the side-effect of calling another handler again as well. + foreach (var existingQueueInfo in prefixQueues) + { + // ReSharper disable once InvertIf + if (!existingQueueInfo.MessageClasses.Contains(messageClass)) + { + // Allow this routing key in the existing dynamic queue + var result = new DeclareDynamicQueueResult + { + QueueName = existingQueueInfo.QueueName, + IsNewMessageClass = !existingQueueInfo.MessageClasses.Contains(messageClass) + }; + + if (result.IsNewMessageClass) + existingQueueInfo.MessageClasses.Add(messageClass); + + return result; + } + } + + // Declare a new queue + var queueName = await ClientFactory().DynamicQueueDeclare(CancellationToken, queuePrefix); + var queueInfo = new DynamicQueueInfo + { + QueueName = queueName, + MessageClasses = new List { messageClass } + }; + + prefixQueues.Add(queueInfo); + + return new DeclareDynamicQueueResult + { + QueueName = queueName, + IsNewMessageClass = true + }; + } + } + + + private class DeclareDurableQueuesBindingTarget : CustomBindingTarget + { + private readonly Dictionary> durableQueues = new Dictionary>(); + private readonly HashSet obsoleteDurableQueues = new HashSet(); + + + public DeclareDurableQueuesBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy, CancellationToken cancellationToken) : base(clientFactory, routingKeyStrategy, exchangeStrategy, cancellationToken) + { + } + + + public override Task BindDurable(Type messageClass, string queueName) + { + // Collect the message classes per queue so we can determine afterwards + // if any of the bindings currently set on the durable queue are no + // longer valid and should be removed. + if (!durableQueues.TryGetValue(queueName, out var messageClasses)) + { + durableQueues.Add(queueName, new List + { + messageClass + }); + } + else if (!messageClasses.Contains(messageClass)) + messageClasses.Add(messageClass); + + return Task.CompletedTask; + } + + + public override Task BindDurableDirect(string queueName) + { + if (!durableQueues.ContainsKey(queueName)) + durableQueues.Add(queueName, new List()); + + return Task.CompletedTask; + } + + + public override Task BindDurableObsolete(string queueName) + { + obsoleteDurableQueues.Add(queueName); + return Task.CompletedTask; + } + + + public override async Task Apply() + { + var client = ClientFactory(); + await DeclareQueues(client); + await DeleteObsoleteQueues(client); + } + + + private async Task DeclareQueues(ITapetiClient client) + { + await Task.WhenAll(durableQueues.Select(async queue => + { + var bindings = queue.Value.Select(messageClass => + { + var exchange = ExchangeStrategy.GetExchange(messageClass); + var routingKey = RoutingKeyStrategy.GetRoutingKey(messageClass); + + return new QueueBinding(exchange, routingKey); + }); + + await client.DurableQueueDeclare(CancellationToken, queue.Key, bindings); + })); + } + + + private async Task DeleteObsoleteQueues(ITapetiClient client) + { + await Task.WhenAll(obsoleteDurableQueues.Except(durableQueues.Keys).Select(async queue => + { + await client.DurableQueueDelete(CancellationToken, queue); + })); + } + } + + + private class PassiveDurableQueuesBindingTarget : CustomBindingTarget + { + private readonly List durableQueues = new List(); + + + public PassiveDurableQueuesBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy, CancellationToken cancellationToken) : base(clientFactory, routingKeyStrategy, exchangeStrategy, cancellationToken) + { + } + + + public override async Task BindDurable(Type messageClass, string queueName) + { + await VerifyDurableQueue(queueName); + } + + public override async Task BindDurableDirect(string queueName) + { + await VerifyDurableQueue(queueName); + } + + public override Task BindDurableObsolete(string queueName) + { + return Task.CompletedTask; + } + + + private async Task VerifyDurableQueue(string queueName) + { + if (!durableQueues.Contains(queueName)) + { + await ClientFactory().DurableQueueVerify(CancellationToken, queueName); + durableQueues.Add(queueName); + } + } } } } diff --git a/Tapeti/Connection/TapetiWorker.cs b/Tapeti/Connection/TapetiWorker.cs deleted file mode 100644 index aca3a3b..0000000 --- a/Tapeti/Connection/TapetiWorker.cs +++ /dev/null @@ -1,521 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; -using RabbitMQ.Client.Exceptions; -using RabbitMQ.Client.Framing; -using Tapeti.Config; -using Tapeti.Exceptions; -using Tapeti.Helpers; -using Tapeti.Tasks; - -namespace Tapeti.Connection -{ - public class TapetiWorker - { - private const int ReconnectDelay = 5000; - private const int MandatoryReturnTimeout = 30000; - private const int MinimumConnectedReconnectDelay = 1000; - - private readonly IConfig config; - private readonly ILogger logger; - public TapetiConnectionParams ConnectionParams { get; set; } - public IConnectionEventListener ConnectionEventListener { get; set; } - - private readonly IMessageSerializer messageSerializer; - private readonly IRoutingKeyStrategy routingKeyStrategy; - private readonly IExchangeStrategy exchangeStrategy; - private readonly Lazy taskQueue = new Lazy(); - - - // These fields are for use in the taskQueue only! - private RabbitMQ.Client.IConnection connection; - private bool isClosing; - private bool isReconnect; - private IModel channelInstance; - private ulong lastDeliveryTag; - private DateTime connectedDateTime; - - // These fields must be locked, since the callbacks for BasicAck/BasicReturn can run in a different thread - private readonly object confirmLock = new object(); - private readonly Dictionary confirmMessages = new Dictionary(); - private readonly Dictionary returnRoutingKeys = new Dictionary(); - - - private class ConfirmMessageInfo - { - public string ReturnKey; - public TaskCompletionSource CompletionSource; - } - - - private class ReturnInfo - { - public uint RefCount; - public int FirstReplyCode; - } - - - - public TapetiWorker(IConfig config) - { - this.config = config; - - logger = config.DependencyResolver.Resolve(); - messageSerializer = config.DependencyResolver.Resolve(); - routingKeyStrategy = config.DependencyResolver.Resolve(); - exchangeStrategy = config.DependencyResolver.Resolve(); - } - - - public Task Publish(object message, IBasicProperties properties, bool mandatory) - { - return Publish(message, properties, exchangeStrategy.GetExchange(message.GetType()), routingKeyStrategy.GetRoutingKey(message.GetType()), mandatory); - } - - - public Task PublishDirect(object message, string queueName, IBasicProperties properties, bool mandatory) - { - return Publish(message, properties, "", queueName, mandatory); - } - - - public Task Consume(string queueName, IEnumerable bindings) - { - if (string.IsNullOrEmpty(queueName)) - throw new ArgumentNullException(nameof(queueName)); - - return taskQueue.Value.Add(() => - { - WithRetryableChannel(channel => channel.BasicConsume(queueName, false, new TapetiConsumer(this, queueName, config.DependencyResolver, bindings, config.MessageMiddleware, config.CleanupMiddleware))); - }); - } - - - public Task Subscribe(IQueue queue) - { - return taskQueue.Value.Add(() => - { - WithRetryableChannel(channel => - { - if (queue.Dynamic) - { - if (!(queue is IDynamicQueue dynamicQueue)) - throw new NullReferenceException("Queue with Dynamic = true must implement IDynamicQueue"); - - var declaredQueue = channel.QueueDeclare(dynamicQueue.GetDeclareQueueName()); - dynamicQueue.SetName(declaredQueue.QueueName); - - foreach (var binding in queue.Bindings) - { - if (binding.QueueBindingMode == QueueBindingMode.RoutingKey) - { - if (binding.MessageClass == null) - throw new NullReferenceException("Binding with QueueBindingMode = RoutingKey must have a MessageClass"); - - var routingKey = routingKeyStrategy.GetRoutingKey(binding.MessageClass); - var exchange = exchangeStrategy.GetExchange(binding.MessageClass); - - channel.QueueBind(declaredQueue.QueueName, exchange, routingKey); - } - - (binding as IBuildBinding)?.SetQueueName(declaredQueue.QueueName); - } - } - else - { - channel.QueueDeclarePassive(queue.Name); - foreach (var binding in queue.Bindings) - { - (binding as IBuildBinding)?.SetQueueName(queue.Name); - } - } - }); - }); - } - - - public Task Respond(ulong deliveryTag, ConsumeResponse response) - { - return taskQueue.Value.Add(() => - { - // No need for a retryable channel here, if the connection is lost we can't - // use the deliveryTag anymore. - switch (response) - { - case ConsumeResponse.Ack: - GetChannel().BasicAck(deliveryTag, false); - break; - - case ConsumeResponse.Nack: - GetChannel().BasicNack(deliveryTag, false, false); - break; - - case ConsumeResponse.Requeue: - GetChannel().BasicNack(deliveryTag, false, true); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(response), response, null); - } - - }); - } - - - public Task Close() - { - if (!taskQueue.IsValueCreated) - return Task.CompletedTask; - - return taskQueue.Value.Add(() => - { - isClosing = true; - - if (channelInstance != null) - { - channelInstance.Dispose(); - channelInstance = null; - } - - // ReSharper disable once InvertIf - if (connection != null) - { - connection.Dispose(); - connection = null; - } - - taskQueue.Value.Dispose(); - }); - } - - - private Task Publish(object message, IBasicProperties properties, string exchange, string routingKey, bool mandatory) - { - var context = new PublishContext - { - DependencyResolver = config.DependencyResolver, - Exchange = exchange, - RoutingKey = routingKey, - Message = message, - Properties = properties ?? new BasicProperties() - }; - - if (!context.Properties.IsTimestampPresent()) - context.Properties.Timestamp = new AmqpTimestamp(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds()); - - if (!context.Properties.IsDeliveryModePresent()) - context.Properties.DeliveryMode = 2; // Persistent - - - // ReSharper disable ImplicitlyCapturedClosure - MiddlewareHelper will not keep a reference to the lambdas - return MiddlewareHelper.GoAsync( - config.PublishMiddleware, - async (handler, next) => await handler.Handle(context, next), - () => taskQueue.Value.Add(async () => - { - if (Thread.CurrentThread.ManagedThreadId != 3) - Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); - - var body = messageSerializer.Serialize(context.Message, context.Properties); - - Task publishResultTask = null; - var messageInfo = new ConfirmMessageInfo - { - ReturnKey = GetReturnKey(context.Exchange, context.RoutingKey), - CompletionSource = new TaskCompletionSource() - }; - - - WithRetryableChannel(channel => - { - // The delivery tag is lost after a reconnect, register under the new tag - if (config.UsePublisherConfirms) - { - lastDeliveryTag++; - - Monitor.Enter(confirmLock); - try - { - confirmMessages.Add(lastDeliveryTag, messageInfo); - } - finally - { - Monitor.Exit(confirmLock); - } - - publishResultTask = messageInfo.CompletionSource.Task; - } - else - mandatory = false; - - channel.BasicPublish(context.Exchange, context.RoutingKey, mandatory, context.Properties, body); - }); - - - if (publishResultTask == null) - return; - - var delayCancellationTokenSource = new CancellationTokenSource(); - var signalledTask = await Task.WhenAny(publishResultTask, Task.Delay(MandatoryReturnTimeout, delayCancellationTokenSource.Token)); - - if (signalledTask != publishResultTask) - throw new TimeoutException($"Timeout while waiting for basic.return for message with class {context.Message?.GetType().FullName ?? "null"} and Id {context.Properties.MessageId}"); - - delayCancellationTokenSource.Cancel(); - - if (publishResultTask.IsCanceled) - throw new NackException($"Mandatory message with class {context.Message?.GetType().FullName ?? "null"} was nacked"); - - var replyCode = publishResultTask.Result; - - // There is no RabbitMQ.Client.Framing.Constants value for this "No route" reply code - // at the time of writing... - if (replyCode == 312) - throw new NoRouteException($"Mandatory message with class {context.Message?.GetType().FullName ?? "null"} does not have a route"); - - if (replyCode > 0) - throw new NoRouteException($"Mandatory message with class {context.Message?.GetType().FullName ?? "null"} could not be delivery, reply code {replyCode}"); - })); - // ReSharper restore ImplicitlyCapturedClosure - } - - - /// - /// Only call this from a task in the taskQueue to ensure IModel is only used - /// by a single thread, as is recommended in the RabbitMQ .NET Client documentation. - /// - private void WithRetryableChannel(Action operation) - { - while (true) - { - try - { - operation(GetChannel()); - break; - } - catch (AlreadyClosedException e) - { - // TODO log? - } - } - } - - - /// - /// Only call this from a task in the taskQueue to ensure IModel is only used - /// by a single thread, as is recommended in the RabbitMQ .NET Client documentation. - /// - private IModel GetChannel() - { - if (channelInstance != null && channelInstance.IsOpen) - return channelInstance; - - // If the Disconnect quickly follows the Connect (when an error occurs that is reported back by RabbitMQ - // not related to the connection), wait for a bit to avoid spamming the connection - if ((DateTime.UtcNow - connectedDateTime).TotalMilliseconds <= MinimumConnectedReconnectDelay) - Thread.Sleep(ReconnectDelay); - - - var connectionFactory = new ConnectionFactory - { - HostName = ConnectionParams.HostName, - Port = ConnectionParams.Port, - VirtualHost = ConnectionParams.VirtualHost, - UserName = ConnectionParams.Username, - Password = ConnectionParams.Password, - AutomaticRecoveryEnabled = false, - TopologyRecoveryEnabled = false, - RequestedHeartbeat = 30 - }; - - while (true) - { - try - { - logger.Connect(ConnectionParams); - - connection = connectionFactory.CreateConnection(); - channelInstance = connection.CreateModel(); - - if (channelInstance == null) - throw new BrokerUnreachableException(null); - - if (config.UsePublisherConfirms) - { - lastDeliveryTag = 0; - - Monitor.Enter(confirmLock); - try - { - confirmMessages.Clear(); - } - finally - { - Monitor.Exit(confirmLock); - } - - channelInstance.ConfirmSelect(); - } - - if (ConnectionParams.PrefetchCount > 0) - channelInstance.BasicQos(0, ConnectionParams.PrefetchCount, false); - - channelInstance.ModelShutdown += (sender, e) => - { - ConnectionEventListener?.Disconnected(new DisconnectedEventArgs - { - ReplyCode = e.ReplyCode, - ReplyText = e.ReplyText - }); - - channelInstance = null; - - if (!isClosing) - taskQueue.Value.Add(() => WithRetryableChannel(channel => { })); - }; - - channelInstance.BasicReturn += HandleBasicReturn; - channelInstance.BasicAcks += HandleBasicAck; - channelInstance.BasicNacks += HandleBasicNack; - - connectedDateTime = DateTime.UtcNow; - - if (isReconnect) - ConnectionEventListener?.Reconnected(); - else - ConnectionEventListener?.Connected(); - - logger.ConnectSuccess(ConnectionParams); - isReconnect = true; - - break; - } - catch (BrokerUnreachableException e) - { - logger.ConnectFailed(ConnectionParams, e); - Thread.Sleep(ReconnectDelay); - } - } - - return channelInstance; - } - - - private void HandleBasicReturn(object sender, BasicReturnEventArgs e) - { - /* - * "If the message is also published as mandatory, the basic.return is sent to the client before basic.ack." - * - https://www.rabbitmq.com/confirms.html - * - * Because there is no delivery tag included in the basic.return message. This solution is modeled after - * user OhJeez' answer on StackOverflow: - * - * "Since all messages with the same routing key are routed the same way. I assumed that once I get a - * basic.return about a specific routing key, all messages with this routing key can be considered undelivered" - * https://stackoverflow.com/questions/21336659/how-to-tell-which-amqp-message-was-not-routed-from-basic-return-response - */ - var key = GetReturnKey(e.Exchange, e.RoutingKey); - - if (!returnRoutingKeys.TryGetValue(key, out var returnInfo)) - { - returnInfo = new ReturnInfo - { - RefCount = 0, - FirstReplyCode = e.ReplyCode - }; - - returnRoutingKeys.Add(key, returnInfo); - } - - returnInfo.RefCount++; - } - - - private void HandleBasicAck(object sender, BasicAckEventArgs e) - { - Monitor.Enter(confirmLock); - try - { - foreach (var deliveryTag in GetDeliveryTags(e)) - { - if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) - continue; - - if (returnRoutingKeys.TryGetValue(messageInfo.ReturnKey, out var returnInfo)) - { - messageInfo.CompletionSource.SetResult(returnInfo.FirstReplyCode); - - returnInfo.RefCount--; - if (returnInfo.RefCount == 0) - returnRoutingKeys.Remove(messageInfo.ReturnKey); - } - - messageInfo.CompletionSource.SetResult(0); - confirmMessages.Remove(deliveryTag); - } - } - finally - { - Monitor.Exit(confirmLock); - } - } - - - private void HandleBasicNack(object sender, BasicNackEventArgs e) - { - Monitor.Enter(confirmLock); - try - { - foreach (var deliveryTag in GetDeliveryTags(e)) - { - if (!confirmMessages.TryGetValue(deliveryTag, out var messageInfo)) - continue; - - messageInfo.CompletionSource.SetCanceled(); - confirmMessages.Remove(e.DeliveryTag); - } - } - finally - { - Monitor.Exit(confirmLock); - } - } - - - private IEnumerable GetDeliveryTags(BasicAckEventArgs e) - { - return e.Multiple - ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() - : new[] { e.DeliveryTag }; - } - - - private IEnumerable GetDeliveryTags(BasicNackEventArgs e) - { - return e.Multiple - ? confirmMessages.Keys.Where(tag => tag <= e.DeliveryTag).ToArray() - : new[] { e.DeliveryTag }; - } - - - private static string GetReturnKey(string exchange, string routingKey) - { - return exchange + ':' + routingKey; - } - - - private class PublishContext : IPublishContext - { - public IDependencyResolver DependencyResolver { get; set; } - public string Exchange { get; set; } - public string RoutingKey { get; set; } - public object Message { get; set; } - public IBasicProperties Properties { get; set; } - } - } -} diff --git a/Tapeti/ConsumeResponse.cs b/Tapeti/ConsumeResponse.cs deleted file mode 100644 index 2997930..0000000 --- a/Tapeti/ConsumeResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Tapeti -{ - public enum ConsumeResponse - { - Ack, - Nack, - Requeue - } -} diff --git a/Tapeti/ConsumeResult.cs b/Tapeti/ConsumeResult.cs new file mode 100644 index 0000000..e31d274 --- /dev/null +++ b/Tapeti/ConsumeResult.cs @@ -0,0 +1,33 @@ +namespace Tapeti +{ + /// + /// Determines how the message has been handled and the response given to the message bus. + /// + public enum ConsumeResult + { + /// + /// Acknowledge the message and remove it from the queue. + /// + Success, + + /// + /// Negatively acknowledge the message and remove it from the queue, send to dead-letter queue if configured on the bus. + /// + Error, + + /// + /// Negatively acknowledge the message and put it back in the queue to try again later. + /// + Requeue, + + /// + /// The message has been stored for republishing and will be delivered again by some other means. + /// It will be acknowledged and removed from the queue as if succesful. + /// + /// + /// This option is for compatibility with external scheduler services. The exception strategy must guarantee that the + /// message will eventually be republished. + /// + ExternalRequeue + } +} diff --git a/Tapeti/Default/ConsoleLogger.cs b/Tapeti/Default/ConsoleLogger.cs index a00f8d2..e942ae0 100644 --- a/Tapeti/Default/ConsoleLogger.cs +++ b/Tapeti/Default/ConsoleLogger.cs @@ -1,27 +1,63 @@ using System; +using Tapeti.Config; namespace Tapeti.Default { + /// + /// + /// Default ILogger implementation for console applications. + /// public class ConsoleLogger : ILogger { - public void Connect(TapetiConnectionParams connectionParams) + /// + public void Connect(IConnectContext connectContext) { - Console.WriteLine($"[Tapeti] Connecting to {connectionParams.HostName}:{connectionParams.Port}{connectionParams.VirtualHost}"); + Console.WriteLine($"[Tapeti] {(connectContext.IsReconnect ? "Reconnecting" : "Connecting")} to {connectContext.ConnectionParams.HostName}:{connectContext.ConnectionParams.Port}{connectContext.ConnectionParams.VirtualHost}"); } - public void ConnectFailed(TapetiConnectionParams connectionParams, Exception exception) + /// + public void ConnectFailed(IConnectFailedContext connectContext) { - Console.WriteLine($"[Tapeti] Connection failed: {exception}"); + Console.WriteLine($"[Tapeti] Connection failed: {connectContext.Exception}"); } - public void ConnectSuccess(TapetiConnectionParams connectionParams) + /// + public void ConnectSuccess(IConnectSuccessContext connectContext) { - Console.WriteLine("[Tapeti] Connected"); + Console.WriteLine($"[Tapeti] {(connectContext.IsReconnect ? "Reconnected" : "Connected")} using local port {connectContext.LocalPort}"); } - public void HandlerException(Exception e) + /// + public void Disconnect(IDisconnectContext disconnectContext) { - Console.WriteLine(e.ToString()); + Console.WriteLine($"[Tapeti] Connection closed: {(!string.IsNullOrEmpty(disconnectContext.ReplyText) ? disconnectContext.ReplyText : "")} (reply code: {disconnectContext.ReplyCode})"); + } + + /// + public void ConsumeException(Exception exception, IMessageContext messageContext, ConsumeResult consumeResult) + { + Console.WriteLine("[Tapeti] Exception while handling message"); + Console.WriteLine($" Result : {consumeResult}"); + Console.WriteLine($" Exchange : {messageContext.Exchange}"); + Console.WriteLine($" Queue : {messageContext.Queue}"); + Console.WriteLine($" RoutingKey : {messageContext.RoutingKey}"); + + if (messageContext is IControllerMessageContext controllerMessageContext) + { + Console.WriteLine($" Controller : {controllerMessageContext.Binding.Controller.FullName}"); + Console.WriteLine($" Method : {controllerMessageContext.Binding.Method.Name}"); + } + + Console.WriteLine(); + Console.WriteLine(exception); + } + + /// + public void QueueObsolete(string queueName, bool deleted, uint messageCount) + { + Console.WriteLine(deleted + ? $"[Tapeti] Obsolete queue was deleted: {queueName}" + : $"[Tapeti] Obsolete queue bindings removed: {queueName}, {messageCount} messages remaining"); } } } diff --git a/Tapeti/Default/ControllerBindingContext.cs b/Tapeti/Default/ControllerBindingContext.cs new file mode 100644 index 0000000..9d80452 --- /dev/null +++ b/Tapeti/Default/ControllerBindingContext.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Tapeti.Config; + +namespace Tapeti.Default +{ + internal class ControllerBindingContext : IControllerBindingContext + { + private BindingTargetMode? bindingTargetMode; + private readonly List middleware = new List(); + private readonly List parameters; + private readonly ControllerBindingResult result; + + /// + /// Determines how the binding target is configured. + /// + public BindingTargetMode BindingTargetMode => bindingTargetMode ?? BindingTargetMode.Default; + + + /// + /// Provides access to the registered middleware for this method. + /// + public IReadOnlyList Middleware => middleware; + + + /// + public Type MessageClass { get; set; } + + /// + public bool HasMessageClass => MessageClass != null; + + /// + public Type Controller { get; set; } + + /// + public MethodInfo Method { get; set; } + + /// + public IReadOnlyList Parameters => parameters; + + /// + public IBindingResult Result => result; + + + /// + public ControllerBindingContext(IEnumerable parameters, ParameterInfo result) + { + this.parameters = parameters.Select(parameter => new ControllerBindingParameter(parameter)).ToList(); + + this.result = new ControllerBindingResult(result); + } + + + /// + public void SetMessageClass(Type messageClass) + { + if (HasMessageClass) + throw new InvalidOperationException("SetMessageClass can only be called once"); + + MessageClass = messageClass; + } + + + /// + public void SetBindingTargetMode(BindingTargetMode mode) + { + if (bindingTargetMode.HasValue) + throw new InvalidOperationException("SetBindingTargetMode can only be called once"); + + bindingTargetMode = mode; + } + + + /// + public void Use(IControllerMiddlewareBase handler) + { + middleware.Add(handler); + } + + + /// + /// Returns the configured bindings for the parameters. + /// + public IEnumerable GetParameterHandlers() + { + return parameters.Select(p => p.Binding); + } + + + /// + /// Returns the configured result handler. + /// + /// + public ResultHandler GetResultHandler() + { + return result.Handler; + } + } + + + /// + /// + /// Default implementation for IBindingParameter + /// + public class ControllerBindingParameter : IBindingParameter + { + /// + /// Provides access to the configured binding. + /// + public ValueFactory Binding { get; set; } + + + /// + public ParameterInfo Info { get; } + + /// + public bool HasBinding => Binding != null; + + + /// + public ControllerBindingParameter(ParameterInfo info) + { + Info = info; + } + + + /// + public void SetBinding(ValueFactory valueFactory) + { + if (Binding != null) + throw new InvalidOperationException("SetBinding can only be called once"); + + Binding = valueFactory; + } + } + + + /// + /// + /// Default implementation for IBindingResult + /// + public class ControllerBindingResult : IBindingResult + { + /// + /// Provides access to the configured handler. + /// + public ResultHandler Handler { get; set; } + + + /// + public ParameterInfo Info { get; } + + /// + public bool HasHandler => Handler != null; + + + /// + public ControllerBindingResult(ParameterInfo info) + { + Info = info; + } + + + /// + public void SetHandler(ResultHandler resultHandler) + { + if (Handler != null) + throw new InvalidOperationException("SetHandler can only be called once"); + + Handler = resultHandler; + } + } +} diff --git a/Tapeti/Default/ControllerMessageContext.cs b/Tapeti/Default/ControllerMessageContext.cs new file mode 100644 index 0000000..b5aa037 --- /dev/null +++ b/Tapeti/Default/ControllerMessageContext.cs @@ -0,0 +1,61 @@ +using Tapeti.Config; + +namespace Tapeti.Default +{ + internal class ControllerMessageContext : IControllerMessageContext + { + private readonly IMessageContext decoratedContext; + + /// + public object Controller { get; set; } + + /// + public ITapetiConfig Config => decoratedContext.Config; + + /// + public string Queue => decoratedContext.Queue; + + /// + public string Exchange => decoratedContext.Exchange; + + /// + public string RoutingKey => decoratedContext.RoutingKey; + + /// + public object Message => decoratedContext.Message; + + /// + public IMessageProperties Properties => decoratedContext.Properties; + + + IBinding IMessageContext.Binding => decoratedContext.Binding; + IControllerMethodBinding IControllerMessageContext.Binding => decoratedContext.Binding as IControllerMethodBinding; + + + /// + public ControllerMessageContext(IMessageContext decoratedContext) + { + this.decoratedContext = decoratedContext; + } + + + /// + public void Dispose() + { + } + + + /// + public void Store(string key, object value) + { + decoratedContext.Store(key, value); + } + + + /// + public bool Get(string key, out T value) where T : class + { + return decoratedContext.Get(key, out value); + } + } +} diff --git a/Tapeti/Default/ControllerMethodBinding.cs b/Tapeti/Default/ControllerMethodBinding.cs new file mode 100644 index 0000000..a0e5bfa --- /dev/null +++ b/Tapeti/Default/ControllerMethodBinding.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Tapeti.Config; +using Tapeti.Helpers; + +namespace Tapeti.Default +{ + /// + /// + /// Binding implementation for controller methods. Do not instantiate this class yourself, + /// instead use the ITapetiConfigBuilder RegisterController / RegisterAllControllers extension + /// methods. + /// + internal class ControllerMethodBinding : IControllerMethodBinding + { + /// + /// Contains all the required information to bind a controller method to a queue. + /// + public struct BindingInfo + { + /// + /// The controller type associated with this binding. + /// + public Type ControllerType; + + /// + /// The method called when this binding is invoked. + /// + public MethodInfo Method; + + /// + /// The queue this binding consumes. + /// + public QueueInfo QueueInfo; + + /// + /// The message class handled by this binding's method. + /// + public Type MessageClass; + + /// + /// Indicates whether this method accepts messages to the exchange by routing key, or direct-to-queue only. + /// + public BindingTargetMode BindingTargetMode; + + /// + /// Indicates if the method or controller is marked with the Obsolete attribute, indicating it should + /// only handle messages already in the queue and not bind to the routing key for new messages. + /// + public bool IsObsolete; + + /// + /// Value factories for the method parameters. + /// + public IEnumerable ParameterFactories; + + /// + /// The return value handler. + /// + public ResultHandler ResultHandler; + + + /// + /// Filter middleware as registered by the binding middleware. + /// + public IReadOnlyList FilterMiddleware; + + /// + /// Message middleware as registered by the binding middleware. + /// + public IReadOnlyList MessageMiddleware; + + /// + /// Cleanup middleware as registered by the binding middleware. + /// + public IReadOnlyList CleanupMiddleware; + } + + + private readonly IDependencyResolver dependencyResolver; + private readonly BindingInfo bindingInfo; + + private readonly MessageHandlerFunc messageHandler; + + + /// + public string QueueName { get; private set; } + + /// + public QueueType QueueType => bindingInfo.QueueInfo.QueueType; + + /// + public Type Controller => bindingInfo.ControllerType; + + /// + public MethodInfo Method => bindingInfo.Method; + + + /// + public ControllerMethodBinding(IDependencyResolver dependencyResolver, BindingInfo bindingInfo) + { + this.dependencyResolver = dependencyResolver; + this.bindingInfo = bindingInfo; + + messageHandler = WrapMethod(bindingInfo.Method, bindingInfo.ParameterFactories, bindingInfo.ResultHandler); + } + + + /// + public async Task Apply(IBindingTarget target) + { + if (!bindingInfo.IsObsolete) + { + switch (bindingInfo.BindingTargetMode) + { + case BindingTargetMode.Default: + if (bindingInfo.QueueInfo.QueueType == QueueType.Dynamic) + QueueName = await target.BindDynamic(bindingInfo.MessageClass, bindingInfo.QueueInfo.Name); + else + { + await target.BindDurable(bindingInfo.MessageClass, bindingInfo.QueueInfo.Name); + QueueName = bindingInfo.QueueInfo.Name; + } + + break; + + case BindingTargetMode.Direct: + if (bindingInfo.QueueInfo.QueueType == QueueType.Dynamic) + QueueName = await target.BindDynamicDirect(bindingInfo.MessageClass, bindingInfo.QueueInfo.Name); + else + { + await target.BindDurableDirect(bindingInfo.QueueInfo.Name); + QueueName = bindingInfo.QueueInfo.Name; + } + + break; + + default: + throw new ArgumentOutOfRangeException(nameof(bindingInfo.BindingTargetMode), bindingInfo.BindingTargetMode, "Invalid BindingTargetMode"); + } + } + else if (bindingInfo.QueueInfo.QueueType == QueueType.Durable) + { + await target.BindDurableObsolete(bindingInfo.QueueInfo.Name); + QueueName = bindingInfo.QueueInfo.Name; + } + } + + + /// + public bool Accept(Type messageClass) + { + return messageClass == bindingInfo.MessageClass; + } + + + /// + public async Task Invoke(IMessageContext context) + { + var controller = dependencyResolver.Resolve(bindingInfo.ControllerType); + + using (var controllerContext = new ControllerMessageContext(context) + { + Controller = controller + }) + { + if (!await FilterAllowed(controllerContext)) + return; + + + await MiddlewareHelper.GoAsync( + bindingInfo.MessageMiddleware, + async (handler, next) => await handler.Handle(controllerContext, next), + async () => await messageHandler(controllerContext)); + } + } + + + /// + public async Task Cleanup(IMessageContext context, ConsumeResult consumeResult) + { + await MiddlewareHelper.GoAsync( + bindingInfo.CleanupMiddleware, + async (handler, next) => await handler.Cleanup(context, ConsumeResult.Success, next), + () => Task.CompletedTask); + } + + + private async Task FilterAllowed(IControllerMessageContext context) + { + var allowed = false; + await MiddlewareHelper.GoAsync( + bindingInfo.FilterMiddleware, + async (handler, next) => await handler.Filter(context, next), + () => + { + allowed = true; + return Task.CompletedTask; + }); + + return allowed; + } + + + private delegate Task MessageHandlerFunc(IControllerMessageContext context); + + + private static MessageHandlerFunc WrapMethod(MethodInfo method, IEnumerable parameterFactories, ResultHandler resultHandler) + { + if (resultHandler != null) + return WrapResultHandlerMethod(method, parameterFactories, resultHandler); + + if (method.ReturnType == typeof(void)) + return WrapNullMethod(method, parameterFactories); + + if (method.ReturnType == typeof(Task)) + return WrapTaskMethod(method, parameterFactories); + + if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + return WrapGenericTaskMethod(method, parameterFactories); + + return WrapObjectMethod(method, parameterFactories); + } + + + private static MessageHandlerFunc WrapResultHandlerMethod(MethodBase method, IEnumerable parameterFactories, ResultHandler resultHandler) + { + return context => + { + var result = method.Invoke(context.Controller, parameterFactories.Select(p => p(context)).ToArray()); + return resultHandler(context, result); + }; + } + + private static MessageHandlerFunc WrapNullMethod(MethodBase method, IEnumerable parameterFactories) + { + return context => + { + method.Invoke(context.Controller, parameterFactories.Select(p => p(context)).ToArray()); + return Task.CompletedTask; + }; + } + + + private static MessageHandlerFunc WrapTaskMethod(MethodBase method, IEnumerable parameterFactories) + { + return context => (Task)method.Invoke(context.Controller, parameterFactories.Select(p => p(context)).ToArray()); + } + + + private static MessageHandlerFunc WrapGenericTaskMethod(MethodBase method, IEnumerable parameterFactories) + { + return context => + { + return (Task)method.Invoke(context.Controller, parameterFactories.Select(p => p(context)).ToArray()); + }; + } + + + private static MessageHandlerFunc WrapObjectMethod(MethodBase method, IEnumerable parameterFactories) + { + return context => + { + return Task.FromResult(method.Invoke(context.Controller, parameterFactories.Select(p => p(context)).ToArray())); + }; + } + + + + /// + /// Contains information about the queue linked to the controller method. + /// + public class QueueInfo + { + /// + /// The type of queue this binding consumes. + /// + public QueueType QueueType { get; set; } + + /// + /// The name of the durable queue, or optional prefix of the dynamic queue. + /// + public string Name { get; set; } + + + /// + /// Determines if the QueueInfo properties contain a valid combination. + /// + public bool IsValid => QueueType == QueueType.Dynamic || !string.IsNullOrEmpty(Name); + } + } +} diff --git a/Tapeti/Default/DependencyResolverBinding.cs b/Tapeti/Default/DependencyResolverBinding.cs index f1d61bb..8eb3b9a 100644 --- a/Tapeti/Default/DependencyResolverBinding.cs +++ b/Tapeti/Default/DependencyResolverBinding.cs @@ -4,14 +4,20 @@ using Tapeti.Config; namespace Tapeti.Default { - public class DependencyResolverBinding : IBindingMiddleware + /// + /// + /// Attempts to resolve any unhandled parameters to Controller methods using the IoC container. + /// This middleware is included by default in the standard TapetiConfig. + /// + public class DependencyResolverBinding : IControllerBindingMiddleware { - public void Handle(IBindingContext context, Action next) + /// + public void Handle(IControllerBindingContext context, Action next) { next(); foreach (var parameter in context.Parameters.Where(p => !p.HasBinding && p.Info.ParameterType.IsClass)) - parameter.SetBinding(messageContext => messageContext.DependencyResolver.Resolve(parameter.Info.ParameterType)); + parameter.SetBinding(messageContext => messageContext.Config.DependencyResolver.Resolve(parameter.Info.ParameterType)); } } } diff --git a/Tapeti/Default/DevNullLogger.cs b/Tapeti/Default/DevNullLogger.cs index df7952f..bbaf911 100644 --- a/Tapeti/Default/DevNullLogger.cs +++ b/Tapeti/Default/DevNullLogger.cs @@ -1,22 +1,41 @@ using System; +using Tapeti.Config; namespace Tapeti.Default { + /// + /// + /// Default ILogger implementation which does not log anything. + /// public class DevNullLogger : ILogger { - public void Connect(TapetiConnectionParams connectionParams) + /// + public void Connect(IConnectContext connectContext) { } - public void ConnectFailed(TapetiConnectionParams connectionParams, Exception exception) + /// + public void ConnectFailed(IConnectFailedContext connectContext) { } - public void ConnectSuccess(TapetiConnectionParams connectionParams) + /// + public void ConnectSuccess(IConnectSuccessContext connectContext) { } - public void HandlerException(Exception e) + /// + public void Disconnect(IDisconnectContext disconnectContext) + { + } + + /// + public void ConsumeException(Exception exception, IMessageContext messageContext, ConsumeResult consumeResult) + { + } + + /// + public void QueueObsolete(string queueName, bool deleted, uint messageCount) { } } diff --git a/Tapeti/Default/ExceptionStrategyContext.cs b/Tapeti/Default/ExceptionStrategyContext.cs index 89280ee..56e2a8a 100644 --- a/Tapeti/Default/ExceptionStrategyContext.cs +++ b/Tapeti/Default/ExceptionStrategyContext.cs @@ -3,23 +3,33 @@ using Tapeti.Config; namespace Tapeti.Default { - public class ExceptionStrategyContext : IExceptionStrategyContext + internal class ExceptionStrategyContext : IExceptionStrategyContext { - internal ExceptionStrategyContext(IMessageContext messageContext, Exception exception) + /// + /// The ConsumeResult as set by the exception strategy. Defaults to Error. + /// + public ConsumeResult ConsumeResult { get; set; } = ConsumeResult.Error; + + + /// + public IMessageContext MessageContext { get; } + + /// + public Exception Exception { get; } + + + /// + public ExceptionStrategyContext(IMessageContext messageContext, Exception exception) { MessageContext = messageContext; Exception = exception; } - public IMessageContext MessageContext { get; } - public Exception Exception { get; } - - private HandlingResultBuilder handlingResult; - public HandlingResultBuilder HandlingResult + /// + public void SetConsumeResult(ConsumeResult consumeResult) { - get => handlingResult ?? (handlingResult = new HandlingResultBuilder()); - set => handlingResult = value; + ConsumeResult = consumeResult; } } } diff --git a/Tapeti/Default/FallbackStringEnumConverter.cs b/Tapeti/Default/FallbackStringEnumConverter.cs index d4098c3..9801f6c 100644 --- a/Tapeti/Default/FallbackStringEnumConverter.cs +++ b/Tapeti/Default/FallbackStringEnumConverter.cs @@ -4,11 +4,11 @@ using Newtonsoft.Json; namespace Tapeti.Default { + /// /// - /// Converts an to and from its name string value. If an unknown string value is encountered + /// Converts an to and from its name string value. If an unknown string value is encountered /// it will translate to 0xDEADBEEF (-559038737) so it can be gracefully handled. /// If you copy this value as-is to another message and try to send it, this converter will throw an exception. - /// /// This converter is far simpler than the default StringEnumConverter, it assumes both sides use the same /// enum and therefore skips the naming strategy. /// @@ -17,12 +17,14 @@ namespace Tapeti.Default private readonly int invalidEnumValue; + /// public FallbackStringEnumConverter() { unchecked { invalidEnumValue = (int)0xDEADBEEF; } } + /// public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { if (value == null) @@ -39,6 +41,7 @@ namespace Tapeti.Default } + /// public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { var isNullable = IsNullableType(objectType); @@ -72,6 +75,7 @@ namespace Tapeti.Default } + /// public override bool CanConvert(Type objectType) { var actualType = IsNullableType(objectType) ? Nullable.GetUnderlyingType(objectType) : objectType; diff --git a/Tapeti/Default/JsonMessageSerializer.cs b/Tapeti/Default/JsonMessageSerializer.cs index 9cee002..e15a4d3 100644 --- a/Tapeti/Default/JsonMessageSerializer.cs +++ b/Tapeti/Default/JsonMessageSerializer.cs @@ -1,22 +1,27 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Text; using Newtonsoft.Json; -using RabbitMQ.Client; +using Tapeti.Config; namespace Tapeti.Default { + /// + /// + /// IMessageSerializer implementation for JSON encoding and decoding using Newtonsoft.Json. + /// public class JsonMessageSerializer : IMessageSerializer { - protected const string ContentType = "application/json"; - protected const string ClassTypeHeader = "classType"; + private const string ContentType = "application/json"; + private const string ClassTypeHeader = "classType"; private readonly ConcurrentDictionary deserializedTypeNames = new ConcurrentDictionary(); private readonly ConcurrentDictionary serializedTypeNames = new ConcurrentDictionary(); private readonly JsonSerializerSettings serializerSettings; + + /// public JsonMessageSerializer() { serializerSettings = new JsonSerializerSettings @@ -28,35 +33,41 @@ namespace Tapeti.Default } - public byte[] Serialize(object message, IBasicProperties properties) + /// + public byte[] Serialize(object message, IMessageProperties properties) { - if (properties.Headers == null) - properties.Headers = new Dictionary(); - var typeName = serializedTypeNames.GetOrAdd(message.GetType(), SerializeTypeName); - properties.Headers.Add(ClassTypeHeader, Encoding.UTF8.GetBytes(typeName)); + properties.SetHeader(ClassTypeHeader, typeName); properties.ContentType = ContentType; return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message, serializerSettings)); } - public object Deserialize(byte[] body, IBasicProperties properties) + /// + public object Deserialize(byte[] body, IMessageProperties properties) { if (properties.ContentType == null || !properties.ContentType.Equals(ContentType)) throw new ArgumentException($"content_type must be {ContentType}"); - if (properties.Headers == null || !properties.Headers.TryGetValue(ClassTypeHeader, out var typeName)) + var typeName = properties.GetHeader(ClassTypeHeader); + if (string.IsNullOrEmpty(typeName)) throw new ArgumentException($"{ClassTypeHeader} header not present"); - var messageType = deserializedTypeNames.GetOrAdd(Encoding.UTF8.GetString((byte[])typeName), DeserializeTypeName); + var messageType = deserializedTypeNames.GetOrAdd(typeName, DeserializeTypeName); return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(body), messageType, serializerSettings); } - public virtual Type DeserializeTypeName(string typeName) + /// + /// Resolves a Type based on the serialized type name. + /// + /// The type name in the format FullNamespace.ClassName:AssemblyName + /// The resolved Type + /// If the format is unrecognized or the Type could not be resolved + protected virtual Type DeserializeTypeName(string typeName) { var parts = typeName.Split(':'); if (parts.Length != 2) @@ -69,7 +80,14 @@ namespace Tapeti.Default return type; } - public virtual string SerializeTypeName(Type type) + + /// + /// Serializes a Type into a string representation. + /// + /// The type to serialize + /// The type name in the format FullNamespace.ClassName:AssemblyName + /// If the serialized type name results in the AMQP limit of 255 characters to be exceeded + protected virtual string SerializeTypeName(Type type) { var typeName = type.FullName + ":" + type.Assembly.GetName().Name; if (typeName.Length > 255) diff --git a/Tapeti/Default/MessageBinding.cs b/Tapeti/Default/MessageBinding.cs index 3f86b21..2265a88 100644 --- a/Tapeti/Default/MessageBinding.cs +++ b/Tapeti/Default/MessageBinding.cs @@ -3,19 +3,28 @@ using Tapeti.Config; namespace Tapeti.Default { - public class MessageBinding : IBindingMiddleware + /// + /// + /// Gets the message class from the first parameter of a controller method. + /// This middleware is included by default in the standard TapetiConfig. + /// + public class MessageBinding : IControllerBindingMiddleware { - public void Handle(IBindingContext context, Action next) + /// + public void Handle(IControllerBindingContext context, Action next) { - if (context.Parameters.Count == 0) - throw new TopologyConfigurationException($"First parameter of method {context.Method.Name} in controller {context.Method.DeclaringType?.Name} must be a message class"); + if (!context.HasMessageClass) + { + if (context.Parameters.Count == 0) + throw new TopologyConfigurationException($"First parameter of method {context.Method.Name} in controller {context.Method.DeclaringType?.Name} must be a message class"); - var parameter = context.Parameters[0]; - if (!parameter.Info.ParameterType.IsClass) - throw new TopologyConfigurationException($"First parameter {parameter.Info.Name} of method {context.Method.Name} in controller {context.Method.DeclaringType?.Name} must be a message class"); + var parameter = context.Parameters[0]; + if (!parameter.Info.ParameterType.IsClass) + throw new TopologyConfigurationException($"First parameter {parameter.Info.Name} of method {context.Method.Name} in controller {context.Method.DeclaringType?.Name} must be a message class"); - parameter.SetBinding(messageContext => messageContext.Message); - context.MessageClass = parameter.Info.ParameterType; + parameter.SetBinding(messageContext => messageContext.Message); + context.SetMessageClass(parameter.Info.ParameterType); + } next(); } diff --git a/Tapeti/Default/MessageContext.cs b/Tapeti/Default/MessageContext.cs index 77486dc..4c9f1a0 100644 --- a/Tapeti/Default/MessageContext.cs +++ b/Tapeti/Default/MessageContext.cs @@ -1,172 +1,62 @@ using System; -using System.Collections; using System.Collections.Generic; -using RabbitMQ.Client; using Tapeti.Config; -using System.Linq; namespace Tapeti.Default { - public class MessageContext : IMessageContext + internal class MessageContext : IMessageContext { - public IDependencyResolver DependencyResolver { get; set; } + private readonly Dictionary items = new Dictionary(); - public object Controller { get; set; } + + /// + public ITapetiConfig Config { get; set; } + + /// + public string Queue { get; set; } + + /// + public string Exchange { get; set; } + + /// + public string RoutingKey { get; set; } + + /// + public object Message { get; set; } + + /// + public IMessageProperties Properties { get; set; } + + /// public IBinding Binding { get; set; } - public string Queue { get; set; } - public string RoutingKey { get; set; } - public object Message { get; set; } - public IBasicProperties Properties { get; set; } - - public IDictionary Items { get; } - - internal Action UseNestedContext; - internal Action OnContextDisposed; - - public MessageContext() - { - Items = new Dictionary(); - } - - private MessageContext(MessageContext outerContext) - { - DependencyResolver = outerContext.DependencyResolver; - - Controller = outerContext.Controller; - Binding = outerContext.Binding; - - Queue = outerContext.Queue; - RoutingKey = outerContext.RoutingKey; - Message = outerContext.Message; - Properties = outerContext.Properties; - - Items = new DeferingDictionary(outerContext.Items); - } + /// public void Dispose() { - var items = (Items as DeferingDictionary)?.MyState ?? Items; - - foreach (var value in items.Values) - (value as IDisposable)?.Dispose(); - - OnContextDisposed?.Invoke(this); + foreach (var item in items.Values) + (item as IDisposable)?.Dispose(); } - public IMessageContext SetupNestedContext() + + /// + public void Store(string key, object value) { - if (UseNestedContext == null) - throw new NotSupportedException("This context does not support creating nested contexts"); - - var nested = new MessageContext(this); - - UseNestedContext(nested); - - return nested; + items.Add(key, value); } - private class DeferingDictionary : IDictionary + + /// + public bool Get(string key, out T value) where T : class { - private readonly IDictionary myState; - private readonly IDictionary deferee; - - public DeferingDictionary(IDictionary deferee) + if (!items.TryGetValue(key, out var objectValue)) { - myState = new Dictionary(); - this.deferee = deferee; + value = default(T); + return false; } - public IDictionary MyState => myState; - - object IDictionary.this[string key] - { - get => myState.ContainsKey(key) ? myState[key] : deferee[key]; - - set - { - if (deferee.ContainsKey(key)) - throw new InvalidOperationException("Cannot hide an item set in an outer context."); - - myState[key] = value; - } - } - - int ICollection>.Count => myState.Count + deferee.Count; - bool ICollection>.IsReadOnly => false; - ICollection IDictionary.Keys => myState.Keys.Concat(deferee.Keys).ToList().AsReadOnly(); - ICollection IDictionary.Values => myState.Values.Concat(deferee.Values).ToList().AsReadOnly(); - - void ICollection>.Add(KeyValuePair item) - { - if (deferee.ContainsKey(item.Key)) - throw new InvalidOperationException("Cannot hide an item set in an outer context."); - - myState.Add(item); - } - - void IDictionary.Add(string key, object value) - { - if (deferee.ContainsKey(key)) - throw new InvalidOperationException("Cannot hide an item set in an outer context."); - - myState.Add(key, value); - } - - void ICollection>.Clear() - { - throw new InvalidOperationException("Cannot influence the items in an outer context."); - } - - bool ICollection>.Contains(KeyValuePair item) - { - return myState.Contains(item) || deferee.Contains(item); - } - - bool IDictionary.ContainsKey(string key) - { - return myState.ContainsKey(key) || deferee.ContainsKey(key); - } - - void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) - { - foreach(var item in myState.Concat(deferee)) - { - array[arrayIndex++] = item; - } - } - - IEnumerator IEnumerable.GetEnumerator() - { - return (IEnumerator)myState.Concat(deferee); - } - - IEnumerator> IEnumerable>.GetEnumerator() - { - return (IEnumerator < KeyValuePair < string, object>> )myState.Concat(deferee); - } - - bool ICollection>.Remove(KeyValuePair item) - { - if (deferee.ContainsKey(item.Key)) - throw new InvalidOperationException("Cannot remove an item set in an outer context."); - - return myState.Remove(item); - } - - bool IDictionary.Remove(string key) - { - if (deferee.ContainsKey(key)) - throw new InvalidOperationException("Cannot remove an item set in an outer context."); - - return myState.Remove(key); - } - - bool IDictionary.TryGetValue(string key, out object value) - { - return myState.TryGetValue(key, out value) - || deferee.TryGetValue(key, out value); - } + value = (T)objectValue; + return true; } } } diff --git a/Tapeti/Default/MessageProperties.cs b/Tapeti/Default/MessageProperties.cs new file mode 100644 index 0000000..64b2eb2 --- /dev/null +++ b/Tapeti/Default/MessageProperties.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Tapeti.Config; + +namespace Tapeti.Default +{ + /// + /// + /// IMessagePropertiesReader implementation for providing properties manually + /// + public class MessageProperties : IMessageProperties + { + private readonly Dictionary headers = new Dictionary(); + + + /// + public string ContentType { get; set; } + + /// + public string CorrelationId { get; set; } + + /// + public string ReplyTo { get; set; } + + /// + public bool? Persistent { get; set; } + + /// + public DateTime? Timestamp { get; set; } + + + /// + public MessageProperties() + { + } + + + /// + public MessageProperties(IMessageProperties source) + { + if (source == null) + return; + + ContentType = source.ContentType; + CorrelationId = source.CorrelationId; + ReplyTo = source.ReplyTo; + Persistent = source.Persistent; + Timestamp = source.Timestamp; + + headers.Clear(); + foreach (var pair in source.GetHeaders()) + SetHeader(pair.Key, pair.Value); + } + + + /// + public void SetHeader(string name, string value) + { + if (headers.ContainsKey(name)) + headers[name] = value; + else + headers.Add(name, value); + } + + /// + public string GetHeader(string name) + { + return headers.TryGetValue(name, out var value) ? value : null; + } + + /// + public IEnumerable> GetHeaders() + { + return headers; + } + } +} diff --git a/Tapeti/Default/NackExceptionStrategy.cs b/Tapeti/Default/NackExceptionStrategy.cs index 3bbb2d5..06510f2 100644 --- a/Tapeti/Default/NackExceptionStrategy.cs +++ b/Tapeti/Default/NackExceptionStrategy.cs @@ -2,11 +2,16 @@ namespace Tapeti.Default { + /// + /// + /// Default implementation of an exception strategy which marks the messages as Error. + /// public class NackExceptionStrategy : IExceptionStrategy { + /// public void HandleException(IExceptionStrategyContext context) { - context.HandlingResult.ConsumeResponse = ConsumeResponse.Nack; + context.SetConsumeResult(ConsumeResult.Error); } } } diff --git a/Tapeti/Default/NamespaceMatchExchangeStrategy.cs b/Tapeti/Default/NamespaceMatchExchangeStrategy.cs index 01ee0a6..3c4465e 100644 --- a/Tapeti/Default/NamespaceMatchExchangeStrategy.cs +++ b/Tapeti/Default/NamespaceMatchExchangeStrategy.cs @@ -3,13 +3,20 @@ using System.Text.RegularExpressions; namespace Tapeti.Default { + /// + /// + /// IExchangeStrategy implementation which uses the first identifier in the namespace in lower case, + /// skipping the first identifier if it is 'Messaging'. + /// + /// + /// Messaging.Service.Optional.Further.Parts will result in the exchange name 'service'. + /// public class NamespaceMatchExchangeStrategy : IExchangeStrategy { - // If the namespace starts with "Messaging.Service[.Optional.Further.Parts]", the exchange will be "Service". - // If no Messaging prefix is present, the first part of the namespace will be used instead. private static readonly Regex NamespaceRegex = new Regex("^(Messaging\\.)?(?[^\\.]+)", RegexOptions.Compiled | RegexOptions.Singleline); + /// public string GetExchange(Type messageType) { if (messageType.Namespace == null) diff --git a/Tapeti/Default/PublishResultBinding.cs b/Tapeti/Default/PublishResultBinding.cs index 76542c3..933302a 100644 --- a/Tapeti/Default/PublishResultBinding.cs +++ b/Tapeti/Default/PublishResultBinding.cs @@ -2,16 +2,20 @@ using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; -using RabbitMQ.Client.Framing; using Tapeti.Annotations; using Tapeti.Config; using Tapeti.Helpers; namespace Tapeti.Default { - public class PublishResultBinding : IBindingMiddleware + /// + /// + /// Attempts to publish a return value for Controller methods as a response to the incoming message. + /// + public class PublishResultBinding : IControllerBindingMiddleware { - public void Handle(IBindingContext context, Action next) + /// + public void Handle(IControllerBindingContext context, Action next) { next(); @@ -60,18 +64,15 @@ namespace Tapeti.Default if (message == null) throw new ArgumentException("Return value of a request message handler must not be null"); - var publisher = (IInternalPublisher)messageContext.DependencyResolver.Resolve(); - var properties = new BasicProperties(); + var publisher = (IInternalPublisher)messageContext.Config.DependencyResolver.Resolve(); + var properties = new MessageProperties + { + CorrelationId = messageContext.Properties.CorrelationId + }; - // Only set the property if it's not null, otherwise a string reference exception can occur: - // http://rabbitmq.1065348.n5.nabble.com/SocketException-when-invoking-model-BasicPublish-td36330.html - if (messageContext.Properties.IsCorrelationIdPresent()) - properties.CorrelationId = messageContext.Properties.CorrelationId; - - if (messageContext.Properties.IsReplyToPresent()) - return publisher.PublishDirect(message, messageContext.Properties.ReplyTo, properties, messageContext.Properties.Persistent); - - return publisher.Publish(message, properties, false); + return !string.IsNullOrEmpty(messageContext.Properties.ReplyTo) + ? publisher.PublishDirect(message, messageContext.Properties.ReplyTo, properties, messageContext.Properties.Persistent.GetValueOrDefault(true)) + : publisher.Publish(message, properties, false); } } } diff --git a/Tapeti/Default/RabbitMQMessageProperties.cs b/Tapeti/Default/RabbitMQMessageProperties.cs new file mode 100644 index 0000000..3ddd967 --- /dev/null +++ b/Tapeti/Default/RabbitMQMessageProperties.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Text; +using RabbitMQ.Client; +using Tapeti.Config; + +namespace Tapeti.Default +{ + internal class RabbitMQMessageProperties : IMessageProperties + { + /// + /// Provides access to the wrapped IBasicProperties + /// + public IBasicProperties BasicProperties { get; } + + + /// + public string ContentType + { + get => BasicProperties.IsContentTypePresent() ? BasicProperties.ContentType : null; + set { if (!string.IsNullOrEmpty(value)) BasicProperties.ContentType = value; else BasicProperties.ClearContentType(); } + } + + /// + public string CorrelationId + { + get => BasicProperties.IsCorrelationIdPresent() ? BasicProperties.CorrelationId : null; + set { if (!string.IsNullOrEmpty(value)) BasicProperties.CorrelationId = value; else BasicProperties.ClearCorrelationId(); } + } + + /// + public string ReplyTo + { + get => BasicProperties.IsReplyToPresent() ? BasicProperties.ReplyTo : null; + set { if (!string.IsNullOrEmpty(value)) BasicProperties.ReplyTo = value; else BasicProperties.ClearReplyTo(); } + } + + /// + public bool? Persistent + { + get => BasicProperties.Persistent; + set { if (value.HasValue) BasicProperties.Persistent = value.Value; else BasicProperties.ClearDeliveryMode(); } + } + + /// + public DateTime? Timestamp + { + get => DateTimeOffset.FromUnixTimeSeconds(BasicProperties.Timestamp.UnixTime).UtcDateTime; + set + { + if (value.HasValue) + BasicProperties.Timestamp = new AmqpTimestamp(new DateTimeOffset(value.Value.ToUniversalTime()).ToUnixTimeSeconds()); + else + BasicProperties.ClearTimestamp(); + } + } + + + /// + public RabbitMQMessageProperties(IBasicProperties basicProperties) + { + BasicProperties = basicProperties; + } + + + /// + public RabbitMQMessageProperties(IBasicProperties basicProperties, IMessageProperties source) + { + BasicProperties = basicProperties; + if (source == null) + return; + + ContentType = source.ContentType; + CorrelationId = source.CorrelationId; + ReplyTo = source.ReplyTo; + Persistent = source.Persistent; + Timestamp = source.Timestamp; + + BasicProperties.Headers = null; + foreach (var pair in source.GetHeaders()) + SetHeader(pair.Key, pair.Value); + } + + + /// + public void SetHeader(string name, string value) + { + if (BasicProperties.Headers == null) + BasicProperties.Headers = new Dictionary(); + + if (BasicProperties.Headers.ContainsKey(name)) + BasicProperties.Headers[name] = Encoding.UTF8.GetBytes(value); + else + BasicProperties.Headers.Add(name, Encoding.UTF8.GetBytes(value)); + } + + + /// + public string GetHeader(string name) + { + if (BasicProperties.Headers == null) + return null; + + return BasicProperties.Headers.TryGetValue(name, out var value) ? Encoding.UTF8.GetString((byte[])value) : null; + } + + + /// + public IEnumerable> GetHeaders() + { + if (BasicProperties.Headers == null) + yield break; + + foreach (var pair in BasicProperties.Headers) + yield return new KeyValuePair(pair.Key, Encoding.UTF8.GetString((byte[])pair.Value)); + } + } +} diff --git a/Tapeti/Default/RequeueExceptionStrategy.cs b/Tapeti/Default/RequeueExceptionStrategy.cs index 6c014f6..87fa8a2 100644 --- a/Tapeti/Default/RequeueExceptionStrategy.cs +++ b/Tapeti/Default/RequeueExceptionStrategy.cs @@ -4,11 +4,25 @@ namespace Tapeti.Default { + /// + /// + /// Example exception strategy which requeues all messages that result in an error. + /// + /// + /// You probably do not want to use this strategy as-is in production code, unless + /// you are sure that all your exceptions are transient. A better way would be to + /// check for exceptions you know are transient. An even better way would be to + /// never requeue but retry transient errors internally. See the Tapeti documentation + /// for an example of this pattern: + /// + /// https://tapeti.readthedocs.io/en/latest/ + /// public class RequeueExceptionStrategy : IExceptionStrategy { + /// public void HandleException(IExceptionStrategyContext context) { - context.HandlingResult.ConsumeResponse = ConsumeResponse.Requeue; + context.SetConsumeResult(ConsumeResult.Requeue); } } } diff --git a/Tapeti/Default/TypeNameRoutingKeyStrategy.cs b/Tapeti/Default/TypeNameRoutingKeyStrategy.cs index 99bf6b9..5b8af1f 100644 --- a/Tapeti/Default/TypeNameRoutingKeyStrategy.cs +++ b/Tapeti/Default/TypeNameRoutingKeyStrategy.cs @@ -6,6 +6,14 @@ using System.Text.RegularExpressions; namespace Tapeti.Default { + /// + /// + /// IRoutingKeyStrategy implementation which transforms the class name into a dot-separated routing key based + /// on the casing. Accounts for acronyms. If the class name ends with 'Message' it is not included in the routing key. + /// + /// + /// ExampleClassNameMessage will result in example.class.name + /// public class TypeNameRoutingKeyStrategy : IRoutingKeyStrategy { private const string SeparatorPattern = @" @@ -24,12 +32,17 @@ namespace Tapeti.Default private static readonly ConcurrentDictionary RoutingKeyCache = new ConcurrentDictionary(); + /// public string GetRoutingKey(Type messageType) { return RoutingKeyCache.GetOrAdd(messageType, BuildRoutingKey); } + /// + /// Actual implementation of GetRoutingKey, called only when the type has not been cached yet. + /// + /// protected virtual string BuildRoutingKey(Type messageType) { // Split PascalCase into dot-separated parts. If the class name ends in "Message" leave that out. @@ -43,6 +56,7 @@ namespace Tapeti.Default return string.Join(".", words.Select(s => s.ToLower())); } + private static List SplitPascalCase(string value) { var split = SeparatorRegex.Split(value); diff --git a/Tapeti/Exceptions/NackException.cs b/Tapeti/Exceptions/NackException.cs index 408dd71..a2fb7fa 100644 --- a/Tapeti/Exceptions/NackException.cs +++ b/Tapeti/Exceptions/NackException.cs @@ -2,8 +2,13 @@ namespace Tapeti.Exceptions { + /// + /// + /// Raised when a message is nacked by the message bus. + /// public class NackException : Exception { + /// public NackException(string message) : base(message) { } } } diff --git a/Tapeti/Exceptions/NoRouteException.cs b/Tapeti/Exceptions/NoRouteException.cs index 2dcd591..3f1ac64 100644 --- a/Tapeti/Exceptions/NoRouteException.cs +++ b/Tapeti/Exceptions/NoRouteException.cs @@ -2,8 +2,13 @@ namespace Tapeti.Exceptions { + /// + /// + /// Raised when a mandatory message has no route. + /// public class NoRouteException : Exception { + /// public NoRouteException(string message) : base(message) { } } } diff --git a/Tapeti/HandlingResult.cs b/Tapeti/HandlingResult.cs deleted file mode 100644 index 107c206..0000000 --- a/Tapeti/HandlingResult.cs +++ /dev/null @@ -1,63 +0,0 @@ -// ReSharper disable UnusedMember.Global - -namespace Tapeti -{ - public class HandlingResult - { - public HandlingResult() - { - ConsumeResponse = ConsumeResponse.Nack; - MessageAction = MessageAction.None; - } - - /// - /// Determines which response will be given to the message bus from where the message originates. - /// - public ConsumeResponse ConsumeResponse { get; internal set; } - - /// - /// Registers which action the Exception strategy has taken or will take to handle the error condition - /// on the message. This is important to know for cleanup handlers registered by middleware. - /// - public MessageAction MessageAction { get; internal set; } - - } - - public class HandlingResultBuilder - { - private static readonly HandlingResult Default = new HandlingResult(); - - private HandlingResult data = Default; - - public ConsumeResponse ConsumeResponse { - get => data.ConsumeResponse; - set => GetWritableData().ConsumeResponse = value; - } - - public MessageAction MessageAction - { - get => data.MessageAction; - set => GetWritableData().MessageAction = value; - } - - public HandlingResult ToHandlingResult() - { - if (data == Default) - { - return new HandlingResult(); - } - var result = GetWritableData(); - data = Default; - return result; - } - - private HandlingResult GetWritableData() - { - if (data == Default) - { - data = new HandlingResult(); - } - return data; - } - } -} diff --git a/Tapeti/Helpers/ConnectionstringParser.cs b/Tapeti/Helpers/ConnectionstringParser.cs index bbda0d9..4bab299 100644 --- a/Tapeti/Helpers/ConnectionstringParser.cs +++ b/Tapeti/Helpers/ConnectionstringParser.cs @@ -2,6 +2,10 @@ namespace Tapeti.Helpers { + /// + /// Helper class to construct a TapetiConnectionParams instance based on the + /// ConnectionString syntax as used by EasyNetQ. + /// public class ConnectionStringParser { private readonly TapetiConnectionParams result = new TapetiConnectionParams(); @@ -10,6 +14,10 @@ namespace Tapeti.Helpers private int pos = -1; private char current = '\0'; + /// + /// Parses an EasyNetQ-compatible ConnectionString into a TapetiConnectionParams instance. + /// + /// public static TapetiConnectionParams Parse(string connectionstring) { return new ConnectionStringParser(connectionstring).result; @@ -106,13 +114,16 @@ namespace Tapeti.Helpers private void SetValue(string key, string value) { - switch (key.ToLowerInvariant()) { + // ReSharper disable once SwitchStatementMissingSomeCases - by design, don't fail on unknown properties + switch (key.ToLowerInvariant()) + { case "hostname": result.HostName = value; break; case "port": result.Port = int.Parse(value); break; case "virtualhost": result.VirtualHost = value; break; case "username": result.Username = value; break; case "password": result.Password = value; break; case "prefetchcount": result.PrefetchCount = ushort.Parse(value); break; + case "managementport": result.ManagementPort = int.Parse(value); break; } } } diff --git a/Tapeti/Helpers/ConsoleHelper.cs b/Tapeti/Helpers/ConsoleHelper.cs index 0769de8..350947e 100644 --- a/Tapeti/Helpers/ConsoleHelper.cs +++ b/Tapeti/Helpers/ConsoleHelper.cs @@ -2,9 +2,17 @@ namespace Tapeti.Helpers { + /// + /// Helper class for console applications. + /// public static class ConsoleHelper { - // Source: http://stackoverflow.com/questions/6408588/how-to-tell-if-there-is-a-console + /// + /// Determines if the application is running in a console. + /// + /// + /// Source: http://stackoverflow.com/questions/6408588/how-to-tell-if-there-is-a-console + /// public static bool IsAvailable() { try diff --git a/Tapeti/Helpers/MiddlewareHelper.cs b/Tapeti/Helpers/MiddlewareHelper.cs index a40e2bd..ba1158e 100644 --- a/Tapeti/Helpers/MiddlewareHelper.cs +++ b/Tapeti/Helpers/MiddlewareHelper.cs @@ -4,8 +4,18 @@ using System.Threading.Tasks; namespace Tapeti.Helpers { + /// + /// Helper class for executing the middleware pattern. + /// public static class MiddlewareHelper { + /// + /// Executes the chain of middleware synchronously, starting with the last item in the list. + /// + /// The list of middleware to run + /// Receives the middleware which should be called and a reference to the action which will call the next. Pass this on to the middleware. + /// The action to execute when the innermost middleware calls next. + /// public static void Go(IReadOnlyList middleware, Action handle, Action lastHandler) { var handlerIndex = middleware?.Count - 1 ?? -1; @@ -28,6 +38,13 @@ namespace Tapeti.Helpers } + /// + /// Executes the chain of middleware asynchronously, starting with the last item in the list. + /// + /// The list of middleware to run + /// Receives the middleware which should be called and a reference to the action which will call the next. Pass this on to the middleware. + /// The action to execute when the innermost middleware calls next. + /// public static async Task GoAsync(IReadOnlyList middleware, Func, Task> handle, Func lastHandler) { var handlerIndex = middleware?.Count - 1 ?? -1; diff --git a/Tapeti/Helpers/TaskTypeHelper.cs b/Tapeti/Helpers/TaskTypeHelper.cs index 416a7ba..44e0c99 100644 --- a/Tapeti/Helpers/TaskTypeHelper.cs +++ b/Tapeti/Helpers/TaskTypeHelper.cs @@ -3,8 +3,18 @@ using System.Threading.Tasks; namespace Tapeti.Helpers { + /// + /// Helper methods for working with synchronous and asynchronous versions of methods. + /// public static class TaskTypeHelper { + /// + /// Determines if the given type matches the predicate, taking Task types into account. + /// + /// + /// + /// + /// public static bool IsTypeOrTaskOf(this Type type, Func predicate, out bool isTaskOf, out Type actualType) { if (type == typeof(Task)) @@ -32,11 +42,24 @@ namespace Tapeti.Helpers } + /// + /// Determines if the given type matches the predicate, taking Task types into account. + /// + /// + /// + /// public static bool IsTypeOrTaskOf(this Type type, Func predicate, out bool isTaskOf) { return IsTypeOrTaskOf(type, predicate, out isTaskOf, out _); } + + /// + /// Determines if the given type matches the compareTo type, taking Task types into account. + /// + /// + /// + /// public static bool IsTypeOrTaskOf(this Type type, Type compareTo, out bool isTaskOf) { return IsTypeOrTaskOf(type, t => t == compareTo, out isTaskOf); diff --git a/Tapeti/IConnection.cs b/Tapeti/IConnection.cs index 5993f0f..eedc765 100644 --- a/Tapeti/IConnection.cs +++ b/Tapeti/IConnection.cs @@ -5,8 +5,96 @@ using System.Threading.Tasks; namespace Tapeti { + /// + /// + /// + public class ConnectedEventArgs + { + /// + /// The connection parameters used to establish the connection. + /// + public TapetiConnectionParams ConnectionParams; + + /// + /// The local port for the connection. Useful for identifying the connection in the management interface. + /// + public int LocalPort; + } + + + /// + /// Contains information about the reason for a lost connection. + /// + public class DisconnectedEventArgs + { + /// + /// The ReplyCode as indicated by the client library + /// + public ushort ReplyCode; + + /// + /// The ReplyText as indicated by the client library + /// + public string ReplyText; + } + + + /// + public delegate void ConnectedEventHandler(object sender, ConnectedEventArgs e); + + /// + public delegate void DisconnectedEventHandler(object sender, DisconnectedEventArgs e); + + + /// + /// + /// Represents a connection to a RabbitMQ server + /// public interface IConnection : IDisposable { - Task Subscribe(); + /// + /// Creates a subscriber to consume messages from the bound queues. + /// + /// If true, the subscriber will start consuming messages immediately. If false, the queues will be + /// declared but no messages will be consumed yet. Call Resume on the returned ISubscriber to start consuming messages. + Task Subscribe(bool startConsuming = true); + + + /// + /// Synchronous version of Subscribe. + /// + /// If true, the subscriber will start consuming messages immediately. If false, the queues will be + /// declared but no messages will be consumed yet. Call Resume on the returned ISubscriber to start consuming messages. + ISubscriber SubscribeSync(bool startConsuming = true); + + + /// + /// Returns an IPublisher implementation for the current connection. + /// + /// + IPublisher GetPublisher(); + + + /// + /// Closes the connection to RabbitMQ. + /// + Task Close(); + + + /// + /// Fired when a connection to RabbitMQ has been established. + /// + event ConnectedEventHandler Connected; + + /// + /// Fired when the connection to RabbitMQ has been lost. + /// + event DisconnectedEventHandler Disconnected; + + /// + /// Fired when the connection to RabbitMQ has been recovered after an unexpected disconnect. + /// + event ConnectedEventHandler Reconnected; + } } diff --git a/Tapeti/IConsumer.cs b/Tapeti/IConsumer.cs new file mode 100644 index 0000000..7204bc5 --- /dev/null +++ b/Tapeti/IConsumer.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Tapeti.Config; + +namespace Tapeti +{ + /// + /// Processes incoming messages. + /// + public interface IConsumer + { + /// + /// + /// + /// The exchange from which the message originated + /// The routing key the message was sent with + /// Metadata included in the message + /// The raw body of the message + /// + Task Consume(string exchange, string routingKey, IMessageProperties properties, byte[] body); + } +} diff --git a/Tapeti/IDependencyResolver.cs b/Tapeti/IDependencyResolver.cs index f7a67eb..12870bf 100644 --- a/Tapeti/IDependencyResolver.cs +++ b/Tapeti/IDependencyResolver.cs @@ -2,22 +2,84 @@ namespace Tapeti { + /// + /// Wrapper interface for an IoC container to allow dependency injection in Tapeti. + /// public interface IDependencyResolver { + /// + /// Resolve an instance of T + /// + /// The type to instantiate + /// A new or singleton instance, depending on the registration T Resolve() where T : class; + + /// + /// Resolve an instance of T + /// + /// The type to instantiate + /// A new or singleton instance, depending on the registration object Resolve(Type type); } + /// + /// + /// Allows registering controller classes into the IoC container. Also registers default implementations, + /// so that the calling application may override these. + /// + /// + /// All implementations of IDependencyResolver should implement IDependencyContainer as well, + /// otherwise all registrations of Tapeti components will have to be done manually by the application. + /// public interface IDependencyContainer : IDependencyResolver { + /// + /// Registers a default implementation in the IoC container. If an alternative implementation + /// was registered before, it is not replaced. + /// + /// + /// void RegisterDefault() where TService : class where TImplementation : class, TService; + + /// + /// Registers a default implementation in the IoC container. If an alternative implementation + /// was registered before, it is not replaced. + /// + /// + /// void RegisterDefault(Func factory) where TService : class; + + /// + /// Registers a default singleton implementation in the IoC container. If an alternative implementation + /// was registered before, it is not replaced. + /// + /// + /// void RegisterDefaultSingleton() where TService : class where TImplementation : class, TService; + + /// + /// Registers a default singleton implementation in the IoC container. If an alternative implementation + /// was registered before, it is not replaced. + /// + /// + /// void RegisterDefaultSingleton(TService instance) where TService : class; + + /// + /// Registers a default singleton implementation in the IoC container. If an alternative implementation + /// was registered before, it is not replaced. + /// + /// + /// void RegisterDefaultSingleton(Func factory) where TService : class; + + /// + /// Registers a concrete controller class in the IoC container. + /// + /// void RegisterController(Type type); } } diff --git a/Tapeti/IExceptionStrategy.cs b/Tapeti/IExceptionStrategy.cs index 979f454..5aeb5e1 100644 --- a/Tapeti/IExceptionStrategy.cs +++ b/Tapeti/IExceptionStrategy.cs @@ -2,14 +2,16 @@ namespace Tapeti { + /// + /// Called when an exception occurs while handling a message. Determines how it should be handled. + /// public interface IExceptionStrategy { /// /// Called when an exception occurs while handling a message. /// /// The exception strategy context containing the necessary data including the message context and the thrown exception. - /// Also the response to the message can be set. - /// If there is any other handling of the message than the expected default than HandlingResult.MessageFutureAction must be set accordingly. + /// Also proivdes methods for the exception strategy to indicate how the message should be handled. void HandleException(IExceptionStrategyContext context); } } diff --git a/Tapeti/IExchangeStrategy.cs b/Tapeti/IExchangeStrategy.cs index e7aaa7e..2878e71 100644 --- a/Tapeti/IExchangeStrategy.cs +++ b/Tapeti/IExchangeStrategy.cs @@ -2,8 +2,16 @@ namespace Tapeti { + /// + /// Translates message classes into their target exchange. + /// public interface IExchangeStrategy { + /// + /// Determines the exchange belonging to the given message class. + /// + /// + /// string GetExchange(Type messageType); } } diff --git a/Tapeti/ILogger.cs b/Tapeti/ILogger.cs index 8ec857d..0a16ba0 100644 --- a/Tapeti/ILogger.cs +++ b/Tapeti/ILogger.cs @@ -1,16 +1,122 @@ using System; +using Tapeti.Config; // ReSharper disable UnusedMember.Global namespace Tapeti { - // This interface is deliberately specific and typed to allow for structured logging (e.g. Serilog) - // instead of only string-based logging without control over the output. + /// + /// Contains information about the connection being established. + /// + public interface IConnectContext + { + /// + /// The connection parameters used to establish the connection. + /// + TapetiConnectionParams ConnectionParams { get; } + + /// + /// Indicates whether this is an automatic reconnect or an initial connection. + /// + bool IsReconnect { get; } + } + + + /// + /// + /// Contains information about the failed connection. + /// + public interface IConnectFailedContext : IConnectContext + { + /// + /// The exception that caused the connection to fail. + /// + Exception Exception { get; } + } + + + /// + /// + /// Contains information about the established connection. + /// + public interface IConnectSuccessContext : IConnectContext + { + /// + /// The local port for the connection. Useful for identifying the connection in the management interface. + /// + int LocalPort { get; } + } + + + /// + /// Contains information about the disconnection. + /// + public interface IDisconnectContext + { + /// + /// The connection parameters used to establish the connection. + /// + TapetiConnectionParams ConnectionParams { get; } + + /// + /// The reply code as provided by RabbitMQ, if the connection was closed by a protocol message. + /// + ushort ReplyCode { get; } + + /// + /// The reply text as provided by RabbitMQ, if the connection was closed by a protocol message. + /// + string ReplyText { get; } + } + + + /// + /// Handles the logging of various events in Tapeti + /// + /// + /// This interface is deliberately specific and typed to allow for structured logging (e.g. Serilog) + /// instead of only string-based logging without control over the output. + /// public interface ILogger { - void Connect(TapetiConnectionParams connectionParams); - void ConnectFailed(TapetiConnectionParams connectionParams, Exception exception); - void ConnectSuccess(TapetiConnectionParams connectionParams); - void HandlerException(Exception e); + /// + /// Called before a connection to RabbitMQ is attempted. + /// + /// Contains information about the connection being established. + void Connect(IConnectContext connectContext); + + /// + /// Called when the connection has failed. + /// + /// Contains information about the connection that has failed. + void ConnectFailed(IConnectFailedContext connectContext); + + /// + /// Called when a connection to RabbitMQ has been succesfully established. + /// + /// Contains information about the established connection. + void ConnectSuccess(IConnectSuccessContext connectContext); + + /// + /// Called when the connection to RabbitMQ is lost. + /// + /// Contains information about the disconnect event. + void Disconnect(IDisconnectContext disconnectContext); + + /// + /// Called when an exception occurs in a consumer. + /// + /// + /// + /// Indicates the action taken by the exception handler + void ConsumeException(Exception exception, IMessageContext messageContext, ConsumeResult consumeResult); + + /// + /// Called when a queue is determined to be obsolete. + /// + /// + /// True if the queue was empty and has been deleted, false if there are still messages to process + /// If deleted, the number of messages purged, otherwise the number of messages still in the queue + void QueueObsolete(string queueName, bool deleted, uint messageCount); } } diff --git a/Tapeti/IMessageSerializer.cs b/Tapeti/IMessageSerializer.cs index ada89c6..b2bbfc4 100644 --- a/Tapeti/IMessageSerializer.cs +++ b/Tapeti/IMessageSerializer.cs @@ -1,10 +1,26 @@ -using RabbitMQ.Client; +using Tapeti.Config; namespace Tapeti { + /// + /// Provides serialization and deserialization for messages. + /// public interface IMessageSerializer { - byte[] Serialize(object message, IBasicProperties properties); - object Deserialize(byte[] body, IBasicProperties properties); + /// + /// Serialize a message object instance to a byte array. + /// + /// An instance of a message class + /// Writable access to the message properties which will be sent along with the message + /// The encoded message + byte[] Serialize(object message, IMessageProperties properties); + + /// + /// Deserializes a previously serialized message. + /// + /// The encoded message + /// The properties as sent along with the message + /// A decoded instance of the message + object Deserialize(byte[] body, IMessageProperties properties); } } diff --git a/Tapeti/IPublisher.cs b/Tapeti/IPublisher.cs index c55f47c..3a02ac3 100644 --- a/Tapeti/IPublisher.cs +++ b/Tapeti/IPublisher.cs @@ -1,19 +1,50 @@ using System.Threading.Tasks; -using RabbitMQ.Client; +using Tapeti.Config; + +// ReSharper disable once UnusedMember.Global namespace Tapeti { - // Note: Tapeti assumes every implementation of IPublisher can also be cast to an IInternalPublisher. - // The distinction is made on purpose to trigger code-smells in non-Tapeti code when casting. + /// + /// Allows publishing of messages. + /// public interface IPublisher { + /// + /// Publish the specified message. Transport details are determined by the Tapeti configuration. + /// + /// The message to send Task Publish(object message); } + /// + /// + /// Low-level publisher for Tapeti internal use. + /// + /// + /// Tapeti assumes every implementation of IPublisher can also be cast to an IInternalPublisher. + /// The distinction is made on purpose to trigger code-smells in non-Tapeti code when casting. + /// public interface IInternalPublisher : IPublisher { - Task Publish(object message, IBasicProperties properties, bool mandatory); - Task PublishDirect(object message, string queueName, IBasicProperties properties, bool mandatory); + /// + /// Publishes a message. The exchange and routing key are determined by the registered strategies. + /// + /// An instance of a message class + /// Metadata to include in the message + /// If true, an exception will be raised if the message can not be delivered to at least one queue + Task Publish(object message, IMessageProperties properties, bool mandatory); + + + /// + /// Publishes a message directly to a queue. The exchange and routing key are not used. + /// + /// An instance of a message class + /// The name of the queue to send the message to + /// Metadata to include in the message + /// If true, an exception will be raised if the message can not be delivered to the queue + /// + Task PublishDirect(object message, string queueName, IMessageProperties properties, bool mandatory); } } diff --git a/Tapeti/IRoutingKeyStrategy.cs b/Tapeti/IRoutingKeyStrategy.cs index e13f287..db4ff14 100644 --- a/Tapeti/IRoutingKeyStrategy.cs +++ b/Tapeti/IRoutingKeyStrategy.cs @@ -2,8 +2,16 @@ namespace Tapeti { + /// + /// Translates message classes into routing keys. + /// public interface IRoutingKeyStrategy { + /// + /// Determines the routing key for the given message class. + /// + /// + /// string GetRoutingKey(Type messageType); } } diff --git a/Tapeti/ISubscriber.cs b/Tapeti/ISubscriber.cs index 06a76da..f1aaafb 100644 --- a/Tapeti/ISubscriber.cs +++ b/Tapeti/ISubscriber.cs @@ -1,9 +1,17 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; namespace Tapeti { - public interface ISubscriber + /// + /// + /// Manages subscriptions to queues as configured by the bindings. + /// + public interface ISubscriber : IDisposable { + /// + /// Starts consuming from the subscribed queues if not already started. + /// Task Resume(); } } diff --git a/Tapeti/MessageAction.cs b/Tapeti/MessageAction.cs new file mode 100644 index 0000000..a78793a --- /dev/null +++ b/Tapeti/MessageAction.cs @@ -0,0 +1,29 @@ +// ReSharper disable UnusedMember.Global + +namespace Tapeti +{ + /// + /// Indicates how the message was handled. + /// + public enum MessageAction + { + /// + /// The message was handled succesfully. + /// + Success, + + /// + /// There was an error while processing the message. + /// + Error, + + /// + /// The message has been stored for republishing and will be delivered again + /// even if the current messages has been Acked or Nacked. + /// + /// + /// This option is for compatibility with external scheduler services that do not immediately requeue a message. + /// + ExternalRetry + } +} diff --git a/Tapeti/MessageController.cs b/Tapeti/MessageController.cs index 8616de9..138ca97 100644 --- a/Tapeti/MessageController.cs +++ b/Tapeti/MessageController.cs @@ -5,7 +5,7 @@ namespace Tapeti { /// - /// Base class for message controllers + /// Base class for message controllers. /// /// /// Using this base class is not required, you can add the MessageController attribute diff --git a/Tapeti/MessageFutureAction.cs b/Tapeti/MessageFutureAction.cs deleted file mode 100644 index bc48049..0000000 --- a/Tapeti/MessageFutureAction.cs +++ /dev/null @@ -1,11 +0,0 @@ -// ReSharper disable UnusedMember.Global - -namespace Tapeti -{ - public enum MessageAction - { - None = 1, - ErrorLog = 2, - Retry = 3, - } -} diff --git a/Tapeti/Tapeti.csproj b/Tapeti/Tapeti.csproj index d4ecad3..a1cb606 100644 --- a/Tapeti/Tapeti.csproj +++ b/Tapeti/Tapeti.csproj @@ -3,11 +3,16 @@ netstandard2.0 true + 2.0.0 + + + + 1701;1702 - - + + diff --git a/Tapeti/Tapeti.nuspec b/Tapeti/Tapeti.nuspec index 72e9360..565e854 100644 --- a/Tapeti/Tapeti.nuspec +++ b/Tapeti/Tapeti.nuspec @@ -6,7 +6,7 @@ Tapeti Mark van Renswoude Mark van Renswoude - https://raw.githubusercontent.com/MvRens/Tapeti/master/UNLICENSE + Unlicense https://github.com/MvRens/Tapeti https://raw.githubusercontent.com/MvRens/Tapeti/master/resources/icons/Tapeti.png false @@ -15,7 +15,7 @@ rabbitmq tapeti - + diff --git a/Tapeti/TapetiAppSettingsConnectionParams.cs b/Tapeti/TapetiAppSettingsConnectionParams.cs index e6312e5..a18285b 100644 --- a/Tapeti/TapetiAppSettingsConnectionParams.cs +++ b/Tapeti/TapetiAppSettingsConnectionParams.cs @@ -1,36 +1,85 @@ -using System; -using System.Configuration; +using System.Configuration; using System.Linq; namespace Tapeti { + /// + /// + /// Implementation of TapetiConnectionParams which reads the values from the AppSettings. + /// + /// + /// + /// AppSettings keys + /// + /// rabbitmq:hostname + /// rabbitmq:port + /// rabbitmq:virtualhost + /// rabbitmq:username + /// rabbitmq:password + /// rabbitmq:prefetchcount + /// rabbitmq:managementport + /// rabbitmq:clientproperty:* + /// public class TapetiAppSettingsConnectionParams : TapetiConnectionParams { - public const string DefaultPrefix = "rabbitmq:"; - public const string KeyHostname = "hostname"; - public const string KeyPort = "port"; - public const string KeyVirtualHost = "virtualhost"; - public const string KeyUsername = "username"; - public const string KeyPassword = "password"; - public const string KeyPrefetchCount = "prefetchcount"; + private const string DefaultPrefix = "rabbitmq:"; + private const string KeyHostname = "hostname"; + private const string KeyPort = "port"; + private const string KeyVirtualHost = "virtualhost"; + private const string KeyUsername = "username"; + private const string KeyPassword = "password"; + private const string KeyPrefetchCount = "prefetchcount"; + private const string KeyManagementPort = "managementport"; + private const string KeyClientProperty = "clientproperty:"; + private struct AppSettingsKey + { + public readonly string Entry; + public readonly string Parameter; + + public AppSettingsKey(string entry, string parameter) + { + Entry = entry; + Parameter = parameter; + } + } + + + /// + /// + /// The prefix to apply to the keys. Defaults to "rabbitmq:" public TapetiAppSettingsConnectionParams(string prefix = DefaultPrefix) { - var keys = ConfigurationManager.AppSettings.AllKeys; + var keys = !string.IsNullOrEmpty(prefix) + ? ConfigurationManager.AppSettings.AllKeys.Where(k => k.StartsWith(prefix)).Select(k => new AppSettingsKey(k, k.Substring(prefix.Length))) + : ConfigurationManager.AppSettings.AllKeys.Select(k => new AppSettingsKey(k, k)); - void GetAppSetting(string key, Action setValue) + + + foreach (var key in keys) { - if (keys.Contains(prefix + key)) setValue(ConfigurationManager.AppSettings[prefix + key]); + var value = ConfigurationManager.AppSettings[key.Entry]; + + if (key.Parameter.StartsWith(KeyClientProperty)) + { + ClientProperties.Add(key.Parameter.Substring(KeyClientProperty.Length), value); + } + else + { + // ReSharper disable once SwitchStatementMissingSomeCases - don't fail if we encounter an unknown value + switch (key.Parameter) + { + case KeyHostname: HostName = value; break; + case KeyPort: Port = int.Parse(value); break; + case KeyVirtualHost: VirtualHost = value; break; + case KeyUsername: Username = value; break; + case KeyPassword: Password = value; break; + case KeyPrefetchCount: PrefetchCount = ushort.Parse(value); break; + case KeyManagementPort: ManagementPort = int.Parse(value); break; + } + } } - - - GetAppSetting(KeyHostname, value => HostName = value); - GetAppSetting(KeyPort, value => Port = int.Parse(value)); - GetAppSetting(KeyVirtualHost, value => VirtualHost = value); - GetAppSetting(KeyUsername, value => Username = value); - GetAppSetting(KeyPassword, value => Password = value); - GetAppSetting(KeyPrefetchCount, value => PrefetchCount = ushort.Parse(value)); } } } diff --git a/Tapeti/TapetiConfig.cs b/Tapeti/TapetiConfig.cs index 9291798..7a542a1 100644 --- a/Tapeti/TapetiConfig.cs +++ b/Tapeti/TapetiConfig.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Threading.Tasks; -using Tapeti.Annotations; using Tapeti.Config; using Tapeti.Default; using Tapeti.Helpers; @@ -12,198 +10,169 @@ using Tapeti.Helpers; namespace Tapeti { - public class TopologyConfigurationException : Exception + /// + /// + /// Default implementation of the Tapeti config builder. + /// Automatically registers the default middleware for injecting the message parameter and handling the return value. + /// + public class TapetiConfig : ITapetiConfigBuilder, ITapetiConfigBuilderAccess { - public TopologyConfigurationException(string message) : base(message) { } - } - - public delegate Task MessageHandlerFunc(IMessageContext context, object message); + private Config config; + private readonly List bindingMiddleware = new List(); - public class TapetiConfig - { - private readonly Dictionary> staticRegistrations = new Dictionary>(); - private readonly Dictionary>> dynamicRegistrations = new Dictionary>>(); - private readonly List uniqueRegistrations = new List(); - - private readonly List customBindings = new List(); - private readonly List bindingMiddleware = new List(); - private readonly List messageMiddleware = new List(); - private readonly List cleanupMiddleware = new List(); - private readonly List publishMiddleware = new List(); - - private readonly IDependencyResolver dependencyResolver; - - private bool usePublisherConfirms = true; + /// + public IDependencyResolver DependencyResolver => GetConfig().DependencyResolver; + /// + /// Instantiates a new Tapeti config builder. + /// + /// A wrapper implementation for an IoC container to allow dependency injection public TapetiConfig(IDependencyResolver dependencyResolver) { - this.dependencyResolver = dependencyResolver; + config = new Config(dependencyResolver); Use(new DependencyResolverBinding()); - Use(new MessageBinding()); Use(new PublishResultBinding()); + + // Registered last so it runs first and the MessageClass is known to other middleware + Use(new MessageBinding()); } - public IConfig Build() + /// + public ITapetiConfig Build() { - RegisterCustomBindings(); + if (config == null) + throw new InvalidOperationException("TapetiConfig.Build must only be called once"); RegisterDefaults(); - - var queues = new List(); - queues.AddRange(staticRegistrations.Select(qb => new Queue(new QueueInfo { Dynamic = false, Name = qb.Key }, qb.Value))); + (config.DependencyResolver as IDependencyContainer)?.RegisterDefaultSingleton(config); - // We want to ensure each queue only has unique messages classes. This means we can requeue - // without the side-effect of calling other handlers for the same message class again as well. - // - // Since I had trouble deciphering this code after a year, here's an overview of how it achieves this grouping - // and how the bindingIndex is relevant: - // - // dynamicRegistrations: - // Key (prefix) - // "" - // Key (message class) Value (list of bindings) - // A binding1, binding2, binding3 - // B binding4 - // "prefix" - // A binding5, binding6 - // - // By combining all bindings with the same index, per prefix, the following queues will be registered: - // - // Prefix Bindings - // "" binding1 (message A), binding4 (message B) - // "" binding2 (message A) - // "" binding3 (message A) - // "prefix" binding5 (message A) - // "prefix" binding6 (message A) - // - foreach (var prefixGroup in dynamicRegistrations) - { - var dynamicBindings = new List>(); + var outputConfig = config; + config = null; - foreach (var bindings in prefixGroup.Value.Values) - { - while (dynamicBindings.Count < bindings.Count) - dynamicBindings.Add(new List()); - - for (var bindingIndex = 0; bindingIndex < bindings.Count; bindingIndex++) - dynamicBindings[bindingIndex].Add(bindings[bindingIndex]); - } - - queues.AddRange(dynamicBindings.Select(bl => new Queue(new QueueInfo { Dynamic = true, Name = GetDynamicQueueName(prefixGroup.Key) }, bl))); - } - - queues.AddRange(uniqueRegistrations.Select(b => new Queue(new QueueInfo { Dynamic = true, Name = GetDynamicQueueName(b.QueueInfo.Name) }, new []{b}))); - - - var config = new Config(queues) - { - DependencyResolver = dependencyResolver, - MessageMiddleware = messageMiddleware, - CleanupMiddleware = cleanupMiddleware, - PublishMiddleware = publishMiddleware, - - UsePublisherConfirms = usePublisherConfirms - }; - - (dependencyResolver as IDependencyContainer)?.RegisterDefaultSingleton(config); - - return config; + outputConfig.Lock(); + return outputConfig; } - public TapetiConfig Use(IBindingMiddleware handler) + /// + public ITapetiConfigBuilder Use(IControllerBindingMiddleware handler) { bindingMiddleware.Add(handler); return this; } - public TapetiConfig Use(IMessageMiddleware handler) + /// + public ITapetiConfigBuilder Use(IMessageMiddleware handler) { - messageMiddleware.Add(handler); + GetConfig().Use(handler); return this; } - public TapetiConfig Use(ICleanupMiddleware handler) + /// + public ITapetiConfigBuilder Use(IPublishMiddleware handler) { - cleanupMiddleware.Add(handler); + GetConfig().Use(handler); return this; } - public TapetiConfig Use(IPublishMiddleware handler) + /// + public ITapetiConfigBuilder Use(ITapetiExtension extension) { - publishMiddleware.Add(handler); - return this; - } - - - public TapetiConfig Use(ITapetiExtension extension) - { - if (dependencyResolver is IDependencyContainer container) + if (DependencyResolver is IDependencyContainer container) extension.RegisterDefaults(container); - var middlewareBundle = extension.GetMiddleware(dependencyResolver); + var configInstance = GetConfig(); - if (extension is ITapetiExtentionBinding extentionBindings) - customBindings.AddRange(extentionBindings.GetBindings(dependencyResolver)); - - // ReSharper disable once InvertIf + var middlewareBundle = extension.GetMiddleware(DependencyResolver); if (middlewareBundle != null) { foreach (var middleware in middlewareBundle) { - // ReSharper disable once CanBeReplacedWithTryCastAndCheckForNull - if (middleware is IBindingMiddleware bindingExtension) - Use(bindingExtension); - else if (middleware is IMessageMiddleware messageExtension) - Use(messageExtension); - else if (middleware is ICleanupMiddleware cleanupExtension) - Use(cleanupExtension); - else if (middleware is IPublishMiddleware publishExtension) - Use(publishExtension); - else - throw new ArgumentException($"Unsupported middleware implementation: {(middleware == null ? "null" : middleware.GetType().Name)}"); + switch (middleware) + { + case IControllerBindingMiddleware bindingExtension: + Use(bindingExtension); + break; + + case IMessageMiddleware messageExtension: + configInstance.Use(messageExtension); + break; + + case IPublishMiddleware publishExtension: + configInstance.Use(publishExtension); + break; + + default: + throw new ArgumentException( + $"Unsupported middleware implementation: {(middleware == null ? "null" : middleware.GetType().Name)}"); + } } } + var bindingBundle = (extension as ITapetiExtensionBinding)?.GetBindings(DependencyResolver); + if (bindingBundle == null) + return this; + + foreach (var binding in bindingBundle) + config.RegisterBinding(binding); + return this; } - - /// - /// WARNING: disabling publisher confirms means there is no guarantee that a Publish succeeds, - /// and disables Tapeti.Flow from verifying if a request/response can be routed. This may - /// result in never-ending flows. Only disable if you can accept those consequences. - /// - public TapetiConfig DisablePublisherConfirms() + + /// + public void RegisterBinding(IBinding binding) { - usePublisherConfirms = false; + GetConfig().RegisterBinding(binding); + } + + + /// + public ITapetiConfigBuilder DisablePublisherConfirms() + { + GetConfig().SetPublisherConfirms(false); + return this; + } + + + /// + public ITapetiConfigBuilder SetPublisherConfirms(bool enabled) + { + GetConfig().SetPublisherConfirms(enabled); + return this; + } + + + /// + public ITapetiConfigBuilder EnableDeclareDurableQueues() + { + GetConfig().SetDeclareDurableQueues(true); + return this; + } + + + /// + public ITapetiConfigBuilder SetDeclareDurableQueues(bool enabled) + { + GetConfig().SetDeclareDurableQueues(enabled); return this; } /// - /// WARNING: disabling publisher confirms means there is no guarantee that a Publish succeeds, - /// and disables Tapeti.Flow from verifying if a request/response can be routed. This may - /// result in never-ending flows. Only disable if you accept those consequences. + /// Registers the default implementation of various Tapeti interfaces into the IoC container. /// - public TapetiConfig SetPublisherConfirms(bool enabled) + protected void RegisterDefaults() { - usePublisherConfirms = enabled; - return this; - } - - - public void RegisterDefaults() - { - if (!(dependencyResolver is IDependencyContainer container)) + if (!(DependencyResolver is IDependencyContainer container)) return; if (ConsoleHelper.IsAvailable()) @@ -218,538 +187,124 @@ namespace Tapeti } - public TapetiConfig RegisterController(Type controller) + /// + public void ApplyBindingMiddleware(IControllerBindingContext context, Action lastHandler) { - var controllerQueueInfo = GetQueueInfo(controller); - - if (!controller.IsInterface) - (dependencyResolver as IDependencyContainer)?.RegisterController(controller); - - foreach (var method in controller.GetMembers(BindingFlags.Public | BindingFlags.Instance) - .Where(m => m.MemberType == MemberTypes.Method && m.DeclaringType != typeof(object) && (m as MethodInfo)?.IsSpecialName == false) - .Select(m => (MethodInfo)m)) - { - var context = new BindingContext(method); - var messageHandler = GetMessageHandler(context, method); - if (messageHandler == null) - continue; - - var methodQueueInfo = GetQueueInfo(method) ?? controllerQueueInfo; - if (!methodQueueInfo.IsValid) - throw new TopologyConfigurationException( - $"Method {method.Name} or controller {controller.Name} requires a queue attribute"); - - var handlerInfo = new Binding - { - Controller = controller, - Method = method, - QueueInfo = methodQueueInfo, - QueueBindingMode = context.QueueBindingMode, - MessageClass = context.MessageClass, - MessageHandler = messageHandler, - MessageMiddleware = context.MessageMiddleware, - MessageFilterMiddleware = context.MessageFilterMiddleware - }; - - if (methodQueueInfo.Dynamic.GetValueOrDefault()) - AddDynamicRegistration(handlerInfo); - else - AddStaticRegistration(handlerInfo); - } - - return this; - } - - - public TapetiConfig RegisterAllControllers(Assembly assembly) - { - foreach (var type in assembly.GetTypes().Where(t => t.IsDefined(typeof(MessageControllerAttribute)))) - RegisterController(type); - - return this; - } - - - public TapetiConfig RegisterAllControllers() - { - return RegisterAllControllers(Assembly.GetEntryAssembly()); - } - - private void RegisterCustomBindings() - { - foreach (var customBinding in customBindings) - { - // TODO Do we need to configure additional middleware, or does this only get confused if there is no MessageClass - - var binding = new CustomBinding(customBinding); - if (binding.QueueInfo.Dynamic == false) - { - AddStaticRegistration(binding); - } - else if (binding.MessageClass != null) - { - AddDynamicRegistration(binding); - } - else - { - AddUniqueRegistration(binding); - } - } - } - - protected MessageHandlerFunc GetMessageHandler(IBindingContext context, MethodInfo method) - { - var allowBinding= false; - - MiddlewareHelper.Go(bindingMiddleware, + MiddlewareHelper.Go(bindingMiddleware, (handler, next) => handler.Handle(context, next), - () => - { - allowBinding = true; - }); - - if (!allowBinding) - return null; - - if (context.MessageClass == null) - throw new TopologyConfigurationException($"Method {method.Name} in controller {method.DeclaringType?.Name} does not resolve to a message class"); + lastHandler); + } - var invalidBindings = context.Parameters.Where(p => !p.HasBinding).ToList(); + private Config GetConfig() + { + if (config == null) + throw new InvalidOperationException("TapetiConfig can not be updated after Build"); - // ReSharper disable once InvertIf - if (invalidBindings.Count > 0) + return config; + } + + + /// + internal class Config : ITapetiConfig + { + private readonly ConfigFeatures features = new ConfigFeatures(); + private readonly ConfigMiddleware middleware = new ConfigMiddleware(); + private readonly ConfigBindings bindings = new ConfigBindings(); + + public IDependencyResolver DependencyResolver { get; } + public ITapetiConfigFeatues Features => features; + public ITapetiConfigMiddleware Middleware => middleware; + public ITapetiConfigBindings Bindings => bindings; + + + public Config(IDependencyResolver dependencyResolver) { - var parameterNames = string.Join(", ", invalidBindings.Select(p => p.Info.Name)); - throw new TopologyConfigurationException($"Method {method.Name} in controller {method.DeclaringType?.Name} has unknown parameters: {parameterNames}"); - } - - var resultHandler = ((IBindingResultAccess) context.Result).GetHandler(); - - return WrapMethod(method, context.Parameters.Select(p => ((IBindingParameterAccess)p).GetBinding()), resultHandler); - } - - - protected MessageHandlerFunc WrapMethod(MethodInfo method, IEnumerable parameters, ResultHandler resultHandler) - { - if (resultHandler != null) - return WrapResultHandlerMethod(method, parameters, resultHandler); - - if (method.ReturnType == typeof(void)) - return WrapNullMethod(method, parameters); - - if (method.ReturnType == typeof(Task)) - return WrapTaskMethod(method, parameters); - - if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) - return WrapGenericTaskMethod(method, parameters); - - return WrapObjectMethod(method, parameters); - } - - - protected MessageHandlerFunc WrapResultHandlerMethod(MethodInfo method, IEnumerable parameters, ResultHandler resultHandler) - { - return (context, message) => - { - var result = method.Invoke(context.Controller, parameters.Select(p => p(context)).ToArray()); - return resultHandler(context, result); - }; - } - - protected MessageHandlerFunc WrapNullMethod(MethodInfo method, IEnumerable parameters) - { - return (context, message) => - { - method.Invoke(context.Controller, parameters.Select(p => p(context)).ToArray()); - return Task.CompletedTask; - }; - } - - - protected MessageHandlerFunc WrapTaskMethod(MethodInfo method, IEnumerable parameters) - { - return (context, message) => (Task)method.Invoke(context.Controller, parameters.Select(p => p(context)).ToArray()); - } - - - protected MessageHandlerFunc WrapGenericTaskMethod(MethodInfo method, IEnumerable parameters) - { - return (context, message) => - { - return (Task)method.Invoke(context.Controller, parameters.Select(p => p(context)).ToArray()); - }; - } - - - protected MessageHandlerFunc WrapObjectMethod(MethodInfo method, IEnumerable parameters) - { - return (context, message) => - { - return Task.FromResult(method.Invoke(context.Controller, parameters.Select(p => p(context)).ToArray())); - }; - } - - - protected void AddStaticRegistration(IBindingQueueInfo binding) - { - if (staticRegistrations.ContainsKey(binding.QueueInfo.Name)) - { - var existing = staticRegistrations[binding.QueueInfo.Name]; - - // TODO allow multiple only if there is a filter which guarantees uniqueness? and/or move to independant validation middleware - //if (existing.Any(h => h.MessageClass == binding.MessageClass)) - // throw new TopologyConfigurationException($"Multiple handlers for message class {binding.MessageClass.Name} in queue {binding.QueueInfo.Name}"); - - existing.Add(binding); - } - else - staticRegistrations.Add(binding.QueueInfo.Name, new List { binding }); - } - - - protected void AddDynamicRegistration(IBindingQueueInfo binding) - { - var prefix = binding.QueueInfo.Name ?? ""; - - if (!dynamicRegistrations.TryGetValue(prefix, out Dictionary> prefixRegistrations)) - { - prefixRegistrations = new Dictionary>(); - dynamicRegistrations.Add(prefix, prefixRegistrations); - } - - if (!prefixRegistrations.TryGetValue(binding.MessageClass, out List bindings)) - { - bindings = new List(); - prefixRegistrations.Add(binding.MessageClass, bindings); - } - - bindings.Add(binding); - } - - protected void AddUniqueRegistration(IBindingQueueInfo binding) - { - uniqueRegistrations.Add(binding); - } - - protected QueueInfo GetQueueInfo(MemberInfo member) - { - var dynamicQueueAttribute = member.GetCustomAttribute(); - var durableQueueAttribute = member.GetCustomAttribute(); - - if (dynamicQueueAttribute != null && durableQueueAttribute != null) - throw new TopologyConfigurationException($"Cannot combine static and dynamic queue attributes on {member.Name}"); - - if (dynamicQueueAttribute != null) - return new QueueInfo { Dynamic = true, Name = dynamicQueueAttribute.Prefix }; - - if (durableQueueAttribute != null) - return new QueueInfo { Dynamic = false, Name = durableQueueAttribute.Name }; - - return null; - } - - - protected string GetDynamicQueueName(string prefix) - { - if (String.IsNullOrEmpty(prefix)) - return ""; - - return prefix + "." + Guid.NewGuid().ToString("N"); - } - - - protected class QueueInfo - { - public bool? Dynamic { get; set; } - public string Name { get; set; } - - public bool IsValid => Dynamic.HasValue || !string.IsNullOrEmpty(Name); - } - - - protected class Config : IConfig - { - public bool UsePublisherConfirms { get; set; } - - public IDependencyResolver DependencyResolver { get; set; } - public IReadOnlyList MessageMiddleware { get; set; } - public IReadOnlyList CleanupMiddleware { get; set; } - public IReadOnlyList PublishMiddleware { get; set; } - public IEnumerable Queues { get; } - - private readonly Dictionary bindingMethodLookup; - - - public Config(IEnumerable queues) - { - Queues = queues.ToList(); - - bindingMethodLookup = Queues.SelectMany(q => q.Bindings).ToDictionary(b => b.Method, b => b); + DependencyResolver = dependencyResolver; } - public IBinding GetBinding(Delegate method) + public void Lock() { - return bindingMethodLookup.TryGetValue(method.Method, out var binding) ? binding : null; + bindings.Lock(); + } + + + public void Use(IMessageMiddleware handler) + { + middleware.Use(handler); + } + + public void Use(IPublishMiddleware handler) + { + middleware.Use(handler); + } + + + public void RegisterBinding(IBinding binding) + { + bindings.Add(binding); + } + + + public void SetPublisherConfirms(bool enabled) + { + features.PublisherConfirms = enabled; + } + + public void SetDeclareDurableQueues(bool enabled) + { + features.DeclareDurableQueues = enabled; } } - protected class Queue : IDynamicQueue + internal class ConfigFeatures : ITapetiConfigFeatues { - private readonly string declareQueueName; - - public bool Dynamic { get; } - public string Name { get; set; } - public IEnumerable Bindings { get; } - - - public Queue(QueueInfo queue, IEnumerable bindings) - { - declareQueueName = queue.Name; - - Dynamic = queue.Dynamic.GetValueOrDefault(); - Name = queue.Name; - Bindings = bindings; - } - - - public string GetDeclareQueueName() - { - return declareQueueName; - } - - - public void SetName(string name) - { - Name = name; - } + public bool PublisherConfirms { get; internal set; } = true; + public bool DeclareDurableQueues { get; internal set; } = true; } - protected interface IBindingQueueInfo : IBuildBinding + + internal class ConfigMiddleware : ITapetiConfigMiddleware { - QueueInfo QueueInfo { get; } - } + private readonly List messageMiddleware = new List(); + private readonly List publishMiddleware = new List(); - protected class Binding : IBindingQueueInfo - { - public Type Controller { get; set; } - public MethodInfo Method { get; set; } - public Type MessageClass { get; set; } - public string QueueName { get; set; } - public QueueBindingMode QueueBindingMode { get; set; } - public IReadOnlyList MessageMiddleware { get; set; } - public IReadOnlyList MessageFilterMiddleware { get; set; } + public IReadOnlyList Message => messageMiddleware; + public IReadOnlyList Publish => publishMiddleware; - private QueueInfo queueInfo; - public QueueInfo QueueInfo + + public void Use(IMessageMiddleware handler) { - get => queueInfo; - set - { - QueueName = (value?.Dynamic).GetValueOrDefault() ? value?.Name : null; - queueInfo = value; - } + messageMiddleware.Add(handler); } - public MessageHandlerFunc MessageHandler { get; set; } - - - public void SetQueueName(string queueName) + public void Use(IPublishMiddleware handler) { - QueueName = queueName; - } - - - public bool Accept(Type messageClass) - { - return MessageClass.IsAssignableFrom(messageClass); - } - - public bool Accept(IMessageContext context, object message) - { - return message.GetType() == MessageClass; - } - - - public Task Invoke(IMessageContext context, object message) - { - return MessageHandler(context, message); + publishMiddleware.Add(handler); } } - protected class CustomBinding : IBindingQueueInfo + internal class ConfigBindings : List, ITapetiConfigBindings { - private readonly ICustomBinding inner; + private Dictionary methodLookup; - public CustomBinding(ICustomBinding inner) + + public IControllerMethodBinding ForMethod(Delegate method) { - this.inner = inner; - - // Copy all variables to make them guaranteed readonly. - Controller = inner.Controller; - Method = inner.Method; - QueueBindingMode = inner.QueueBindingMode; - MessageClass = inner.MessageClass; - - QueueInfo = inner.StaticQueueName != null - ? new QueueInfo() - { - Dynamic = false, - Name = inner.StaticQueueName - } - : new QueueInfo() - { - Dynamic = true, - Name = inner.DynamicQueuePrefix - }; - - // Custom bindings cannot have other middleware messing with the binding. - MessageFilterMiddleware = new IMessageFilterMiddleware[0]; - MessageMiddleware = new IMessageMiddleware[0]; - } - - public Type Controller { get; } - public MethodInfo Method { get; } - public string QueueName { get; private set; } - public QueueBindingMode QueueBindingMode { get; set; } - public IReadOnlyList MessageFilterMiddleware { get; } - public IReadOnlyList MessageMiddleware { get; } - - public bool Accept(Type messageClass) - { - return inner.Accept(messageClass); - } - - public bool Accept(IMessageContext context, object message) - { - return inner.Accept(context, message); - } - - public Task Invoke(IMessageContext context, object message) - { - return inner.Invoke(context, message); - } - - public void SetQueueName(string queueName) - { - QueueName = queueName; - inner.SetQueueName(queueName); - } - - public Type MessageClass { get; } - public QueueInfo QueueInfo { get; } - } - - internal interface IBindingParameterAccess - { - ValueFactory GetBinding(); - } - - - - internal interface IBindingResultAccess - { - ResultHandler GetHandler(); - } - - - internal class BindingContext : IBindingContext - { - private List messageMiddleware; - private List messageFilterMiddleware; - - public Type MessageClass { get; set; } - - public MethodInfo Method { get; } - public IReadOnlyList Parameters { get; } - public IBindingResult Result { get; } - - public QueueBindingMode QueueBindingMode { get; set; } - - public IReadOnlyList MessageMiddleware => messageMiddleware; - public IReadOnlyList MessageFilterMiddleware => messageFilterMiddleware; - - - public BindingContext(MethodInfo method) - { - Method = method; - - Parameters = method.GetParameters().Select(p => new BindingParameter(p)).ToList(); - Result = new BindingResult(method.ReturnParameter); + return methodLookup.TryGetValue(method.Method, out var binding) ? binding : null; } - public void Use(IMessageMiddleware middleware) + public void Lock() { - if (messageMiddleware == null) - messageMiddleware = new List(); - - messageMiddleware.Add(middleware); - } - - - public void Use(IMessageFilterMiddleware filterMiddleware) - { - if (messageFilterMiddleware == null) - messageFilterMiddleware = new List(); - - messageFilterMiddleware.Add(filterMiddleware); - } - } - - - internal class BindingParameter : IBindingParameter, IBindingParameterAccess - { - private ValueFactory binding; - - public ParameterInfo Info { get; } - public bool HasBinding => binding != null; - - - public BindingParameter(ParameterInfo parameter) - { - Info = parameter; - - } - - public ValueFactory GetBinding() - { - return binding; - } - - public void SetBinding(ValueFactory valueFactory) - { - binding = valueFactory; - } - } - - - internal class BindingResult : IBindingResult, IBindingResultAccess - { - private ResultHandler handler; - - public ParameterInfo Info { get; } - public bool HasHandler => handler != null; - - - public BindingResult(ParameterInfo parameter) - { - Info = parameter; - } - - - public ResultHandler GetHandler() - { - return handler; - } - - public void SetHandler(ResultHandler resultHandler) - { - handler = resultHandler; + methodLookup = this + .Where(binding => binding is IControllerMethodBinding) + .Cast() + .ToDictionary(binding => binding.Method, binding => binding); } } } diff --git a/Tapeti/TapetiConfigControllers.cs b/Tapeti/TapetiConfigControllers.cs new file mode 100644 index 0000000..dcd8127 --- /dev/null +++ b/Tapeti/TapetiConfigControllers.cs @@ -0,0 +1,147 @@ +using System; +using System.Linq; +using System.Reflection; +using Tapeti.Annotations; +using Tapeti.Config; +using Tapeti.Default; + +// ReSharper disable UnusedMember.Global + +namespace Tapeti +{ + /// + /// + /// Thrown when an issue is detected in a controller configuration. + /// + public class TopologyConfigurationException : Exception + { + /// + public TopologyConfigurationException(string message) : base(message) { } + } + + + /// + /// Extension methods for registering message controllers. + /// + public static class TapetiConfigControllers + { + /// + /// Registers all public methods in the specified controller class as message handlers. + /// + /// + /// The controller class to register. The class and/or methods must be annotated with either the DurableQueue or DynamicQueue attribute. + public static ITapetiConfigBuilder RegisterController(this ITapetiConfigBuilder builder, Type controller) + { + var builderAccess = (ITapetiConfigBuilderAccess)builder; + + if (!controller.IsClass) + throw new ArgumentException($"Controller {controller.Name} must be a class"); + + var controllerQueueInfo = GetQueueInfo(controller); + (builderAccess.DependencyResolver as IDependencyContainer)?.RegisterController(controller); + + var controllerIsObsolete = controller.GetCustomAttribute() != null; + + + foreach (var method in controller.GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.MemberType == MemberTypes.Method && m.DeclaringType != typeof(object) && (m as MethodInfo)?.IsSpecialName == false) + .Select(m => (MethodInfo)m)) + { + var methodQueueInfo = GetQueueInfo(method) ?? controllerQueueInfo; + if (methodQueueInfo == null || !methodQueueInfo.IsValid) + throw new TopologyConfigurationException( + $"Method {method.Name} or controller {controller.Name} requires a queue attribute"); + + + var methodIsObsolete = controllerIsObsolete || method.GetCustomAttribute() != null; + + + var context = new ControllerBindingContext(method.GetParameters(), method.ReturnParameter) + { + Controller = controller, + Method = method + }; + + + var allowBinding = false; + builderAccess.ApplyBindingMiddleware(context, () => { allowBinding = true; }); + + if (!allowBinding) + continue; + + + if (context.MessageClass == null) + throw new TopologyConfigurationException($"Method {method.Name} in controller {controller.Name} does not resolve to a message class"); + + + var invalidBindings = context.Parameters.Where(p => !p.HasBinding).ToList(); + if (invalidBindings.Count > 0) + { + var parameterNames = string.Join(", ", invalidBindings.Select(p => p.Info.Name)); + throw new TopologyConfigurationException($"Method {method.Name} in controller {method.DeclaringType?.Name} has unknown parameters: {parameterNames}"); + } + + + builder.RegisterBinding(new ControllerMethodBinding(builderAccess.DependencyResolver, new ControllerMethodBinding.BindingInfo + { + ControllerType = controller, + Method = method, + QueueInfo = methodQueueInfo, + MessageClass = context.MessageClass, + BindingTargetMode = context.BindingTargetMode, + IsObsolete = methodIsObsolete, + ParameterFactories = context.GetParameterHandlers(), + ResultHandler = context.GetResultHandler(), + + FilterMiddleware = context.Middleware.Where(m => m is IControllerFilterMiddleware).Cast().ToList(), + MessageMiddleware = context.Middleware.Where(m => m is IControllerMessageMiddleware).Cast().ToList(), + CleanupMiddleware = context.Middleware.Where(m => m is IControllerCleanupMiddleware).Cast().ToList() + })); + + } + + return builder; + } + + + /// + /// Registers all controllers in the specified assembly which are marked with the MessageController attribute. + /// + /// + /// The assembly to scan for controllers. + public static ITapetiConfigBuilder RegisterAllControllers(this ITapetiConfigBuilder builder, Assembly assembly) + { + foreach (var type in assembly.GetTypes().Where(t => t.IsDefined(typeof(MessageControllerAttribute)))) + RegisterController(builder, type); + + return builder; + } + + + /// + /// Registers all controllers in the entry assembly which are marked with the MessageController attribute. + /// + /// + public static ITapetiConfigBuilder RegisterAllControllers(this ITapetiConfigBuilder builder) + { + return RegisterAllControllers(builder, Assembly.GetEntryAssembly()); + } + + + private static ControllerMethodBinding.QueueInfo GetQueueInfo(MemberInfo member) + { + var dynamicQueueAttribute = member.GetCustomAttribute(); + var durableQueueAttribute = member.GetCustomAttribute(); + + if (dynamicQueueAttribute != null && durableQueueAttribute != null) + throw new TopologyConfigurationException($"Cannot combine static and dynamic queue attributes on controller {member.DeclaringType?.Name} method {member.Name}"); + + if (dynamicQueueAttribute != null) + return new ControllerMethodBinding.QueueInfo { QueueType = QueueType.Dynamic, Name = dynamicQueueAttribute.Prefix }; + + return durableQueueAttribute != null + ? new ControllerMethodBinding.QueueInfo { QueueType = QueueType.Durable, Name = durableQueueAttribute.Name } + : null; + } + } +} diff --git a/Tapeti/TapetiConnection.cs b/Tapeti/TapetiConnection.cs index d66f880..ff72c3f 100644 --- a/Tapeti/TapetiConnection.cs +++ b/Tapeti/TapetiConnection.cs @@ -1,46 +1,68 @@ using System; -using System.Linq; using System.Threading.Tasks; using Tapeti.Config; using Tapeti.Connection; // ReSharper disable UnusedMember.Global +// TODO more separation from the actual worker / RabbitMQ Client for unit testing purposes + namespace Tapeti { - public delegate void DisconnectedEventHandler(object sender, DisconnectedEventArgs e); - - public class TapetiConnection : IDisposable + /// + /// + /// Creates a connection to RabbitMQ based on the provided Tapeti config. + /// + public class TapetiConnection : IConnection { - private readonly IConfig config; + private readonly ITapetiConfig config; + + /// + /// Specifies the hostname and credentials to use when connecting to RabbitMQ. + /// Defaults to guest on localhost. + /// + /// + /// This property must be set before first subscribing or publishing, otherwise it + /// will use the default connection parameters. + /// public TapetiConnectionParams Params { get; set; } - private readonly Lazy worker; + private readonly Lazy client; private TapetiSubscriber subscriber; - public TapetiConnection(IConfig config) + /// + /// Creates a new instance of a TapetiConnection and registers a default IPublisher + /// in the IoC container as provided in the config. + /// + /// + public TapetiConnection(ITapetiConfig config) { this.config = config; (config.DependencyResolver as IDependencyContainer)?.RegisterDefault(GetPublisher); - worker = new Lazy(() => new TapetiWorker(config) + client = new Lazy(() => new TapetiClient(config, Params ?? new TapetiConnectionParams()) { - ConnectionParams = Params ?? new TapetiConnectionParams(), ConnectionEventListener = new ConnectionEventListener(this) }); } - public event EventHandler Connected; + /// + public event ConnectedEventHandler Connected; + + /// public event DisconnectedEventHandler Disconnected; - public event EventHandler Reconnected; + + /// + public event ConnectedEventHandler Reconnected; + /// public async Task Subscribe(bool startConsuming = true) { if (subscriber == null) { - subscriber = new TapetiSubscriber(() => worker.Value, config.Queues.ToList()); - await subscriber.BindQueues(); + subscriber = new TapetiSubscriber(() => client.Value, config); + await subscriber.ApplyBindings(); } if (startConsuming) @@ -50,30 +72,37 @@ namespace Tapeti } + /// public ISubscriber SubscribeSync(bool startConsuming = true) { return Subscribe(startConsuming).Result; } + /// public IPublisher GetPublisher() { - return new TapetiPublisher(() => worker.Value); + return new TapetiPublisher(config, () => client.Value); } + /// public async Task Close() { - if (worker.IsValueCreated) - await worker.Value.Close(); + if (client.IsValueCreated) + await client.Value.Close(); } + /// public void Dispose() { Close().Wait(); + + subscriber?.Dispose(); } + private class ConnectionEventListener: IConnectionEventListener { private readonly TapetiConnection owner; @@ -83,9 +112,9 @@ namespace Tapeti this.owner = owner; } - public void Connected() + public void Connected(ConnectedEventArgs e) { - owner.OnConnected(new EventArgs()); + owner.OnConnected(e); } public void Disconnected(DisconnectedEventArgs e) @@ -93,31 +122,51 @@ namespace Tapeti owner.OnDisconnected(e); } - public void Reconnected() + public void Reconnected(ConnectedEventArgs e) { - owner.OnReconnected(new EventArgs()); + owner.OnReconnected(e); } } - protected virtual void OnConnected(EventArgs e) + + /// + /// Called when a connection to RabbitMQ has been established. + /// + protected virtual void OnConnected(ConnectedEventArgs e) { - Task.Run(() => Connected?.Invoke(this, e)); + var connectedEvent = Connected; + if (connectedEvent == null) + return; + + Task.Run(() => connectedEvent.Invoke(this, e)); } - protected virtual void OnReconnected(EventArgs e) + /// + /// Called when the connection to RabbitMQ has been lost. + /// + protected virtual void OnReconnected(ConnectedEventArgs e) { - Task.Run(() => - { - subscriber?.RebindQueues().ContinueWith((t) => - { - Reconnected?.Invoke(this, e); - }); - }); + var reconnectedEvent = Reconnected; + if (reconnectedEvent == null && subscriber == null) + return; + + subscriber?.Reconnect(); + + Task.Run(() => reconnectedEvent?.Invoke(this, e)); } + /// + /// Called when the connection to RabbitMQ has been recovered after an unexpected disconnect. + /// protected virtual void OnDisconnected(DisconnectedEventArgs e) { - Task.Run(() => Disconnected?.Invoke(this, e)); + var disconnectedEvent = Disconnected; + if (disconnectedEvent == null) + return; + + subscriber?.Disconnect(); + + Task.Run(() => disconnectedEvent.Invoke(this, e)); } } } diff --git a/Tapeti/TapetiConnectionParams.cs b/Tapeti/TapetiConnectionParams.cs index 2c6c525..ac739b1 100644 --- a/Tapeti/TapetiConnectionParams.cs +++ b/Tapeti/TapetiConnectionParams.cs @@ -1,15 +1,41 @@ using System; +using System.Collections.Generic; // ReSharper disable UnusedMember.Global namespace Tapeti { + /// + /// Defines the connection parameters. + /// public class TapetiConnectionParams { + private IDictionary clientProperties; + + + /// + /// The hostname to connect to. Defaults to localhost. + /// public string HostName { get; set; } = "localhost"; + + /// + /// The port to connect to. Defaults to 5672. + /// public int Port { get; set; } = 5672; + + /// + /// The virtual host in RabbitMQ. Defaults to /. + /// public string VirtualHost { get; set; } = "/"; + + /// + /// The username to authenticate with. Defaults to guest. + /// public string Username { get; set; } = "guest"; + + /// + /// The password to authenticate with. Defaults to guest. + /// public string Password { get; set; } = "guest"; /// @@ -19,11 +45,37 @@ namespace Tapeti /// public ushort PrefetchCount { get; set; } = 50; + /// + /// The port the management plugin binds to. Only used when DeclareDurableQueues is enabled. Defaults to 15672. + /// + public int ManagementPort { get; set; } = 15672; - public TapetiConnectionParams() - { + /// + /// Key-value pair of properties that are set on the connection. These will be visible in the RabbitMQ Management interface. + /// Note that you can either set a new dictionary entirely, to allow for inline declaration, or use this property directly + /// and use the auto-created dictionary. + /// + /// + /// If any of the default keys used by the RabbitMQ Client library (product, version) are specified their value + /// will be overwritten. See DefaultClientProperties in Connection.cs in the RabbitMQ .NET client source for the default values. + /// + public IDictionary ClientProperties { + get => clientProperties ?? (clientProperties = new Dictionary()); + set => clientProperties = value; } + + /// + public TapetiConnectionParams() + { + } + + /// + /// Construct a new TapetiConnectionParams instance based on standard URI syntax. + /// + /// new TapetiConnectionParams(new Uri("amqp://username:password@hostname/")) + /// new TapetiConnectionParams(new Uri("amqp://username:password@hostname:5672/virtualHost")) + /// public TapetiConnectionParams(Uri uri) { HostName = uri.Host; @@ -33,12 +85,12 @@ namespace Tapeti Port = uri.Port; var userInfo = uri.UserInfo.Split(':'); - if (userInfo.Length > 0) - { - Username = userInfo[0]; - if (userInfo.Length > 1) - Password = userInfo[1]; - } + if (userInfo.Length <= 0) + return; + + Username = userInfo[0]; + if (userInfo.Length > 1) + Password = userInfo[1]; } } } diff --git a/Tapeti/Tasks/SingleThreadTaskQueue.cs b/Tapeti/Tasks/SingleThreadTaskQueue.cs index f22f869..acee8c7 100644 --- a/Tapeti/Tasks/SingleThreadTaskQueue.cs +++ b/Tapeti/Tasks/SingleThreadTaskQueue.cs @@ -6,6 +6,10 @@ using System.Threading.Tasks; namespace Tapeti.Tasks { + /// + /// + /// An implementation of a queue which runs tasks on a single thread. + /// public class SingleThreadTaskQueue : IDisposable { private readonly object previousTaskLock = new object(); @@ -14,6 +18,10 @@ namespace Tapeti.Tasks private readonly Lazy singleThreadScheduler = new Lazy(); + /// + /// Add the specified synchronous action to the task queue. + /// + /// public Task Add(Action action) { lock (previousTaskLock) @@ -27,6 +35,10 @@ namespace Tapeti.Tasks } + /// + /// Add the specified asynchronous method to the task queue. + /// + /// public Task Add(Func func) { lock (previousTaskLock) @@ -45,89 +57,90 @@ namespace Tapeti.Tasks } + /// public void Dispose() { if (singleThreadScheduler.IsValueCreated) singleThreadScheduler.Value.Dispose(); } - } - public class SingleThreadTaskScheduler : TaskScheduler, IDisposable - { - public override int MaximumConcurrencyLevel => 1; - - - private readonly Queue scheduledTasks = new Queue(); - private bool disposed; - - - public SingleThreadTaskScheduler() + internal class SingleThreadTaskScheduler : TaskScheduler, IDisposable { - // ReSharper disable once ObjectCreationAsStatement - fire and forget! - new Thread(WorkerThread).Start(); - } + public override int MaximumConcurrencyLevel => 1; - public void Dispose() - { - lock (scheduledTasks) + private readonly Queue scheduledTasks = new Queue(); + private bool disposed; + + + public SingleThreadTaskScheduler() { - disposed = true; - Monitor.PulseAll(scheduledTasks); + // ReSharper disable once ObjectCreationAsStatement - fire and forget! + new Thread(WorkerThread).Start(); } - } - protected override void QueueTask(Task task) - { - if (disposed) return; - - lock (scheduledTasks) + public void Dispose() { - scheduledTasks.Enqueue(task); - Monitor.Pulse(scheduledTasks); - } - } - - protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) - { - return false; - } - - - protected override IEnumerable GetScheduledTasks() - { - lock (scheduledTasks) - { - return scheduledTasks.ToList(); - } - } - - - private void WorkerThread() - { - while(true) - { - Task task; lock (scheduledTasks) { - task = WaitAndDequeueTask(); + disposed = true; + Monitor.PulseAll(scheduledTasks); } - - if (task == null) - break; - - TryExecuteTask(task); } - } - private Task WaitAndDequeueTask() - { - while (!scheduledTasks.Any() && !disposed) - Monitor.Wait(scheduledTasks); - return disposed ? null : scheduledTasks.Dequeue(); + protected override void QueueTask(Task task) + { + if (disposed) return; + + lock (scheduledTasks) + { + scheduledTasks.Enqueue(task); + Monitor.Pulse(scheduledTasks); + } + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + return false; + } + + + protected override IEnumerable GetScheduledTasks() + { + lock (scheduledTasks) + { + return scheduledTasks.ToList(); + } + } + + + private void WorkerThread() + { + while (true) + { + Task task; + lock (scheduledTasks) + { + task = WaitAndDequeueTask(); + } + + if (task == null) + break; + + TryExecuteTask(task); + } + } + + private Task WaitAndDequeueTask() + { + while (!scheduledTasks.Any() && !disposed) + Monitor.Wait(scheduledTasks); + + return disposed ? null : scheduledTasks.Dequeue(); + } } } } diff --git a/Test/App.config b/Test/App.config deleted file mode 100644 index b55cd45..0000000 --- a/Test/App.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Test/FlowEndController.cs b/Test/FlowEndController.cs deleted file mode 100644 index 5c2f450..0000000 --- a/Test/FlowEndController.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using Tapeti.Annotations; -using Tapeti.Flow; -using Tapeti.Flow.Annotations; - -// ReSharper disable UnusedMember.Global - -namespace Test -{ - [MessageController] - [DynamicQueue] - public class FlowEndController - { - private readonly IFlowProvider flowProvider; - - public FlowEndController(IFlowProvider flowProvider) - { - this.flowProvider = flowProvider; - } - - public IYieldPoint StartFlow(PingMessage message) - { - Console.WriteLine("PingMessage received, calling flowProvider.End() directly"); - - if (DateTime.Now < new DateTime(2000, 1, 1)) - { - //never true - return flowProvider - .YieldWithRequestSync - (new PingConfirmationRequestMessage() { StoredInState = "Ping:" }, - HandlePingConfirmationResponse); - } - - return Finish(); - } - - - [Continuation] - public IYieldPoint HandlePingConfirmationResponse(PingConfirmationResponseMessage msg) - { - Console.WriteLine("Ending ping flow: " + msg.Answer); - return Finish(); - } - - - private IYieldPoint Finish() - { - return flowProvider.End(); - } - - - public class PingMessage - { - - } - - [Request(Response = typeof(PingConfirmationResponseMessage))] - public class PingConfirmationRequestMessage - { - public string StoredInState { get; set; } - } - - - public class PingConfirmationResponseMessage - { - public string Answer { get; set; } - } - - public PingConfirmationResponseMessage PingConfirmation(PingConfirmationRequestMessage message) - { - Console.WriteLine(">> receive Ping (returning pong)"); - - return new PingConfirmationResponseMessage - { - Answer = message.StoredInState + " Pong!" - }; - } - } -} diff --git a/Test/MarcoController.cs b/Test/MarcoController.cs deleted file mode 100644 index 3368f59..0000000 --- a/Test/MarcoController.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; -using Tapeti.Annotations; -using Tapeti.Flow; -using Tapeti.Flow.Annotations; - -// ReSharper disable UnusedMember.Global - -namespace Test -{ - [MessageController] - [DynamicQueue] - public class MarcoController - { - //private readonly IPublisher publisher; - private readonly IFlowProvider flowProvider; - //private readonly Visualizer visualizer; - - // Public properties are automatically stored and retrieved while in a flow - public Guid StateTestGuid { get; set; } - - public int Phase; - - public MarcoController(/*IPublisher publisher, */IFlowProvider flowProvider/*, Visualizer visualizer*/) - { - //this.publisher = publisher; - this.flowProvider = flowProvider; - //this.visualizer = visualizer; - } - - - [Start] - public async Task StartFlow(bool go) - { - Console.WriteLine("Phase = " + Phase + " Starting stand-alone flow"); - await Task.Delay(10); - - Phase = 1; - - if (go) - return flowProvider.YieldWithRequestSync - (new PoloConfirmationRequestMessage(), - HandlePoloConfirmationResponse); - - Console.WriteLine("Phase = " + Phase + " Ending stand-alone flow prematurely"); - return flowProvider.End(); - } - - - [Continuation] - public IYieldPoint HandlePoloConfirmationResponse(PoloConfirmationResponseMessage msg) - { - Console.WriteLine("Phase = " + Phase + " Handling the first response and sending the second..."); - - Phase = 2; - - return flowProvider.YieldWithRequestSync - (new PoloConfirmationRequestMessage(), - HandlePoloConfirmationResponseEnd); - } - - - [Continuation] - public IYieldPoint HandlePoloConfirmationResponseEnd(PoloConfirmationResponseMessage msg) - { - Console.WriteLine("Phase = " + Phase + " Handling the second response and Ending stand-alone flow"); - return flowProvider.End(); - } - - - [Start] - public IYieldPoint TestParallelRequest() - { - Console.WriteLine(">> Marco (yielding with request)"); - - StateTestGuid = Guid.NewGuid(); - Console.WriteLine($"Starting parallel request with StateTestGuid {StateTestGuid}"); - - return flowProvider.YieldWithParallelRequest() - .AddRequestSync(new PoloConfirmationRequestMessage - { - StoredInState = StateTestGuid, - EnumValue = TestEnum.Value1, - - }, HandlePoloConfirmationResponse1) - - .AddRequestSync(new PoloConfirmationRequestMessage - { - StoredInState = StateTestGuid, - EnumValue = TestEnum.Value2, - OptionalEnumValue = TestEnum.Value1 - }, HandlePoloConfirmationResponse2) - - .YieldSync(ContinuePoloConfirmation); - } - - - [Continuation] - public void HandlePoloConfirmationResponse1(PoloConfirmationResponseMessage message) - { - Console.WriteLine(">> HandlePoloConfirmationResponse1"); - Console.WriteLine(message.ShouldMatchState.Equals(StateTestGuid) ? "Confirmed!" : "Oops! Mismatch!"); - } - - - [Continuation] - public void HandlePoloConfirmationResponse2(PoloConfirmationResponseMessage message) - { - Console.WriteLine(">> HandlePoloConfirmationResponse2"); - Console.WriteLine(message.ShouldMatchState.Equals(StateTestGuid) ? "Confirmed!" : "Oops! Mismatch!"); - } - - - private IYieldPoint ContinuePoloConfirmation() - { - Console.WriteLine("> ConvergePoloConfirmation (ending flow)"); - return flowProvider.End(); - } - - - /** - * For simple request response patterns, the return type can be used. - * This will automatically include the correlationId in the response and - * use the replyTo header of the request if provided. - */ - public async Task PoloConfirmation(PoloConfirmationRequestMessage message) - { - Console.WriteLine(">> PoloConfirmation (returning confirmation)"); - await Task.Delay(100); - - return new PoloConfirmationResponseMessage - { - ShouldMatchState = message.StoredInState, - EnumValue = message.EnumValue, - OptionalEnumValue = message.OptionalEnumValue - }; - } - - - - [DynamicQueue("custom.prefix")] - public void Polo(PoloMessage message) - { - Console.WriteLine(">> Polo"); - } - } - - - public enum TestEnum - { - Value1, - Value2 - } - - - [Request(Response = typeof(PoloMessage))] - public class MarcoMessage - { - } - - - public class PoloMessage - { - } - - - [Request(Response = typeof(PoloConfirmationResponseMessage))] - public class PoloConfirmationRequestMessage - { - [Required] - public Guid StoredInState { get; set; } - - public TestEnum EnumValue; - public TestEnum? OptionalEnumValue; - } - - - public class PoloConfirmationResponseMessage - { - [Required] - public Guid ShouldMatchState { get; set; } - - public TestEnum EnumValue; - public TestEnum? OptionalEnumValue; - } -} diff --git a/Test/MarcoEmitter.cs b/Test/MarcoEmitter.cs deleted file mode 100644 index b40ae95..0000000 --- a/Test/MarcoEmitter.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Threading.Tasks; - -namespace Test -{ - public class MarcoEmitter - { - //private readonly IPublisher publisher; - - - /*public MarcoEmitter(IPublisher publisher) - { - this.publisher = publisher; - } - */ - - - public async Task Run() - { - //await publisher.Publish(new MarcoMessage()); - - /* - var concurrent = new SemaphoreSlim(20); - - while (true) - { - for (var x = 0; x < 200; x++) - { - await concurrent.WaitAsync(); - try - { - await publisher.Publish(new MarcoMessage()); - } - finally - { - concurrent.Release(); - } - } - - await Task.Delay(200); - } - */ - - while (true) - { - await Task.Delay(1000); - } - // ReSharper disable once FunctionNeverReturns - } - } -} diff --git a/Test/Program.cs b/Test/Program.cs deleted file mode 100644 index 3969269..0000000 --- a/Test/Program.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using SimpleInjector; -using Tapeti; -using Tapeti.DataAnnotations; -using Tapeti.Flow; -using Tapeti.SimpleInjector; -using System.Threading; -using Tapeti.Annotations; -using Tapeti.Transient; - -namespace Test -{ - internal class Program - { - private static void Main() - { - // TODO logging - //try - { - var container = new Container(); - container.Register(); - container.Register(); - container.Register(); - - var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) - //.WithFlowSqlRepository("Server=localhost;Database=TapetiTest;Integrated Security=true") - .WithFlow() - .WithDataAnnotations() - .WithTransient(TimeSpan.FromSeconds(30)) - .RegisterAllControllers() - //.DisablePublisherConfirms() -> you probably never want to do this if you're using Flow or want requeues when a publish fails - .Build(); - - using (var connection = new TapetiConnection(config) - { - Params = new TapetiAppSettingsConnectionParams() - }) - { - var flowStore = container.GetInstance(); - var flowStore2 = container.GetInstance(); - - Console.WriteLine("IFlowHandler is singleton = " + (flowStore == flowStore2)); - - connection.Connected += (sender, e) => { Console.WriteLine("Event Connected"); }; - connection.Disconnected += (sender, e) => { Console.WriteLine("Event Disconnected"); }; - connection.Reconnected += (sender, e) => { Console.WriteLine("Event Reconnected"); }; - - Console.WriteLine("Subscribing..."); - var subscriber = connection.Subscribe(false).Result; - - Console.WriteLine("Consuming..."); - subscriber.Resume().Wait(); - - Console.WriteLine("Done!"); - - var response = container.GetInstance() - .RequestResponse( - new PoloConfirmationRequestMessage - { - StoredInState = new Guid("309088d8-9906-4ef3-bc64-56976538d3ab") - }).Result; - - Console.WriteLine(response.ShouldMatchState); - - //connection.GetPublisher().Publish(new FlowEndController.PingMessage()); - - //container.GetInstance().Start(c => c.StartFlow, true).Wait(); - //container.GetInstance().Start(c => c.TestParallelRequest).Wait(); - - Thread.Sleep(1000); - - //var emitter = container.GetInstance(); - //emitter.Run().Wait(); - - - } - } - //catch (Exception e) - { - // Console.WriteLine(e.ToString()); - // Console.ReadKey(); - } - } - } -} diff --git a/Test/Test.csproj b/Test/Test.csproj deleted file mode 100644 index 00f9c1e..0000000 --- a/Test/Test.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - netcoreapp2.1 - - - - - - - - - - - - - diff --git a/Test/Visualizer.cs b/Test/Visualizer.cs deleted file mode 100644 index c99af85..0000000 --- a/Test/Visualizer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Threading.Tasks; - -// ReSharper disable UnusedMember.Global - -namespace Test -{ - public class Visualizer - { - public Task VisualizeMarco() - { - Console.WriteLine("Marco!"); - return Task.CompletedTask; - } - - public Task VisualizePolo() - { - Console.WriteLine("Polo!"); - return Task.CompletedTask; - } - } -} diff --git a/appveyor.yml b/appveyor.yml index 7cd2ceb..ad72b07 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,7 +6,8 @@ install: before_build: - nuget restore - - ps: gitversion /l console /output buildserver /updateAssemblyInfo + - ps: gitversion /l console /output buildserver + - ps: build\UpdateVersion.ps1 after_build: # Tapeti @@ -31,15 +32,24 @@ after_build: # Tapeti.Transient - cmd: nuget pack Tapeti.Transient\Tapeti.Transient.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" - cmd: appveyor PushArtifact "Tapeti.Transient.%GitVersion_NuGetVersion%.nupkg" - # Tapeti.SimpleInjector - - cmd: nuget pack Tapeti.SimpleInjector\Tapeti.SimpleInjector.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" - - cmd: appveyor PushArtifact "Tapeti.SimpleInjector.%GitVersion_NuGetVersion%.nupkg" # Tapeti.Serilog - cmd: nuget pack Tapeti.Serilog\Tapeti.Serilog.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" - cmd: appveyor PushArtifact "Tapeti.Serilog.%GitVersion_NuGetVersion%.nupkg" - -assembly_info: - patch: false + # Tapeti.SimpleInjector + - cmd: nuget pack Tapeti.SimpleInjector\Tapeti.SimpleInjector.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" + - cmd: appveyor PushArtifact "Tapeti.SimpleInjector.%GitVersion_NuGetVersion%.nupkg" + # Tapeti.Autofac + - cmd: nuget pack Tapeti.Autofac\Tapeti.Autofac.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" + - cmd: appveyor PushArtifact "Tapeti.Autofac.%GitVersion_NuGetVersion%.nupkg" + # Tapeti.CastleWindsor + - cmd: nuget pack Tapeti.CastleWindsor\Tapeti.CastleWindsor.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" + - cmd: appveyor PushArtifact "Tapeti.CastleWindsor.%GitVersion_NuGetVersion%.nupkg" + # Tapeti.Ninject + - cmd: nuget pack Tapeti.Ninject\Tapeti.Ninject.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" + - cmd: appveyor PushArtifact "Tapeti.Ninject.%GitVersion_NuGetVersion%.nupkg" + # Tapeti.UnityContainer + - cmd: nuget pack Tapeti.UnityContainer\Tapeti.UnityContainer.nuspec -version "%GitVersion_NuGetVersion%" -prop "target=%CONFIGURATION%" + - cmd: appveyor PushArtifact "Tapeti.UnityContainer.%GitVersion_NuGetVersion%.nupkg" build: project: Tapeti.sln diff --git a/build/UpdateVersion.ps1 b/build/UpdateVersion.ps1 new file mode 100644 index 0000000..3cc1f7e --- /dev/null +++ b/build/UpdateVersion.ps1 @@ -0,0 +1,38 @@ +# For debugging purposes +if (-not (Test-Path env:APPVEYOR_BUILD_FOLDER)) +{ + Write-Host "Warning: APPVEYOR_BUILD_FOLDER environment variable not set" + $env:APPVEYOR_BUILD_FOLDER = "P:\Tapeti" +} + +if (-not (Test-Path env:GitVersion_MajorMinorPatch)) +{ + Write-Host "Warning: GitVersion_MajorMinorPatch environment variable not set" + $env:GitVersion_MajorMinorPatch = "0.0.1" +} + +if (-not (Test-Path env:GitVersion_CommitsSinceVersionSource)) +{ + Write-Host "Warning: GitVersion_CommitsSinceVersionSource environment variable not set" + $env:GitVersion_CommitsSinceVersionSource = "42" +} + +$version = "$($env:GitVersion_MajorMinorPatch).$($env:GitVersion_CommitsSinceVersionSource)" + +Write-Host "Updating version to $($version) for projects in $($env:APPVEYOR_BUILD_FOLDER)" + +$projectFiles = Get-ChildItem $env:APPVEYOR_BUILD_FOLDER -Recurse *.csproj | Select -ExpandProperty FullName +foreach ($projectFile in $projectFiles) +{ + $contents = Get-Content -Path $projectFile + if ($contents -match "(.+?)") + { + $contents = $contents -replace "(.+?)", "$($version)" + Set-Content -Path $projectFile -Value $contents + Write-Host "Updated $($projectFile)" + } + else + { + Write-Host "No version information in $($projectFile)" + } +} \ No newline at end of file diff --git a/docs/dataannotations.rst b/docs/dataannotations.rst new file mode 100644 index 0000000..5c3ed18 --- /dev/null +++ b/docs/dataannotations.rst @@ -0,0 +1,51 @@ +Validating messages +=================== +To validate the contents of messages, Tapeti provides the Tapeti.DataAnnotations package. Once installed and enabled, it verifies each message that is published or consumed using the standard System.ComponentModel.DataAnnotations. + +To enable the validation extension, include it in your TapetiConfig: + +:: + + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .WithDataAnnotations() + .RegisterAllControllers() + .Build(); + + +Annotations +----------- +All `ValidationAttribute `_ derived annotations are supported. For example, use the Required attribute to indicate a field is required: + +:: + + public class RabbitEscapedMessage + { + [Required] + public string Name { get; set; } + + public string LastKnownHutch { get; set; } + } + +Or the Range attribute to indicate valid ranges: + +:: + + public class RabbitBirthdayMessage + { + [Range(1, 15, ErrorMessage = "Sorry, we have no birthday cards for ages below {1} or above {2}")] + public int Age { get; set; } + } + +Required GUIDs +-------------- +Using the standard validation attributes it is tricky to get a Guid to be required, as it is a struct which defaults to Guid.Empty. Using Nullable may work, but then your business logic will look like it is supposed to be optional. + +For this reason, the Tapeti.DataAnnotations.Extensions package can be installed from NuGet into your messaging package. It contains the RequiredGuid attribute which specifically checks for Guid.Empty. + +:: + + public class RabbitBornMessage + { + [RequiredGuid] + public Guid RabbitId { get; set; } + } \ No newline at end of file diff --git a/docs/flow.rst b/docs/flow.rst index f587913..d7705bd 100644 --- a/docs/flow.rst +++ b/docs/flow.rst @@ -8,44 +8,284 @@ This process is fully asynchronous, the service initiating the flow can be resta Request - response pattern -------------------------- -Tapeti implements the request - response pattern by allowing a message handler method to simply return the response message. Tapeti Flow expands on this idea by enforcing you to explicitly declare that pattern. This is intended to prevent bugs where a flow will forever wait on a response that never comes. +Tapeti implements the request - response pattern by allowing a message handler method to simply return the response message. Tapeti Flow extends on this concept by allowing the sender of the request to maintain it's state for when the response arrives. -Due to this requirement your messages need to follow a pattern where broadcasts (events) are separated from true request - response messages (where the reply is sent directly to the originator). +See :doc:`indepth` on defining request - response messages. -This may result in a message having two versions; one where a reply is expected and one where it's not. This is not considered a design flaw but a clear contract between services. An example is given in `Request - response versus transfer of responsibility`_. - -Declaring request - response messages -------------------------------------- -A message must be annotated with the Request attribute, where the Response property declares the expected response message class. +Enabling Tapeti Flow +-------------------- +To enable the use of Tapeti Flow, install the Tapeti.Flow NuGet package and call ``WithFlow()`` when setting up your TapetiConfig: :: - [Request(Response = RabbitCountResponseMessage)] - public class RabbitCountRequestMessage - { - public int? MinimumAge { get; set; } - } - - public class RabbitCountResponseMessage - { - public int Count { get; set; } - } - + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .WithFlow() + .RegisterAllControllers() + .Build(); Starting a flow --------------- +To start a new flow you need to obtain an IFlowStarter from your IoC container. It has one method in various overloads: ``Start``. + +Flow requires all methods participating in the flow, including the starting method, to be in the same controller. This allows the state to be stored and restored when the flow continues. The ``IFlowStarter.Start`` call does not need to be in the controller class. + +The controller type is passed as a generic parameter. The first parameter to the Start method is a method selector. This defines which method in the controller is called as soon as the flow is initialised. + +:: + + await flowStart.Start(c => c.StartFlow); + +The start method can have any name, but must be annotated with the ``[Start]`` attribute. This ensures it is not recognized as a message handler. The start method and any further continuation methods must return either Task (for asynchronous methods) or simply IYieldPoint (for synchronous methods). + +:: + + [MessageController] + [DynamicQueue] + public class QueryBunniesController + { + public DateTime RequestStart { get; set; } + + [Start] + IYieldPoint StartFlow() + { + RequestStart = DateTime.UtcNow(); + } + } + + + +Often you'll want to pass some initial information to the flow. The Start method allows one parameter. If you need more information, bundle it in a class or struct. + +:: + + await flowStart.Start(c => c.StartFlow, "pink"); + + [MessageController] + [DynamicQueue] + public class QueryBunniesController + { + public DateTime RequestStart { get; set; } + + [Start] + IYieldPoint StartFlow(string colorFilter) + { + RequestStart = DateTime.UtcNow(); + } + } + + +.. note:: Every time a flow is started or continued a new instance of the controller is created. All public fields in the controller are considered part of the state and will be restored when a response arrives, private and protected fields are not. Public fields must be serializable to JSON (using JSON.NET) to retain their value when a flow continues. Try to minimize the amount of state as it is cached in memory until the flow ends. + +Continuing a flow +----------------- +When starting a flow you're most likely want to start with a request message. Similarly, when continuing a flow you have the option to follow it up with another request and prolong the flow. This behaviour is controlled by the IYieldPoint that must be returned from the start and continuation handlers. To get an IYieldPoint you need to inject the IFlowProvider into your controller. + +IFlowProvider has a method ``YieldWithRequest`` which sends the provided request message and restores the controller when the response arrives, calling the response handler method you pass along to it. + +The response handler must be marked with the ``[Continuation]`` attribute. This ensures it is never called for broadcast messages, only when the response for our specific request arrives. It must also return an IYieldPoint or Task itself. + +If the response handler is not asynchronous, use ``YieldWithRequestSync`` instead, as used in the example below: + +:: + + [MessageController] + [DynamicQueue] + public class QueryBunniesController + { + private IFlowProvider flowProvider; + + public DateTime RequestStart { get; set; } + + + public QueryBunniesController(IFlowProvider flowProvider) + { + this.flowProvider = flowProvider; + } + + [Start] + IYieldPoint StartFlow(string colorFilter) + { + RequestStart = DateTime.UtcNow(); + + var request = new BunnyCountRequestMessage + { + ColorFilter = colorFilter + }; + + return flowProvider.YieldWithRequestSync + (request, HandleBunnyCountResponse); + } + + + [Continuation] + public IYieldPoint HandleBunnyCountResponse(BunnyCountResponseMessage message) + { + // Handle the response. The original RequestStart is available here as well. + } + } + +You can once again return a ``YieldWithRequest``, or end it. Ending a flow ------------- +To end the flow and dispose of any stored state, return an end yieldpoint: -Request - response versus transfer of responsibility ----------------------------------------------------- +:: -.. error:: You've stumbled upon a piece of unfinished documentation. - Behind you is all prior knowledge. In front of you is nothing but emptyness. What do you do? + [Continuation] + public IYieldPoint HandleBunnyCountResponse(BunnyCountResponseMessage message) + { + // Handle the response. - 1. Attempt to explore further - 2. Complain to the author and demand your money back - 3. Abandon all hope + return flowProvider.End(); + } - > | + +Flows started by a (request) message +------------------------------------ +Instead of manually starting a flow, you can also start one in response to an incoming message. You do not need access to the IFlowStarter in that case, simply return an IYieldPoint from a regular message handler: + +:: + + [MessageController] + [DurableQueue("hutch")] + public class HutchController + { + private IBunnyRepository repository; + private IFlowProvider flowProvider; + + public string ColorFilter { get; set; } + + + public HutchController(IBunnyRepository repository, IFlowProvider flowProvider) + { + this.repository = repository; + this.flowProvider = flowProvider; + } + + public IYieldPoint HandleCountRequest(BunnyCountRequestMessage message) + { + ColorFilter = message.ColorFilter; + + return flowProvider.YieldWithRequestSync + ( + new CheckAccessRequestMessage + { + Username = "hutch" + }, + HandleCheckAccessResponseMessage + ); + } + + + [Continuation] + public IYieldPoint HandleCheckAccessResponseMessage(CheckAccessResponseMessage message) + { + // We must provide a response to our original BunnyCountRequestMessage + return flowProvider.EndWithResponse(new BunnyCountResponseMessage + { + Count = message.HasAccess ? await repository.Count(ColorFilter) : 0 + }); + } + +.. note:: If the message that started the flow was a request message, you must end the flow with EndWithResponse or you will get an exception. Likewise, if the message was not a request message, you must end the flow with End. + + +Parallel requests +----------------- +When you want to send out more than one request, you could chain them in the response handler for each message. An easier way is to use ``YieldWithParallelRequest``. It returns a parallel request builder to which you can add one or more requests to be sent out, each with it's own response handler. In the end, the Yield method of the builder can be used to create a YieldPoint. It also specifies the converge method which is called when all responses have been handled. + +An example: + +:: + + public IYieldPoint HandleBirthdayMessage(RabbitBirthdayMessage message) + { + var sendCardRequest = new SendCardRequestMessage + { + RabbitID = message.RabbitID, + Age = message.Age, + Style = CardStyles.Funny + }; + + var doctorAppointmentMessage = new DoctorAppointmentRequestMessage + { + RabbitID = message.RabbitID, + Reason = "Yearly checkup" + }; + + return flowProvider.YieldWithParallelRequest() + .AddRequestSync( + sendCardRequest, HandleCardResponse) + + .AddRequestSync( + doctorAppointmentMessage, HandleDoctorAppointmentResponse) + + .YieldSync(ContinueAfterResponses); + } + + [Continuation] + public void HandleCardResponse(SendCardResponseMessage message) + { + // Handle card response. For example, store the result in a public field + } + + [Continuation] + public void HandleDoctorAppointmentResponse(DoctorAppointmentResponseMessage message) + { + // Handle appointment response. Note that the order of the responses is not guaranteed, + // but the handlers will never run at the same time, so it is safe to access + // and manipulate the public fields of the controller. + } + + private IYieldPoint ContinueAfterResponses() + { + // Perform further operations on the results stored in the public fields + + // This flow did not start with a request message, so end it normally + return flowProvider.End(); + } + + +A few things to note: + +#) The response handlers do not return an IYieldPoint themselves, but void (for AddRequestSync) or Task (for AddRequest). Therefore they can not influence the flow. Instead the converge method as passed to Yield or YieldSync determines how the flow continues. It is called immediately after the last response handler. +#) The converge method must be private, as it is not a valid message handler in itself. +#) You must add at least one request. + +Note that you do not have to perform all the operations in one go. You can store the result of ``YieldWithParallelRequest`` and conditionally call ``AddRequest`` or ``AddRequestSync`` as many times as required. + + +Persistent state +---------------- +By default flow state is only preserved while the service is running. To persist the flow state across restarts and reboots, provide an implementation of IFlowRepository to ``WithFlow()``. + +:: + + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .WithFlow(new MyFlowRepository()) + .RegisterAllControllers() + .Build(); + + +Tapeti.Flow includes an implementation for SQL server you can use as well. First, make sure your database contains a table to store flow state: + +:: + + create table Flow + ( + FlowID uniqueidentifier not null, + CreationTime datetime2(3) not null, + StateJson nvarchar(max) null, + constraint PK_Flow primary key clustered(FlowID) + ); + +Then install the Tapeti.Flow.SQL NuGet package and register the SqlConnectionFlowRepository by passing it to WithFlow, or by using the ``WithFlowSqlRepository`` extension method before calling ``WithFlow``: + +:: + + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .WithFlowSqlRepository("Server=localhost;Database=TapetiTest;Integrated Security=true") + .WithFlow() + .RegisterAllControllers() + .Build(); diff --git a/docs/gettingstarted.rst b/docs/gettingstarted.rst index 36047da..a4826c7 100644 --- a/docs/gettingstarted.rst +++ b/docs/gettingstarted.rst @@ -9,9 +9,18 @@ I'll assume you are familiar with installing NuGet.org packages into your projec Find and install the *Tapeti* package. This will also install *Tapeti.Annotations*, which contains the various attributes. -You will need an integration package as well for your IoC (Inversion of Control) container of choice. At the time of writing, one is provided for `SimpleInjector `_. Simply install *Tapeti.SimpleInjector* as well. +You will need an integration package as well for your IoC (Inversion of Control) container of choice. Various containers are supported by default: -.. note:: If you need support for your favourite library, implement *IDependencyContainer* using the *Tapeti.SimpleInjector* source as a reference and replace *SimpleInjectorDependencyResolver* with your class name in the example code below. +- `SimpleInjector `_ (Tapeti.SimpleInjector) +- `Autofac `_ (Tapeti.Autofac) +- `Castle Windsor `_ (Tapeti.CastleWindsor) +- `Ninject `_ (Tapeti.Ninject) +- `Unity `_ (Tapeti.UnityContainer) + + +SimpleInjector is used in all examples. The "01-PublishSubscribe" example included in the source shows how the other integration packages can be used. + +.. note:: If you need support for your favourite library, implement *IDependencyContainer* using the existing packages' source as a reference and replace *SimpleInjectorDependencyResolver* with your class name in the example code. Configuring Tapeti ------------------ @@ -46,7 +55,7 @@ First create an instance of TapetiConfig, tell it which controllers to register } } -.. note:: RegisterAllControllers without parameters searches the entry assembly. Pass an Assembly parameter to register other or additional controllers. +.. note:: RegisterAllControllers without parameters searches the entry assembly. Pass an Assembly parameter to register other or additional controllers. You can call RegisterAllControllers multiple times with different assemblies. .. caution:: Tapeti attempts to register it's default implementations in the IoC container during configuration, as well as when starting the connection (to register IPublisher). If your container is immutable after the initial configuration, like SimpleInjector is, make sure that you run the Tapeti configuration before requesting any instances from the container. @@ -73,19 +82,21 @@ The queue attribute can be either *DynamicQueue* or *DurableQueue*. The attribut DynamicQueue will create a queue with a name generated by RabbitMQ which is automatically deleted when your service stops. Bindings will be added for the messages handled by the controller. You will typically use dynamic queues for scenarios where handling the message is only relevant while the service is running (for example, updating a service's cache or performing live queries). -DurableQueue requires a queue name as the parameter. For now it is assumed that durable queues are already declared and bound, though Tapeti will include a way to create these kind of queues automatically as well in the near future. +DurableQueue requires a queue name as the parameter. By default, the queue is assumed to be present already and Tapeti will throw an exception if it does not. If you want Tapeti to create and update the durable queues as well, see :ref:`declaredurablequeues` in :doc:`indepth`. :: [MessageController] - [DynamicQueue] + [DynamicQueue("monitoring")] public class MonitoringController { } +.. note:: Notice the parameter to DynamicQueue. This defines the prefix. If specified, the queue name will begin with the supplied value, followed by a unique identifier, so it can be more easily recognized in the RabbitMQ management interface. -Responding to messages ----------------------- + +Handling incoming messages +-------------------------- Any public method in a message controller is considered a message handler. There are a few requirements which are enforced by Tapeti. Below are the default requirements, although some extension packages (like the :doc:`flow`) add their own or alter these requirements. - The first parameter must be the message class. @@ -108,8 +119,9 @@ A new controller is instantiated for each message, so it is safe to use public o } } +.. note:: If you're doing anything asynchronous in the message handler, make it async as well! Simply change the return type to "Task" or "async Task". -If the method returns a message object, that object is published as if it was a reply to the incoming message, maintaining the correlationId and respecting the replyTo header. +If the method returns a message object, that object is published as if it was a reply to the incoming message, maintaining the correlationId and respecting the replyTo header. See :doc:`indepth` for request-response requirements. Publishing messages @@ -145,4 +157,73 @@ To send a message, get a reference to IPublisher using dependency injection and $"It was last seen in {message.LastKnownHutch}." }); } - } \ No newline at end of file + } + + +Connection parameters +--------------------- +If you don't want to use the default connection parameters, which is probably a good idea in a production environment, you can manually specify the properties for TapetiConnectionParams or get them from your configuration of choice. Tapeti provides with two helpers. + +App.config / Web.config +^^^^^^^^^^^^^^^^^^^^^^^ +The TapetiAppSettingsConnectionParams class can be used to load the connection parameters from the AppSettings: + +:: + + using (var connection = new TapetiConnection(config) + { + Params = new TapetiAppSettingsConnectionParams() + }) + +The constructor accepts a prefix parameter, which defaults to "rabbitmq:". You can then specify the values in the appSettings block of your App.config or Web.config. Any omitted parameters will use the default value. + +.. code-block:: xml + + + + + + + + + + + + + + + + +The last entry is special: any setting which starts with "clientproperty:", after the configured prefix, will be added to the ClientProperties set. These properties are visible in the RabbitMQ Management interface and can be used to identify the connection. + +ConnectionString +^^^^^^^^^^^^^^^^ +Tapeti also includes a helper which can parse a connection string style value which is mainly for compatibility with `EasyNetQ `_. It made porting our applications slightly easier. EasyNetQ is a very capable library which includes high- and low-level wrappers for the RabbitMQ Client as well as a Management API client, and is worth checking out if you have a use case that is not suited to Tapeti. + +To parse a connection string, use the ConnectionStringParser.Parse method. You can of course still load the value from the AppSettings easily: + +:: + + using (var connection = new TapetiConnection(config) + { + Params = Tapeti.Helpers.ConnectionStringParser.Parse( + ConfigurationManager.AppSettings["RabbitMQ.ConnectionString"]) + }) + +An example connection string: + +:: + + host=localhost;username=guest;password=prefetchcount=5 + +Supported keys are: + +- hostname +- port +- virtualhost +- username +- password +- prefetchcount +- managementport + +Any keys in the connection string which are not supported will be silently ignored. \ No newline at end of file diff --git a/docs/indepth.rst b/docs/indepth.rst index cbca2d6..c4e4fe6 100644 --- a/docs/indepth.rst +++ b/docs/indepth.rst @@ -9,6 +9,150 @@ As described in the Getting started guide, a message is a plain object which can When communicating between services it is considered best practice to define messages in separate class library assemblies which can be referenced in other services. This establishes a public interface between services and components without binding to the implementation. +Enums +----- +Special care must be taken when using enums in messages. For example, you have several services consuming a message containing an enum field. Some services will have logic which depends on a specific value, others will not use that specific field at all. + +Then later on, you want to add a new value to the enum. Some services will have to be updated, but the two examples mentioned above do not rely on the new value. As your application grows, it will become unmanageable to keep all services up-to-date. + +Tapeti accounts for this scenario by using a custom deserializer for enums. Enums are always serialized to their string representation, and you should never rely on their ordinal values. When deserializing, if the sending service sends out an enum value that is unknown to the consuming service, Tapeti will deserialize it to an out-of-bounds enum value instead of failing immediately. + +This effectively means that as long as your code has conditional or switch statements that can handle unknown values, or only perform direct comparisons, the existing logic will run without issues. + +In addition, Tapeti does not allow you to pass the value as-is to another message, as the original string representation is lost. If it detects the out-of-bounds value in an enum field of a message being published, it will raise an exception. + +However, Tapeti cannot analyze your code and the ways you use the enum field. So if you ignored all advice and used the ordinal value of the enum to store somewhere directly, you are left with the value 0xDEADBEEF, and you will now know why. + + +.. _declaredurablequeues: + +Durable queues +-------------- +Before version 2.0, and still by default in the newer versions, Tapeti assumes all durable queues are set up with the proper bindings before starting the service. + +However, since this is very inconvenient you can enable Tapeti to manage durable queues as well. There are two things to keep in mind when enabling this functionality: + +#) The `RabbitMQ management plugin `_ must be enabled +#) The queue name must be unique to the service to prevent conflicting updates to the bindings + +To enable the automatic creation of durable queues, call EnableDeclareDurableQueues or SetDeclareDurableQueues on the TapetiConfig: + +:: + + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .EnableDeclareDurableQueues() + .RegisterAllControllers() + .Build(); + + +The queue will be bound to all message classes for which you have defined a message handler. If the queue already existed and contains bindings which are no longer valid, those bindings will be removed. Note however that if there are still messages of that type in the queue they will be consumed and cause an exception. To keep the queue backwards compatible, see the next section on migrating durable queues. + + +Migrating durable queues +------------------------ +.. note:: This section assumes you are using EnableDeclareDurableQueues. + +As your service evolves so can your message handlers. Perhaps a message no longer needs to handled, or you want to split them into another queue. + +If you remove a message handler the binding will also be removed from the queue, but there may still be messages of that type in the queue. Since these have nowhere to go, they will cause an error and be lost. + +Instead of removing the message handler you can mark it with the standard .NET ``[Obsolete]`` attribute: + +:: + + [MessageController] + [DurableQueue("monitoring")] + public class ObsoleteMonitoringController + { + [Obsolete] + public void HandleEscapeMessage(RabbitEscapedMessage message) + { + // Handle the message like before, perform the necessary migration, + // or simply ignore it if you no longer need it. + } + } + +Messages will still be consumed from the queue as long as it exists, but the routing key binding will removed so no new messages of that type will be delivered. + +The ``[Obsolete]`` attribute can also be applied to the entire controller to mark all message handlers it contains as obsolete. + + +If all message handlers bound to a durable queue are marked as obsolete, including other controllers bound to the same durable queue, the queue is a candidate for removal. During startup, if the queue is empty it will be deleted. This action is logged to the registered ILogger. + +If there are still messages in the queue it's pending removal will be logged but the consumers will run as normal to empty the queue. The queue will then remain until it is checked again when the application is restarted. + + +Request - response +------------------ +Messages can be annotated with the Request attribute to indicate that they require a response. For example: + +:: + + [Request(Response = typeof(BunnyCountResponseMessage))] + public class BunnyCountRequestMessage + { + public string ColorFilter { get; set; } + } + + public class BunnyCountResponseMessage + { + public int Count { get; set; } + } + +Message handlers processing the BunnyCountRequestMessage *must* respond with a BunnyCountResponseMessage, either directly or at the end of a Flow when using the :doc:`flow`. + +:: + + [MessageController] + [DurableQueue("hutch")] + public class HutchController + { + private IBunnyRepository repository; + + public HutchController(IBunnyRepository repository) + { + this.repository = repository; + } + + public async Task HandleCountRequest(BunnyCountRequestMessage message) + { + return new BunnyCountResponseMessage + { + Count = await repository.Count(message.ColorFilter) + }; + } + } + +Tapeti will throw an exception if a request message is published but there is no route for it. Tapeti will also throw an exception if you do not return the correct response class. This ensures consistent flow across services. + +If you simply want to broadcast an event in response to a message, do not use the return value but instead call IPublisher.Publish in the message handler. + + +In practise your service may end up with the same message having two versions; one where a reply is expected and one where it's not. This is not considered a design flaw but a clear contract between services. It is common and recommended for the request message to inherit from the base non-request version, and implement two message handlers that internally perform the same logic. + +While designing Tapeti this difference has been defined as `Transfer of responsibility`_ which is explained below. + + +Transfer of responsibility +-------------------------- +When working with microservices there will be dependencies between services. + +Sometimes the dependency should be on the consumer side, which is the classic publish-subscribe pattern. For example, a reporting service will often listen in on status updates from various other services to compose a combined report. The services producing the events simply broadcast the message without concerning who if anyone is listening. + +Sometimes you need another service to handle or query data outside of your responsibility, and the Request - Response mechanism can be used. Tapeti ensures these messages are routed as described above. + +The third pattern is what we refer to as "Transfer of responsibility". You need another service to continue your work, but a response is not required. For example, you have a REST API which receives and validates a request, then sends it to a queue to be handled by a background service. + +Messages like these must not be lost, there should always be a queue bound to it to handle the message. Tapeti supports the [Mandatory] attribute for these cases and will throw an exception if there is no queue bound to receive the message: + +:: + + [Mandatory] + public class SomeoneHandleMeMessage + { + } + + Routing keys ------------ The routing key is determined by converting CamelCase to dot-separated lowercase, leaving out "Message" at the end if it is present. In the example below, the routing key will be "something.happened": @@ -20,9 +164,10 @@ The routing key is determined by converting CamelCase to dot-separated lowercase public string Description { get; set; } } + This behaviour is implemented using the IRoutingKeyStrategy interface. For more information about changing this, see `Overriding default behaviour`_ - +.. note:: As you can see the namespace in which the message class is declared is not used in the routing key. This means you should not use the same class name twice as it may result in conflicts. The exchange strategy described below helps in differentiating between the messages, but to avoid any confusion it is still best practice to use unambiguous message class names or use another routing key strategy. Exchanges --------- diff --git a/docs/index.rst b/docs/index.rst index 219c524..0bc369d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,9 +3,11 @@ Tapeti documentation .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Table of contents introduction gettingstarted indepth - flow \ No newline at end of file + dataannotations + flow + transient \ No newline at end of file diff --git a/docs/introduction.rst b/docs/introduction.rst index 7da1f93..4a6eae1 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -2,31 +2,28 @@ Introduction ============ | 'Small to medium-sized and classified as "Least Concern" by the IUCN.' - | `Wikipedia `_ + | `Wikipedia `_ [*]_ + + +.. [*] Before it was marked as "Endangered" in 2019 and the pun no longer works. Tapeti is a wrapper for the RabbitMQ .NET Client designed for long-running microservices. It's main goal is to minimize the amount of messaging code required, and instead focus on the higher-level flow. -Tapeti requires at least .NET 4.6.1. +Tapeti is built using .NET Standard 2.0 and mostly tested using .NET 4.7. Key features ------------ * Consumers are declared using MVC-style controllers and are registered automatically based on annotations * Publishing requires only the message class, no transport details such as exchange and routing key +* :doc:`flow` (stateful request - response handling with support for parallel requests) * No inheritance required * Graceful recovery in case of connection issues, and in contrast to most libraries not designed for services, during startup as well -* Extensible using middleware, see for example the Tapeti Flow package +* Extensible using middleware What it is not -------------- Tapeti is not a general purpose RabbitMQ client. Although some behaviour can be overridden by implementing various interfaces, it enforces it's style of messaging and assumes everyone on the bus speaks the same language. - -What is missing ---------------- -Durable queues are not created and bound automatically yet. The assumption is made that these queues are initialized during a deploy to ensure messages are persisted even when the consuming service isn't running yet. - -The author shamelessly plugs `RabbitMetaQueue `_, which will probably be integrated into Tapeti at one point. - -Furthermore there are hardly any unit tests yet. This will require a bit more decoupling in the lower levels of the Tapeti code. +There is no support for TLS connections, nor are there any plans to support it. The author is of the opinion the message bus should be considered an internal, highly available, service and recommends self-hosted REST API's behind an SSL proxy for communicating over public interfaces. \ No newline at end of file diff --git a/docs/rundev.bat b/docs/rundev.bat new file mode 100644 index 0000000..1410909 --- /dev/null +++ b/docs/rundev.bat @@ -0,0 +1,3 @@ +@Echo Off +start "" "http://localhost:8000/" +sphinx-autobuild . .\_build\html -N \ No newline at end of file diff --git a/docs/transient.rst b/docs/transient.rst new file mode 100644 index 0000000..c17652e --- /dev/null +++ b/docs/transient.rst @@ -0,0 +1,51 @@ +Transient requests +================== +The Tapeti.Transient extension provides an RPC mechanism for request - responses. + +While the author does not recommend this pattern for services, opting instead for a self-hosted REST API and a discovery mechanism like `Consul `_ if an immediate response is required and a queue is not (like when providing an API for a frontend application), it can still be useful in certain situations to use the Tapeti request - response mechanism and be able to wait for the response. + +After enabling Tapeti.Transient you can use ``ITransientPublisher`` to send a request and await it's response, without needing a message controller. + +Enabling transient requests +--------------------------- +To enable the transient extension, include it in your TapetiConfig. You must provide a timeout after which the call to RequestResponse will throw an exception if no response has been received (for example, when the service handling the requests is not running). + +Optionally you can also provide a prefix for the dynamic queue registered to receive responses, so you can distinguish the queue for your application in the RabbitMQ management interface. If not provided it defaults to "transient". + + +:: + + var config = new TapetiConfig(new SimpleInjectorDependencyResolver(container)) + .WithTransient(TimeSpan.FromSeconds(30), "myapplication.transient") + .RegisterAllControllers() + .Build(); + + +Using transient requests +------------------------ +To send a transient request, inject ITransientPublisher into the class where you want to use it and call RequestResponse: + +:: + + public class BirthdayHandler + { + private ITransientPublisher transientPublisher; + + public BirthdayHandler(ITransientPublisher transientPublisher) + { + this.transientPublisher = transientPublisher; + } + + public async Task SendBirthdayCard(RabbitInfo rabbit) + { + var response = await transientPublisher.RequestResponse( + new SendCardRequestMessage + { + RabbitID = rabbit.RabbitID, + Age = rabbit.Age, + Style = CardStyles.Funny + }); + + // Handle the response + } + }