From f8fca5879cb7149b430a84299beaf4f9a8be8d66 Mon Sep 17 00:00:00 2001 From: Mark van Renswoude Date: Tue, 13 Aug 2019 20:30:04 +0200 Subject: [PATCH] [ci skip] Major refactoring for 2.0 - Compiles, but that's about it. Plenty of ToDo's left before it will run. Beware, ye who enter here. - Cleanup of the internals, with the aim to keep the interface to application code compatible - Added the ability to declare durable queues on startup and update the bindings - Possibly fixed an issue with publish timeouts being logged after a reconnect --- Tapeti.Annotations/Tapeti.Annotations.csproj | 4 + .../Tapeti.DataAnnotations.Extensions.csproj | 4 + .../Tapeti.DataAnnotations.csproj | 4 + Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj | 4 + Tapeti.Flow/Default/FlowBindingMiddleware.cs | 23 +- Tapeti.Flow/Default/FlowCleanupMiddleware.cs | 25 - Tapeti.Flow/Default/FlowContext.cs | 2 +- .../Default/FlowMessageFilterMiddleware.cs | 62 -- Tapeti.Flow/Default/FlowMessageMiddleware.cs | 54 -- Tapeti.Flow/Default/FlowMiddleware.cs | 128 +++ Tapeti.Flow/Default/FlowProvider.cs | 43 +- Tapeti.Flow/Default/FlowStarter.cs | 10 +- Tapeti.Flow/FlowMiddleware.cs | 1 - Tapeti.Flow/IFlowProvider.cs | 2 +- Tapeti.Flow/Tapeti.Flow.csproj | 4 + Tapeti.Serilog/Tapeti.Serilog.csproj | 4 + .../Tapeti.SimpleInjector.csproj | 4 + .../TypeNameRoutingKeyStrategyTests.cs | 2 +- .../{ => Helpers}/ConnectionStringParser.cs | 3 +- Tapeti.Tests/Tapeti.Tests.csproj | 8 + Tapeti.Tests/TransientFilterMiddleware.cs | 14 - Tapeti.Transient/ConfigExtentions.cs | 15 +- Tapeti.Transient/ITransientPublisher.cs | 9 + Tapeti.Transient/Tapeti.Transient.csproj | 4 + ...entMiddleware.cs => TransientExtension.cs} | 21 +- Tapeti.Transient/TransientGenericBinding.cs | 47 +- Tapeti.Transient/TransientPublisher.cs | 13 +- Tapeti.Transient/TransientRouter.cs | 31 +- Tapeti.png | Bin 0 -> 94581 bytes Tapeti.sln.DotSettings | 7 +- Tapeti/Config/IBinding.cs | 89 ++ Tapeti/Config/IBindingContext.cs | 57 -- Tapeti/Config/IBindingMiddleware.cs | 9 - Tapeti/Config/ICleanupMiddleware.cs | 9 - Tapeti/Config/IConfig.cs | 59 -- Tapeti/Config/IControllerBindingContext.cs | 137 ++++ Tapeti/Config/IControllerBindingMiddleware.cs | 19 + Tapeti/Config/IControllerCleanupMiddleware.cs | 20 + Tapeti/Config/IControllerFilterMiddleware.cs | 20 + Tapeti/Config/IControllerMessageContext.cs | 37 + Tapeti/Config/IControllerMessageMiddleware.cs | 19 + Tapeti/Config/IControllerMethodBinding.cs | 22 + Tapeti/Config/IControllerMiddlewareBase.cs | 9 + Tapeti/Config/ICustomBinding.cs | 31 - Tapeti/Config/IMessageContext.cs | 45 +- Tapeti/Config/IMessageFilterMiddleware.cs | 10 - Tapeti/Config/IMessageMiddleware.cs | 8 + Tapeti/Config/IMessageProperties.cs | 48 ++ Tapeti/Config/IPublishContext.cs | 27 +- Tapeti/Config/ITapetiConfig.cs | 112 +++ Tapeti/Config/ITapetiConfigBuilder.cs | 116 +++ Tapeti/Config/ITapetiExtension.cs | 13 + Tapeti/Config/ITapetiExtensionBinding.cs | 18 + Tapeti/Config/ITapetiExtentionBinding.cs | 10 - Tapeti/Connection/IConnectionEventListener.cs | 23 +- Tapeti/Connection/ITapetiClient.cs | 113 +++ Tapeti/Connection/TapetiBasicConsumer.cs | 43 + .../{TapetiWorker.cs => TapetiClient.cs} | 454 +++++++---- Tapeti/Connection/TapetiConsumer.cs | 357 +++----- Tapeti/Connection/TapetiPublisher.cs | 83 +- Tapeti/Connection/TapetiSubscriber.cs | 266 +++++- Tapeti/ConsumeResponse.cs | 14 + Tapeti/Default/ControllerMessageContext.cs | 50 ++ Tapeti/Default/ControllerMethodBinding.cs | 77 ++ Tapeti/Default/DependencyResolverBinding.cs | 12 +- Tapeti/Default/FallbackStringEnumConverter.cs | 8 +- Tapeti/Default/JsonMessageSerializer.cs | 46 +- Tapeti/Default/MessageBinding.cs | 12 +- Tapeti/Default/MessageContext.cs | 182 +---- Tapeti/Default/MessageProperties.cs | 77 ++ Tapeti/Default/PublishResultBinding.cs | 29 +- Tapeti/Default/RabbitMQMessageProperties.cs | 119 +++ Tapeti/Helpers/ConnectionstringParser.cs | 12 +- Tapeti/IConnection.cs | 70 +- Tapeti/IConsumer.cs | 21 + Tapeti/IDependencyResolver.cs | 7 + Tapeti/IMessageSerializer.cs | 22 +- Tapeti/IPublisher.cs | 41 +- Tapeti/ISubscriber.cs | 6 + Tapeti/Tapeti.csproj | 4 + Tapeti/TapetiConfig.cs | 763 +++++------------- Tapeti/TapetiConfigControllers.cs | 127 +++ Tapeti/TapetiConnection.cs | 85 +- Tapeti/TapetiConnectionParams.cs | 29 + Tapeti/Tasks/SingleThreadTaskQueue.cs | 137 ++-- 85 files changed, 3069 insertions(+), 1716 deletions(-) delete mode 100644 Tapeti.Flow/Default/FlowCleanupMiddleware.cs delete mode 100644 Tapeti.Flow/Default/FlowMessageFilterMiddleware.cs delete mode 100644 Tapeti.Flow/Default/FlowMessageMiddleware.cs create mode 100644 Tapeti.Flow/Default/FlowMiddleware.cs rename Tapeti.Tests/{ => Default}/TypeNameRoutingKeyStrategyTests.cs (98%) rename Tapeti.Tests/{ => Helpers}/ConnectionStringParser.cs (98%) delete mode 100644 Tapeti.Tests/TransientFilterMiddleware.cs rename Tapeti.Transient/{TransientMiddleware.cs => TransientExtension.cs} (56%) create mode 100644 Tapeti.png create mode 100644 Tapeti/Config/IBinding.cs delete mode 100644 Tapeti/Config/IBindingContext.cs delete mode 100644 Tapeti/Config/IBindingMiddleware.cs delete mode 100644 Tapeti/Config/ICleanupMiddleware.cs delete mode 100644 Tapeti/Config/IConfig.cs create mode 100644 Tapeti/Config/IControllerBindingContext.cs create mode 100644 Tapeti/Config/IControllerBindingMiddleware.cs create mode 100644 Tapeti/Config/IControllerCleanupMiddleware.cs create mode 100644 Tapeti/Config/IControllerFilterMiddleware.cs create mode 100644 Tapeti/Config/IControllerMessageContext.cs create mode 100644 Tapeti/Config/IControllerMessageMiddleware.cs create mode 100644 Tapeti/Config/IControllerMethodBinding.cs create mode 100644 Tapeti/Config/IControllerMiddlewareBase.cs delete mode 100644 Tapeti/Config/ICustomBinding.cs delete mode 100644 Tapeti/Config/IMessageFilterMiddleware.cs create mode 100644 Tapeti/Config/IMessageProperties.cs create mode 100644 Tapeti/Config/ITapetiConfig.cs create mode 100644 Tapeti/Config/ITapetiConfigBuilder.cs create mode 100644 Tapeti/Config/ITapetiExtensionBinding.cs delete mode 100644 Tapeti/Config/ITapetiExtentionBinding.cs create mode 100644 Tapeti/Connection/ITapetiClient.cs create mode 100644 Tapeti/Connection/TapetiBasicConsumer.cs rename Tapeti/Connection/{TapetiWorker.cs => TapetiClient.cs} (50%) create mode 100644 Tapeti/Default/ControllerMessageContext.cs create mode 100644 Tapeti/Default/ControllerMethodBinding.cs create mode 100644 Tapeti/Default/MessageProperties.cs create mode 100644 Tapeti/Default/RabbitMQMessageProperties.cs create mode 100644 Tapeti/IConsumer.cs create mode 100644 Tapeti/TapetiConfigControllers.cs diff --git a/Tapeti.Annotations/Tapeti.Annotations.csproj b/Tapeti.Annotations/Tapeti.Annotations.csproj index be5c9ef..f584c07 100644 --- a/Tapeti.Annotations/Tapeti.Annotations.csproj +++ b/Tapeti.Annotations/Tapeti.Annotations.csproj @@ -5,4 +5,8 @@ true + + 1701;1702;1591 + + diff --git a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj index 56cdff2..6ad9eab 100644 --- a/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj +++ b/Tapeti.DataAnnotations.Extensions/Tapeti.DataAnnotations.Extensions.csproj @@ -4,6 +4,10 @@ netstandard2.0 + + 1701;1702;1591 + + diff --git a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj index 52e0d73..2237cd1 100644 --- a/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj +++ b/Tapeti.DataAnnotations/Tapeti.DataAnnotations.csproj @@ -5,6 +5,10 @@ true + + 1701;1702;1591 + + diff --git a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj index eaa2e91..51ce58a 100644 --- a/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj +++ b/Tapeti.Flow.SQL/Tapeti.Flow.SQL.csproj @@ -5,6 +5,10 @@ true + + 1701;1702;1591 + + diff --git a/Tapeti.Flow/Default/FlowBindingMiddleware.cs b/Tapeti.Flow/Default/FlowBindingMiddleware.cs index fd796c4..7a65642 100644 --- a/Tapeti.Flow/Default/FlowBindingMiddleware.cs +++ b/Tapeti.Flow/Default/FlowBindingMiddleware.cs @@ -8,15 +8,15 @@ 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; + context.SetBindingTargetMode(BindingTargetMode.Direct); RegisterYieldPointResult(context); RegisterContinuationFilter(context); @@ -27,14 +27,13 @@ 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.Use(new FlowMiddleware()); if (context.Result.HasHandler) return; @@ -58,7 +57,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,16 +76,16 @@ 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(); + var flowHandler = context.Config.DependencyResolver.Resolve(); return flowHandler.Execute(context, yieldPoint); } - private static Task HandleParallelResponse(IMessageContext context) + private static Task HandleParallelResponse(IControllerMessageContext context) { - var flowHandler = context.DependencyResolver.Resolve(); + var flowHandler = context.Config.DependencyResolver.Resolve(); return flowHandler.Execute(context, new DelegateYieldPoint(async flowContext => { await flowContext.Store(); @@ -94,7 +93,7 @@ namespace Tapeti.Flow.Default } - 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..0746a31 100644 --- a/Tapeti.Flow/Default/FlowContext.cs +++ b/Tapeti.Flow/Default/FlowContext.cs @@ -6,7 +6,7 @@ namespace Tapeti.Flow.Default { internal class FlowContext : IDisposable { - public IMessageContext MessageContext { get; set; } + public IControllerMessageContext MessageContext { get; set; } public IFlowStateLock FlowStateLock { get; set; } public FlowState FlowState { 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/FlowMiddleware.cs b/Tapeti.Flow/Default/FlowMiddleware.cs new file mode 100644 index 0000000..c90332a --- /dev/null +++ b/Tapeti.Flow/Default/FlowMiddleware.cs @@ -0,0 +1,128 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Tapeti.Config; +using Tapeti.Flow.FlowHelpers; + +namespace Tapeti.Flow.Default +{ + public class FlowMiddleware : IControllerFilterMiddleware, IControllerMessageMiddleware, IControllerCleanupMiddleware + { + public async Task Filter(IControllerMessageContext context, Func next) + { + var flowContext = await CreateFlowContext(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(IControllerMessageContext context, HandlingResult handlingResult, Func next) + { + await next(); + + if (!context.Get(ContextItems.FlowContext, out FlowContext flowContext)) + return; + + if (flowContext?.FlowStateLock != null) + { + if (handlingResult.ConsumeResponse == ConsumeResponse.Nack + || handlingResult.MessageAction == MessageAction.ErrorLog) + { + await flowContext.FlowStateLock.DeleteFlowState(); + } + flowContext.FlowStateLock.Dispose(); + } + } + + + + private static async Task CreateFlowContext(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 + { + 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.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(context, yieldPoint); + } + } +} diff --git a/Tapeti.Flow/Default/FlowProvider.cs b/Tapeti.Flow/Default/FlowProvider.cs index 5c6d9d9..9da8a7d 100644 --- a/Tapeti.Flow/Default/FlowProvider.cs +++ b/Tapeti.Flow/Default/FlowProvider.cs @@ -4,9 +4,9 @@ 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; @@ -14,11 +14,11 @@ namespace Tapeti.Flow.Default { 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; @@ -72,7 +72,7 @@ namespace Tapeti.Flow.Default ConvergeMethodSync = convergeMethodTaskSync }); - var properties = new BasicProperties + var properties = new MessageProperties { CorrelationId = continuationID.ToString(), ReplyTo = responseHandlerInfo.ReplyToQueue @@ -96,12 +96,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,9 +120,9 @@ 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 binding = config.Bindings.ForMethod(responseHandler); if (binding == null) throw new ArgumentException("responseHandler must be a registered message handler", nameof(responseHandler)); @@ -158,13 +156,13 @@ namespace Tapeti.Flow.Default CorrelationId = context.Properties.CorrelationId, ReplyTo = context.Properties.ReplyTo, ResponseTypeName = requestAttribute.Response.FullName, - Mandatory = context.Properties.Persistent + Mandatory = context.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.MessageContext.Config.DependencyResolver.Resolve(); var flowID = Guid.NewGuid(); flowContext.FlowStateLock = await flowStore.LockFlowState(flowID); @@ -181,25 +179,20 @@ namespace Tapeti.Flow.Default }; } - public async Task Execute(IMessageContext context, IYieldPoint yieldPoint) + public async Task Execute(IControllerMessageContext 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)) + if (!context.Get(ContextItems.FlowContext, out FlowContext flowContext)) { flowContext = new FlowContext { MessageContext = context }; - context.Items.Add(ContextItems.FlowContext, flowContext); + context.Store(ContextItems.FlowContext, flowContext); } - else - flowContext = (FlowContext)flowContextItem; - try { @@ -234,12 +227,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; diff --git a/Tapeti.Flow/Default/FlowStarter.cs b/Tapeti.Flow/Default/FlowStarter.cs index 306f034..ab8a152 100644 --- a/Tapeti.Flow/Default/FlowStarter.cs +++ b/Tapeti.Flow/Default/FlowStarter.cs @@ -9,11 +9,11 @@ namespace Tapeti.Flow.Default { public class FlowStarter : IFlowStarter { - private readonly IConfig config; + private readonly ITapetiConfig config; private readonly ILogger logger; - public FlowStarter(IConfig config, ILogger logger) + public FlowStarter(ITapetiConfig config, ILogger logger) { this.config = config; this.logger = logger; @@ -47,9 +47,9 @@ namespace Tapeti.Flow.Default var controller = config.DependencyResolver.Resolve(); var yieldPoint = await getYieldPointResult(method.Invoke(controller, parameters)); - var context = new MessageContext + var context = new ControllerMessageContext { - DependencyResolver = config.DependencyResolver, + Config = config, Controller = controller }; @@ -72,6 +72,7 @@ namespace Tapeti.Flow.Default private async Task RunCleanup(MessageContext context, HandlingResult handlingResult) { + /* foreach (var handler in config.CleanupMiddleware) { try @@ -83,6 +84,7 @@ namespace Tapeti.Flow.Default logger.HandlerException(eCleanup); } } + */ } diff --git a/Tapeti.Flow/FlowMiddleware.cs b/Tapeti.Flow/FlowMiddleware.cs index ddc1d61..a4b9cdb 100644 --- a/Tapeti.Flow/FlowMiddleware.cs +++ b/Tapeti.Flow/FlowMiddleware.cs @@ -25,7 +25,6 @@ namespace Tapeti.Flow public IEnumerable GetMiddleware(IDependencyResolver dependencyResolver) { yield return new FlowBindingMiddleware(); - yield return new FlowCleanupMiddleware(); } } } diff --git a/Tapeti.Flow/IFlowProvider.cs b/Tapeti.Flow/IFlowProvider.cs index c8f6982..b5fd107 100644 --- a/Tapeti.Flow/IFlowProvider.cs +++ b/Tapeti.Flow/IFlowProvider.cs @@ -38,7 +38,7 @@ namespace Tapeti.Flow /// public interface IFlowHandler { - Task Execute(IMessageContext context, IYieldPoint yieldPoint); + Task Execute(IControllerMessageContext context, IYieldPoint yieldPoint); } public interface IFlowParallelRequestBuilder diff --git a/Tapeti.Flow/Tapeti.Flow.csproj b/Tapeti.Flow/Tapeti.Flow.csproj index 105aa14..a2c4094 100644 --- a/Tapeti.Flow/Tapeti.Flow.csproj +++ b/Tapeti.Flow/Tapeti.Flow.csproj @@ -5,6 +5,10 @@ true + + 1701;1702;1591 + + diff --git a/Tapeti.Serilog/Tapeti.Serilog.csproj b/Tapeti.Serilog/Tapeti.Serilog.csproj index b33e71b..d0780ff 100644 --- a/Tapeti.Serilog/Tapeti.Serilog.csproj +++ b/Tapeti.Serilog/Tapeti.Serilog.csproj @@ -5,6 +5,10 @@ true + + 1701;1702;1591 + + diff --git a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj index ed72a19..6378431 100644 --- a/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj +++ b/Tapeti.SimpleInjector/Tapeti.SimpleInjector.csproj @@ -5,6 +5,10 @@ true + + 1701;1702;1591 + + 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..b0084de 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] diff --git a/Tapeti.Tests/Tapeti.Tests.csproj b/Tapeti.Tests/Tapeti.Tests.csproj index 674cbd2..41ef4bd 100644 --- a/Tapeti.Tests/Tapeti.Tests.csproj +++ b/Tapeti.Tests/Tapeti.Tests.csproj @@ -4,6 +4,10 @@ netcoreapp2.1 + + 1701;1702;1591 + + @@ -14,4 +18,8 @@ + + + + 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/ConfigExtentions.cs b/Tapeti.Transient/ConfigExtentions.cs index 7401578..fd62748 100644 --- a/Tapeti.Transient/ConfigExtentions.cs +++ b/Tapeti.Transient/ConfigExtentions.cs @@ -1,12 +1,23 @@ using System; +using Tapeti.Config; namespace Tapeti.Transient { + /// + /// TapetiConfig extension to register Tapeti.Transient + /// public static class ConfigExtensions { - public static TapetiConfig WithTransient(this TapetiConfig config, TimeSpan defaultTimeout, string dynamicQueuePrefix = "transient") + /// + /// Registers the transient publisher and required middleware + /// + /// + /// + /// + /// + public static ITapetiConfigBuilder WithTransient(this ITapetiConfigBuilder config, TimeSpan defaultTimeout, string dynamicQueuePrefix = "transient") { - config.Use(new TransientMiddleware(defaultTimeout, dynamicQueuePrefix)); + config.Use(new TransientExtension(defaultTimeout, dynamicQueuePrefix)); return config; } } diff --git a/Tapeti.Transient/ITransientPublisher.cs b/Tapeti.Transient/ITransientPublisher.cs index 2765259..494a9b4 100644 --- a/Tapeti.Transient/ITransientPublisher.cs +++ b/Tapeti.Transient/ITransientPublisher.cs @@ -2,8 +2,17 @@ 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..dbe9e14 100644 --- a/Tapeti.Transient/Tapeti.Transient.csproj +++ b/Tapeti.Transient/Tapeti.Transient.csproj @@ -5,6 +5,10 @@ true + + 1701;1702 + + 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..f2f3e5e 100644 --- a/Tapeti.Transient/TransientGenericBinding.cs +++ b/Tapeti.Transient/TransientGenericBinding.cs @@ -1,52 +1,51 @@ 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. + /// + public class TransientGenericBinding : IBinding { private readonly TransientRouter router; + private readonly string dynamicQueuePrefix; + /// + public string QueueName { get; private set; } + + + /// 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.BindDirectDynamic(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) - { - router.TransientResponseQueueName = queueName; - } } } \ No newline at end of file diff --git a/Tapeti.Transient/TransientPublisher.cs b/Tapeti.Transient/TransientPublisher.cs index 62715e7..16343db 100644 --- a/Tapeti.Transient/TransientPublisher.cs +++ b/Tapeti.Transient/TransientPublisher.cs @@ -1,21 +1,26 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; namespace Tapeti.Transient { + /// + /// + /// Default implementation of ITransientPublisher + /// public 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)); diff --git a/Tapeti.Transient/TransientRouter.cs b/Tapeti.Transient/TransientRouter.cs index 775576b..7eb11e5 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 { + /// + /// Manages active requests and responses. For internal use. + /// public 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.png b/Tapeti.png new file mode 100644 index 0000000000000000000000000000000000000000..b4b8350b546493d8decd48289906ff1bca1c5b1a GIT binary patch literal 94581 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 + False ID KV - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> \ No newline at end of file + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + 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..e717a62 --- /dev/null +++ b/Tapeti/Config/IBinding.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + ///

+ /// 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; } + + + /// + /// 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); + } + + + + /// + /// 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 BindDirectDurable(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 BindDirectDynamic(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 BindDirectDynamic(string queuePrefix = null); + } +} 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..9877ff0 --- /dev/null +++ b/Tapeti/Config/IControllerBindingContext.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +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. + /// + Type MessageClass { 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 always done first 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..3bbd0ee --- /dev/null +++ b/Tapeti/Config/IControllerCleanupMiddleware.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; + +namespace Tapeti.Config +{ + /// + /// + /// Denotes middleware that runs after controller methods. + /// + public interface IControllerCleanupMiddleware : IControllerMiddlewareBase + { + /// + /// Called after the message handler method, even if exceptions occured. + /// + /// + /// + /// Always call to allow the next in the chain to clean up + Task Cleanup(IControllerMessageContext context, HandlingResult handlingResult, 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..25108c9 --- /dev/null +++ b/Tapeti/Config/IControllerMessageContext.cs @@ -0,0 +1,37 @@ +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; } + + + /// + /// Stores a key-value pair in the context for passing information between the various + /// controller middleware stages (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/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/IMessageContext.cs b/Tapeti/Config/IMessageContext.cs index 658636c..f8a839f 100644 --- a/Tapeti/Config/IMessageContext.cs +++ b/Tapeti/Config/IMessageContext.cs @@ -1,29 +1,46 @@ 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(); } } 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..c66e765 100644 --- a/Tapeti/Config/IPublishContext.cs +++ b/Tapeti/Config/IPublishContext.cs @@ -4,13 +4,34 @@ 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/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..dab033d --- /dev/null +++ b/Tapeti/Config/ITapetiConfigBuilder.cs @@ -0,0 +1,116 @@ +using System; + +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 + /// + /// + /// + 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..395b393 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 { + /// + /// Called when a connection to RabbitMQ has been established. + /// void Connected(); + + + /// + /// Called when the connection to RabbitMQ has been lost. + /// void Reconnected(); + + + /// + /// 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..ce83888 --- /dev/null +++ b/Tapeti/Connection/ITapetiClient.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +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. + /// + /// + /// The consumer implementation which will receive the messages from the queue + Task Consume(string queueName, IConsumer consumer); + + + /// + /// Creates a durable queue if it does not already exist, and updates the bindings. + /// + /// 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(string queueName, IEnumerable bindings); + + /// + /// Verifies a durable queue exists. Will raise an exception if it does not. + /// + /// The name of the queue to verify + Task DurableQueueVerify(string queueName); + + /// + /// Creates a dynamic 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. + Task DynamicQueueDeclare(string queuePrefix = null); + + /// + /// Add a binding to a dynamic queue. + /// + /// The name of the dynamic queue previously created using DynamicQueueDeclare + /// The binding to add to the dynamic queue + Task DynamicQueueBind(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..80b30ff --- /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 + /// + public 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, ConsumeResponse.Nack); + } + }); + } + } +} diff --git a/Tapeti/Connection/TapetiWorker.cs b/Tapeti/Connection/TapetiClient.cs similarity index 50% rename from Tapeti/Connection/TapetiWorker.cs rename to Tapeti/Connection/TapetiClient.cs index aca3a3b..35ca29f 100644 --- a/Tapeti/Connection/TapetiWorker.cs +++ b/Tapeti/Connection/TapetiClient.cs @@ -1,34 +1,45 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; +using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using System.Web; +using Newtonsoft.Json; using RabbitMQ.Client; using RabbitMQ.Client.Events; using RabbitMQ.Client.Exceptions; using RabbitMQ.Client.Framing; using Tapeti.Config; +using Tapeti.Default; using Tapeti.Exceptions; -using Tapeti.Helpers; using Tapeti.Tasks; namespace Tapeti.Connection { - public class TapetiWorker + /// + /// + /// Implementation of ITapetiClient for the RabbitMQ Client library + /// + public class TapetiClient : ITapetiClient { private const int ReconnectDelay = 5000; private const int MandatoryReturnTimeout = 30000; private const int MinimumConnectedReconnectDelay = 1000; - private readonly IConfig config; + private readonly TapetiConnectionParams connectionParams; + + private readonly ITapetiConfig config; private readonly ILogger logger; - public TapetiConnectionParams ConnectionParams { get; set; } + + + /// + /// Receives events when the connection state changes. + /// public IConnectionEventListener ConnectionEventListener { get; set; } - private readonly IMessageSerializer messageSerializer; - private readonly IRoutingKeyStrategy routingKeyStrategy; - private readonly IExchangeStrategy exchangeStrategy; + private readonly Lazy taskQueue = new Lazy(); @@ -39,6 +50,7 @@ namespace Tapeti.Connection private IModel channelInstance; private ulong lastDeliveryTag; private DateTime connectedDateTime; + private HttpClient managementClient; // These fields must be locked, since the callbacks for BasicAck/BasicReturn can run in a different thread private readonly object confirmLock = new object(); @@ -60,88 +72,123 @@ namespace Tapeti.Connection } - - public TapetiWorker(IConfig config) + /// + public TapetiClient(ITapetiConfig config, TapetiConnectionParams connectionParams) { this.config = config; + this.connectionParams = connectionParams; 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(() => + var handler = new HttpClientHandler { - WithRetryableChannel(channel => channel.BasicConsume(queueName, false, new TapetiConsumer(this, queueName, config.DependencyResolver, bindings, config.MessageMiddleware, config.CleanupMiddleware))); + 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) + { + var publishProperties = new RabbitMQMessageProperties(new BasicProperties(), properties); + + await taskQueue.Value.Add(async () => + { + Task publishResultTask = null; + var messageInfo = new ConfirmMessageInfo + { + ReturnKey = GetReturnKey(exchange, routingKey), + CompletionSource = new TaskCompletionSource() + }; + + + WithRetryableChannel(channel => + { + // 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; + + channel.BasicPublish(exchange, routingKey, mandatory, publishProperties.BasicProperties, 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 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 Task Subscribe(IQueue queue) + /// + public async Task Consume(string queueName, IConsumer consumer) { - return taskQueue.Value.Add(() => + if (string.IsNullOrEmpty(queueName)) + throw new ArgumentNullException(nameof(queueName)); + + await taskQueue.Value.Add(() => { - WithRetryableChannel(channel => + 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); - } - } + var basicConsumer = new TapetiBasicConsumer(consumer, Respond); + channel.BasicConsume(queueName, false, basicConsumer); }); }); } - public Task Respond(ulong deliveryTag, ConsumeResponse response) + private async Task Respond(ulong deliveryTag, ConsumeResponse response) { - return taskQueue.Value.Add(() => + await taskQueue.Value.Add(() => { // No need for a retryable channel here, if the connection is lost we can't // use the deliveryTag anymore. @@ -167,12 +214,82 @@ namespace Tapeti.Connection } - public Task Close() + /// + public async Task DurableQueueDeclare(string queueName, IEnumerable bindings) + { + await taskQueue.Value.Add(async () => + { + var existingBindings = await GetQueueBindings(queueName); + + WithRetryableChannel(channel => + { + channel.QueueDeclare(queueName, true, false, false); + + var currentBindings = bindings.ToList(); + + foreach (var binding in currentBindings) + channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); + + foreach (var deletedBinding in existingBindings.Where(binding => !currentBindings.Any(b => b.Exchange == binding.Exchange && b.RoutingKey == binding.RoutingKey))) + channel.QueueUnbind(queueName, deletedBinding.Exchange, deletedBinding.RoutingKey); + }); + }); + } + + /// + public async Task DurableQueueVerify(string queueName) + { + await taskQueue.Value.Add(() => + { + WithRetryableChannel(channel => + { + channel.QueueDeclarePassive(queueName); + }); + }); + } + + /// + public async Task DynamicQueueDeclare(string queuePrefix = null) + { + string queueName = null; + + await taskQueue.Value.Add(() => + { + WithRetryableChannel(channel => + { + if (!string.IsNullOrEmpty(queuePrefix)) + { + queueName = queuePrefix + "." + Guid.NewGuid().ToString("N"); + channel.QueueDeclare(queueName); + } + else + queueName = channel.QueueDeclare().QueueName; + }); + }); + + return queueName; + } + + /// + public async Task DynamicQueueBind(string queueName, QueueBinding binding) + { + await taskQueue.Value.Add(() => + { + WithRetryableChannel(channel => + { + channel.QueueBind(queueName, binding.Exchange, binding.RoutingKey); + }); + }); + } + + + /// + public async Task Close() { if (!taskQueue.IsValueCreated) - return Task.CompletedTask; + return; - return taskQueue.Value.Add(() => + await taskQueue.Value.Add(() => { isClosing = true; @@ -194,94 +311,95 @@ namespace Tapeti.Connection } - private Task Publish(object message, IBasicProperties properties, string exchange, string routingKey, bool mandatory) + private static readonly List TransientStatusCodes = new List { - var context = new PublishContext - { - DependencyResolver = config.DependencyResolver, - Exchange = exchange, - RoutingKey = routingKey, - Message = message, - Properties = properties ?? new BasicProperties() - }; + HttpStatusCode.GatewayTimeout, + HttpStatusCode.RequestTimeout, + HttpStatusCode.ServiceUnavailable + }; - if (!context.Properties.IsTimestampPresent()) - context.Properties.Timestamp = new AmqpTimestamp(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds()); - - if (!context.Properties.IsDeliveryModePresent()) - context.Properties.DeliveryMode = 2; // Persistent + 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) + }; - // 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 () => + 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); + var requestUri = new Uri($"{connectionParams.HostName}:{connectionParams.Port}/api/queues/{virtualHostPath}/{queuePath}/bindings"); + + using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri)) + { + var retryDelayIndex = 0; + + while (true) { - 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 + try { - ReturnKey = GetReturnKey(context.Exchange, context.RoutingKey), - CompletionSource = new TaskCompletionSource() - }; + var response = await managementClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + var bindings = JsonConvert.DeserializeObject>(content); - WithRetryableChannel(channel => + // 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)); + } + catch (TimeoutException) { - // The delivery tag is lost after a reconnect, register under the new tag - if (config.UsePublisherConfirms) - { - lastDeliveryTag++; + } + catch (WebException e) + { + if (!(e.Response is HttpWebResponse response)) + throw; - Monitor.Enter(confirmLock); - try - { - confirmMessages.Add(lastDeliveryTag, messageInfo); - } - finally - { - Monitor.Exit(confirmLock); - } + if (!TransientStatusCodes.Contains(response.StatusCode)) + throw; + } - publishResultTask = messageInfo.CompletionSource.Task; - } - else - mandatory = false; + await Task.Delay(ExponentialBackoff[retryDelayIndex]); - 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 + if (retryDelayIndex < ExponentialBackoff.Length - 1) + retryDelayIndex++; + } + } } @@ -298,9 +416,8 @@ namespace Tapeti.Connection operation(GetChannel()); break; } - catch (AlreadyClosedException e) + catch (AlreadyClosedException) { - // TODO log? } } } @@ -323,11 +440,11 @@ namespace Tapeti.Connection var connectionFactory = new ConnectionFactory { - HostName = ConnectionParams.HostName, - Port = ConnectionParams.Port, - VirtualHost = ConnectionParams.VirtualHost, - UserName = ConnectionParams.Username, - Password = ConnectionParams.Password, + HostName = connectionParams.HostName, + Port = connectionParams.Port, + VirtualHost = connectionParams.VirtualHost, + UserName = connectionParams.Username, + Password = connectionParams.Password, AutomaticRecoveryEnabled = false, TopologyRecoveryEnabled = false, RequestedHeartbeat = 30 @@ -337,7 +454,7 @@ namespace Tapeti.Connection { try { - logger.Connect(ConnectionParams); + logger.Connect(connectionParams); connection = connectionFactory.CreateConnection(); channelInstance = connection.CreateModel(); @@ -345,13 +462,16 @@ namespace Tapeti.Connection if (channelInstance == null) throw new BrokerUnreachableException(null); - if (config.UsePublisherConfirms) + if (config.Features.PublisherConfirms) { lastDeliveryTag = 0; Monitor.Enter(confirmLock); try { + foreach (var pair in confirmMessages) + pair.Value.CompletionSource.SetCanceled(); + confirmMessages.Clear(); } finally @@ -362,8 +482,8 @@ namespace Tapeti.Connection channelInstance.ConfirmSelect(); } - if (ConnectionParams.PrefetchCount > 0) - channelInstance.BasicQos(0, ConnectionParams.PrefetchCount, false); + if (connectionParams.PrefetchCount > 0) + channelInstance.BasicQos(0, connectionParams.PrefetchCount, false); channelInstance.ModelShutdown += (sender, e) => { @@ -390,14 +510,14 @@ namespace Tapeti.Connection else ConnectionEventListener?.Connected(); - logger.ConnectSuccess(ConnectionParams); + logger.ConnectSuccess(connectionParams); isReconnect = true; break; } catch (BrokerUnreachableException e) { - logger.ConnectFailed(ConnectionParams, e); + logger.ConnectFailed(connectionParams, e); Thread.Sleep(ReconnectDelay); } } @@ -507,15 +627,5 @@ namespace Tapeti.Connection { 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/Connection/TapetiConsumer.cs b/Tapeti/Connection/TapetiConsumer.cs index 06f87aa..fff74ef 100644 --- a/Tapeti/Connection/TapetiConsumer.cs +++ b/Tapeti/Connection/TapetiConsumer.cs @@ -1,309 +1,190 @@ 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. + /// + public 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 () => + try { - MessageContext context = null; - HandlingResult handlingResult = null; - try + var message = messageSerializer.Deserialize(body, properties); + if (message == null) + throw new ArgumentException($"Message body could not be deserialized into a message object in queue {queueName}", nameof(body)); + + await DispatchMessage(message, new MessageContextData { + Exchange = exchange, + RoutingKey = routingKey, + Properties = properties + }); + + return ConsumeResponse.Ack; + } + catch (Exception e) + { + // TODO exception strategy + // TODO logger + return ConsumeResponse.Nack; + } + + + /* + + handlingResult = new HandlingResult + { + ConsumeResponse = ConsumeResponse.Ack, + MessageAction = MessageAction.None + }; + } + catch (Exception eDispatch) + { + var exception = ExceptionDispatchInfo.Capture(UnwrapException(eDispatch)); + logger.HandlerException(eDispatch); try { - context = new MessageContext - { - DependencyResolver = dependencyResolver, - Queue = queueName, - RoutingKey = routingKey, - Properties = properties - }; + var exceptionStrategyContext = new ExceptionStrategyContext(context, exception.SourceException); - await DispatchMesage(context, body); + exceptionStrategy.HandleException(exceptionStrategyContext); + handlingResult = exceptionStrategyContext.HandlingResult.ToHandlingResult(); + } + catch (Exception eStrategy) + { + logger.HandlerException(eStrategy); + } + } + try + { + if (handlingResult == null) + { handlingResult = new HandlingResult { - ConsumeResponse = ConsumeResponse.Ack, + ConsumeResponse = ConsumeResponse.Nack, 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) - { - try - { - await handler.Handle(context, handlingResult); + 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 client.Respond(deliveryTag, handlingResult.ConsumeResponse); + } + catch (Exception eRespond) + { + logger.HandlerException(eRespond); + } + try + { + context?.Dispose(); + } + catch (Exception eDispose) + { + logger.HandlerException(eDispose); + } + } + */ } - 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 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; - } + await InvokeUsingBinding(message, messageContextData, binding); + validMessageType = true; } if (!validMessageType) - throw new ArgumentException($"Unsupported message type: {message.GetType().FullName}"); + throw new ArgumentException($"Unsupported message type in queue {queueName}: {message.GetType().FullName}"); } - 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) + 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."); + Config = config, + Queue = queueName, + Exchange = messageContextData.Exchange, + RoutingKey = messageContextData.RoutingKey, + Message = message, + Properties = messageContextData.Properties, + Binding = binding + }; try { - currentContext = context; - - context.UseNestedContext = Next == null ? (Action)null : UseNestedContext; - - await handle(context, CallNext); + await MiddlewareHelper.GoAsync(config.Middleware.Message, + (handler, next) => handler.Handle(context, next), + async () => { await binding.Invoke(context); }); } finally { - currentContext = null; + context.Dispose(); } } - private async Task CallNext() - { - if (Next == null) - return; - if (nextContext != null) - { - await Next.Call(nextContext); - }else - { - try - { - await Next.Call(currentContext); - } - finally - { - currentContext.UseNestedContext = UseNestedContext; - } - } - } - void UseNestedContext(MessageContext context) + private struct MessageContextData { - if (nextContext != null) - throw new InvalidOperationException("Previous nested context was not yet disposed."); - - context.OnContextDisposed = OnContextDisposed; - nextContext = context; - } - - void OnContextDisposed(MessageContext context) - { - 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..19bea75 100644 --- a/Tapeti/Connection/TapetiPublisher.cs +++ b/Tapeti/Connection/TapetiPublisher.cs @@ -1,37 +1,92 @@ using System; +using System.Diagnostics; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using RabbitMQ.Client; using Tapeti.Annotations; +using Tapeti.Config; +using Tapeti.Default; +using Tapeti.Exceptions; +using Tapeti.Helpers; namespace Tapeti.Connection { + /// public 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 +94,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 ce309b2..6013b61 100644 --- a/Tapeti/Connection/TapetiSubscriber.cs +++ b/Tapeti/Connection/TapetiSubscriber.cs @@ -6,39 +6,273 @@ using Tapeti.Config; namespace Tapeti.Connection { + /// public class TapetiSubscriber : ISubscriber { - private readonly Func workerFactory; - private readonly List queues; + private readonly Func clientFactory; + private readonly ITapetiConfig config; private bool consuming; - 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 Task RebindQueues() + /// + /// Applies the configured bindings and declares the queues in RabbitMQ. For internal use only. + /// + /// + public async Task ApplyBindings() { - return BindQueues(); + var routingKeyStrategy = config.DependencyResolver.Resolve(); + var exchangeStrategy = config.DependencyResolver.Resolve(); + + var bindingTarget = config.Features.DeclareDurableQueues + ? (CustomBindingTarget)new DeclareDurableQueuesBindingTarget(clientFactory, routingKeyStrategy, exchangeStrategy) + : new PassiveDurableQueuesBindingTarget(clientFactory, routingKeyStrategy, exchangeStrategy); + + await Task.WhenAll(config.Bindings.Select(binding => binding.Apply(bindingTarget))); + await bindingTarget.Apply(); } - public Task Resume() + /// + 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()); + + 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(queueName, consumer); + })); + } + + + private async Task ApplyBinding(IBinding binding, IBindingTarget bindingTarget) + { + await binding.Apply(bindingTarget); + } + + + private abstract class CustomBindingTarget : IBindingTarget + { + protected readonly Func ClientFactory; + protected readonly IRoutingKeyStrategy RoutingKeyStrategy; + protected readonly IExchangeStrategy ExchangeStrategy; + + private struct DynamicQueueInfo + { + public string QueueName; + public List MessageClasses; + } + + private readonly Dictionary> dynamicQueues = new Dictionary>(); + + + protected CustomBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy) + { + ClientFactory = clientFactory; + RoutingKeyStrategy = routingKeyStrategy; + ExchangeStrategy = exchangeStrategy; + } + + + public virtual Task Apply() + { + return Task.CompletedTask; + } + + + public abstract Task BindDurable(Type messageClass, string queueName); + public abstract Task BindDirectDurable(string queueName); + + + public async Task BindDynamic(Type messageClass, string queuePrefix = null) + { + var result = await DeclareDynamicQueue(messageClass, queuePrefix); + + if (result.IsNewMessageClass) + { + var routingKey = RoutingKeyStrategy.GetRoutingKey(messageClass); + var exchange = ExchangeStrategy.GetExchange(messageClass); + + await ClientFactory().DynamicQueueBind(result.QueueName, new QueueBinding(exchange, routingKey)); + } + + return result.QueueName; + } + + + public async Task BindDirectDynamic(Type messageClass, string queuePrefix = null) + { + var result = await DeclareDynamicQueue(messageClass, queuePrefix); + return result.QueueName; + } + + + public async Task BindDirectDynamic(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(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(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>(); + + + public DeclareDurableQueuesBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy) : base(clientFactory, routingKeyStrategy, exchangeStrategy) + { + } + + + 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 BindDirectDurable(string queueName) + { + if (!durableQueues.ContainsKey(queueName)) + durableQueues.Add(queueName, new List()); + + return Task.CompletedTask; + } + + + public override async Task Apply() + { + var worker = ClientFactory(); + + 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 worker.DurableQueueDeclare(queue.Key, bindings); + })); + } + } + + + private class PassiveDurableQueuesBindingTarget : CustomBindingTarget + { + private readonly List durableQueues = new List(); + + + public PassiveDurableQueuesBindingTarget(Func clientFactory, IRoutingKeyStrategy routingKeyStrategy, IExchangeStrategy exchangeStrategy) : base(clientFactory, routingKeyStrategy, exchangeStrategy) + { + } + + + public override async Task BindDurable(Type messageClass, string queueName) + { + await VerifyDurableQueue(queueName); + } + + public override async Task BindDirectDurable(string queueName) + { + await VerifyDurableQueue(queueName); + } + + + private async Task VerifyDurableQueue(string queueName) + { + if (!durableQueues.Contains(queueName)) + { + await ClientFactory().DurableQueueVerify(queueName); + durableQueues.Add(queueName); + } + } } } } diff --git a/Tapeti/ConsumeResponse.cs b/Tapeti/ConsumeResponse.cs index 2997930..d539170 100644 --- a/Tapeti/ConsumeResponse.cs +++ b/Tapeti/ConsumeResponse.cs @@ -1,9 +1,23 @@ namespace Tapeti { + /// + /// Determines the response sent back after handling a message. + /// public enum ConsumeResponse { + /// + /// Acknowledge the message and remove it from the queue + /// Ack, + + /// + /// Negatively acknowledge the message and remove it from the queue, send to dead-letter queue if configured on the bus + /// Nack, + + /// + /// Negatively acknowledge the message and put it back in the queue to try again later + /// Requeue } } diff --git a/Tapeti/Default/ControllerMessageContext.cs b/Tapeti/Default/ControllerMessageContext.cs new file mode 100644 index 0000000..4b29a07 --- /dev/null +++ b/Tapeti/Default/ControllerMessageContext.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Tapeti.Config; + +namespace Tapeti.Default +{ + /// + public class ControllerMessageContext : MessageContext, IControllerMessageContext + { + private Dictionary items = new Dictionary(); + + + /// + public object Controller { get; set; } + + /// + public new IControllerMethodBinding Binding { get; set; } + + + /// + public override void Dispose() + { + foreach (var item in items.Values) + (item as IDisposable)?.Dispose(); + + base.Dispose(); + } + + + /// + public void Store(string key, object value) + { + items.Add(key, value); + } + + + /// + public bool Get(string key, out T value) where T : class + { + if (!items.TryGetValue(key, out var objectValue)) + { + value = default(T); + return false; + } + + value = (T)objectValue; + return true; + } + } +} diff --git a/Tapeti/Default/ControllerMethodBinding.cs b/Tapeti/Default/ControllerMethodBinding.cs new file mode 100644 index 0000000..b2b8986 --- /dev/null +++ b/Tapeti/Default/ControllerMethodBinding.cs @@ -0,0 +1,77 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Tapeti.Config; + +namespace Tapeti.Default +{ + /// + /// + /// Binding implementation for controller methods. Do not instantiate this class yourself, + /// instead use the ITapetiConfigBuilder RegisterController / RegisterAllControllers extension + /// methods. + /// + public class ControllerMethodBinding : IBinding + { + private readonly Type controller; + private readonly MethodInfo method; + private readonly QueueInfo queueInfo; + + + /// + public string QueueName { get; private set; } + + + /// + public ControllerMethodBinding(Type controller, MethodInfo method, QueueInfo queueInfo) + { + this.controller = controller; + this.method = method; + this.queueInfo = queueInfo; + } + + + /// + public Task Apply(IBindingTarget target) + { + // TODO ControllerMethodBinding + throw new NotImplementedException(); + } + + + /// + public bool Accept(Type messageClass) + { + throw new NotImplementedException(); + } + + /// + public Task Invoke(IMessageContext context) + { + throw new NotImplementedException(); + } + + + /// + /// + /// + public class QueueInfo + { + /// + /// Whether the queue is dynamic or durable. + /// + public bool Dynamic { 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 => 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/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..34ad212 100644 --- a/Tapeti/Default/MessageBinding.cs +++ b/Tapeti/Default/MessageBinding.cs @@ -3,9 +3,15 @@ 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"); @@ -15,7 +21,7 @@ namespace Tapeti.Default 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; + context.SetMessageClass(parameter.Info.ParameterType); next(); } diff --git a/Tapeti/Default/MessageContext.cs b/Tapeti/Default/MessageContext.cs index 77486dc..8f0b8b6 100644 --- a/Tapeti/Default/MessageContext.cs +++ b/Tapeti/Default/MessageContext.cs @@ -1,172 +1,34 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using RabbitMQ.Client; -using Tapeti.Config; -using System.Linq; +using Tapeti.Config; namespace Tapeti.Default { + /// public class MessageContext : IMessageContext { - public IDependencyResolver DependencyResolver { get; set; } + /// + public ITapetiConfig Config { get; set; } - public object Controller { 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() + /// + public virtual void Dispose() { - 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); - } - - public IMessageContext SetupNestedContext() - { - if (UseNestedContext == null) - throw new NotSupportedException("This context does not support creating nested contexts"); - - var nested = new MessageContext(this); - - UseNestedContext(nested); - - return nested; - } - - private class DeferingDictionary : IDictionary - { - private readonly IDictionary myState; - private readonly IDictionary deferee; - - public DeferingDictionary(IDictionary deferee) - { - myState = new Dictionary(); - this.deferee = deferee; - } - - 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); - } } } } 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/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..6de9719 --- /dev/null +++ b/Tapeti/Default/RabbitMQMessageProperties.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Text; +using RabbitMQ.Client; +using Tapeti.Config; + +namespace Tapeti.Default +{ + /// + /// + /// Wrapper for RabbitMQ Client's IBasicProperties + /// + public class RabbitMQMessageProperties : IMessageProperties + { + 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) + { + this.BasicProperties = BasicProperties; + } + + + /// + public RabbitMQMessageProperties(IBasicProperties BasicProperties, IMessageProperties source) + { + this.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/Helpers/ConnectionstringParser.cs b/Tapeti/Helpers/ConnectionstringParser.cs index bbda0d9..57422da 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,7 +114,9 @@ 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; diff --git a/Tapeti/IConnection.cs b/Tapeti/IConnection.cs index 5993f0f..364efd8 100644 --- a/Tapeti/IConnection.cs +++ b/Tapeti/IConnection.cs @@ -5,8 +5,76 @@ using System.Threading.Tasks; namespace Tapeti { + /// + /// 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 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 EventHandler 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 EventHandler Reconnected; + } } diff --git a/Tapeti/IConsumer.cs b/Tapeti/IConsumer.cs new file mode 100644 index 0000000..f8be17a --- /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..1862aa4 100644 --- a/Tapeti/IDependencyResolver.cs +++ b/Tapeti/IDependencyResolver.cs @@ -2,6 +2,9 @@ namespace Tapeti { + /// + /// Wrapper interface for an IoC container to allow dependency injection in Tapeti. + /// public interface IDependencyResolver { T Resolve() where T : class; @@ -9,6 +12,10 @@ namespace Tapeti } + /// + /// Allows registering controller classes into the IoC container. Also registers default implementations, + /// so that the calling application may override these. + /// public interface IDependencyContainer : IDependencyResolver { void RegisterDefault() where TService : class where TImplementation : class, TService; 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/ISubscriber.cs b/Tapeti/ISubscriber.cs index 06a76da..6b54bbf 100644 --- a/Tapeti/ISubscriber.cs +++ b/Tapeti/ISubscriber.cs @@ -2,8 +2,14 @@ namespace Tapeti { + /// + /// Manages subscriptions to queues as configured by the bindings. + /// public interface ISubscriber { + /// + /// Starts consuming from the subscribed queues if not already started. + /// Task Resume(); } } diff --git a/Tapeti/Tapeti.csproj b/Tapeti/Tapeti.csproj index d4ecad3..95868f0 100644 --- a/Tapeti/Tapeti.csproj +++ b/Tapeti/Tapeti.csproj @@ -5,6 +5,10 @@ true + + 1701;1702 + + diff --git a/Tapeti/TapetiConfig.cs b/Tapeti/TapetiConfig.cs index 9291798..9247f11 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); + } + + + /// + public ITapetiConfig Build() + { + if (config == null) + throw new InvalidOperationException("TapetiConfig.Build must only be called once"); Use(new DependencyResolverBinding()); - Use(new MessageBinding()); Use(new PublishResultBinding()); - } - - public IConfig Build() - { - RegisterCustomBindings(); + // Registered last so it runs first and the MessageClass is known to other middleware + Use(new MessageBinding()); 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,85 +187,133 @@ namespace Tapeti } - public TapetiConfig RegisterController(Type controller) + /// + public void ApplyBindingMiddleware(IControllerBindingContext context, Action lastHandler) { - var controllerQueueInfo = GetQueueInfo(controller); + MiddlewareHelper.Go(bindingMiddleware, + (handler, next) => handler.Handle(context, next), + lastHandler); + } - 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)) + private Config GetConfig() + { + if (config == null) + throw new InvalidOperationException("TapetiConfig can not be updated after Build"); + + return null; + } + + + /// + 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 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); + DependencyResolver = dependencyResolver; } - 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) + public void Lock() { - // TODO Do we need to configure additional middleware, or does this only get confused if there is no MessageClass + bindings.Lock(); + } - var binding = new CustomBinding(customBinding); - if (binding.QueueInfo.Dynamic == false) - { - AddStaticRegistration(binding); - } - else if (binding.MessageClass != null) - { - AddDynamicRegistration(binding); - } - else - { - AddUniqueRegistration(binding); - } + + 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; } } + + internal class ConfigFeatures : ITapetiConfigFeatues + { + public bool PublisherConfirms { get; internal set; } = true; + public bool DeclareDurableQueues { get; internal set; } = true; + } + + + internal class ConfigMiddleware : ITapetiConfigMiddleware + { + private readonly List messageMiddleware = new List(); + private readonly List publishMiddleware = new List(); + + + public IReadOnlyList Message => messageMiddleware; + public IReadOnlyList Publish => publishMiddleware; + + + public void Use(IMessageMiddleware handler) + { + messageMiddleware.Add(handler); + } + + public void Use(IPublishMiddleware handler) + { + publishMiddleware.Add(handler); + } + } + + + internal class ConfigBindings : List, ITapetiConfigBindings + { + private Dictionary methodLookup; + + + public IControllerMethodBinding ForMethod(Delegate method) + { + return methodLookup.TryGetValue(method.Method, out var binding) ? binding : null; + } + + + public void Lock() + { + methodLookup = this + .Where(binding => binding is IControllerMethodBinding) + .Cast() + .ToDictionary(binding => binding.Method, binding => binding); + } + } + } + + + /* + public delegate Task MessageHandlerFunc(IMessageContext context, object message); + + protected MessageHandlerFunc GetMessageHandler(IBindingContext context, MethodInfo method) { var allowBinding= false; @@ -408,48 +425,6 @@ namespace Tapeti } - 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)) @@ -457,300 +432,6 @@ namespace Tapeti 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); - } - - - public IBinding GetBinding(Delegate method) - { - return bindingMethodLookup.TryGetValue(method.Method, out var binding) ? binding : null; - } - } - - - protected class Queue : IDynamicQueue - { - 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; - } - } - - protected interface IBindingQueueInfo : IBuildBinding - { - QueueInfo QueueInfo { get; } - } - - 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; } - - private QueueInfo queueInfo; - public QueueInfo QueueInfo - { - get => queueInfo; - set - { - QueueName = (value?.Dynamic).GetValueOrDefault() ? value?.Name : null; - queueInfo = value; - } - } - - public MessageHandlerFunc MessageHandler { get; set; } - - - public void SetQueueName(string queueName) - { - 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); - } - } - - - protected class CustomBinding : IBindingQueueInfo - { - private readonly ICustomBinding inner; - - public CustomBinding(ICustomBinding inner) - { - 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); - } - - - public void Use(IMessageMiddleware middleware) - { - 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; - } - } } + */ } diff --git a/Tapeti/TapetiConfigControllers.cs b/Tapeti/TapetiConfigControllers.cs new file mode 100644 index 0000000..dd41f2b --- /dev/null +++ b/Tapeti/TapetiConfigControllers.cs @@ -0,0 +1,127 @@ +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); + + 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)) + { + // TODO create binding for method + + /* + var context = new BindingContext(method); + var messageHandler = GetMessageHandler(context, method); + if (messageHandler == null) + continue; + */ + + 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 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); + */ + + builder.RegisterBinding(new ControllerMethodBinding(controller, method, methodQueueInfo)); + } + + 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 { Dynamic = true, Name = dynamicQueueAttribute.Prefix }; + + return durableQueueAttribute != null + ? new ControllerMethodBinding.QueueInfo { Dynamic = false, Name = durableQueueAttribute.Name } + : null; + } + } +} diff --git a/Tapeti/TapetiConnection.cs b/Tapeti/TapetiConnection.cs index d66f880..844249f 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 DisconnectedEventHandler Disconnected; + + /// public event EventHandler 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,35 @@ 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(); } + private class ConnectionEventListener: IConnectionEventListener { private readonly TapetiConnection owner; @@ -99,25 +126,47 @@ namespace Tapeti } } + + /// + /// Called when a connection to RabbitMQ has been established. + /// protected virtual void OnConnected(EventArgs e) { - Task.Run(() => Connected?.Invoke(this, e)); + var connectedEvent = Connected; + if (connectedEvent == null) + return; + + Task.Run(() => connectedEvent.Invoke(this, e)); } + /// + /// Called when the connection to RabbitMQ has been lost. + /// protected virtual void OnReconnected(EventArgs e) { + var reconnectedEvent = Reconnected; + if (reconnectedEvent == null) + return; + Task.Run(() => { - subscriber?.RebindQueues().ContinueWith((t) => + subscriber?.ApplyBindings().ContinueWith((t) => { - Reconnected?.Invoke(this, e); + 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; + + Task.Run(() => disconnectedEvent.Invoke(this, e)); } } } diff --git a/Tapeti/TapetiConnectionParams.cs b/Tapeti/TapetiConnectionParams.cs index 2c6c525..9b0414c 100644 --- a/Tapeti/TapetiConnectionParams.cs +++ b/Tapeti/TapetiConnectionParams.cs @@ -4,12 +4,34 @@ namespace Tapeti { + /// + /// + /// public class TapetiConnectionParams { + /// + /// 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"; /// @@ -20,10 +42,17 @@ namespace Tapeti public ushort PrefetchCount { get; set; } = 50; + /// 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; 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(); + } } } }