-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove Message base class #685
Conversation
cba521d
to
eea63cf
Compare
If the resulting API feels weird (and it does) it's cause at the top of the publish api we don't have a generic |
@@ -1,13 +1,12 @@ | |||
using System; | |||
using JustSaying.Models; | |||
|
|||
namespace JustSaying.Messaging.MessageSerialization | |||
{ | |||
public interface IMessageSerializer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This whole interface should be generic and object
killed from the api surface.
@@ -1,5 +1,3 @@ | |||
using JustSaying.Models; | |||
|
|||
namespace JustSaying.Messaging.MessageSerialization | |||
{ | |||
public interface IMessageSerializationRegister |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This whole interface should be generic and object killed from the api surface.
eea63cf
to
c8c8546
Compare
@@ -32,7 +34,7 @@ internal sealed class ExactlyOnceHandler<T> : IHandlerAsync<T> where T : Message | |||
|
|||
public async Task<bool> Handle(T message) | |||
{ | |||
string lockKey = $"{message.UniqueKey()}-{_lockSuffixKeyForHandler}"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So here UniqueKey
is a virtual method that defaults to Id.ToString()
, I've changed it so we can optionally pass a function when we register the handler, maybe not the right place to surface the api.
@@ -155,8 +154,7 @@ private async Task<bool> CallMessageHandler(Message message) | |||
watch.Stop(); | |||
|
|||
_logger.LogTrace( | |||
"Handled message with Id '{MessageId}' of type {MessageType} in {TimeToHandle}.", | |||
message.Id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So here it is, the one place where we actually do something with a property on Message
(ok, there was UniqueKey()
too I guess). It's on a trace log, we arguably would want to use the SQS message id for this level of debugging, but tying it back to the domain event is going to be the challenge.
There's a failing test because the |
@@ -49,14 +48,19 @@ internal sealed class MessageHandlerWrapper | |||
throw new Exception("IMessageLock is null. You need to specify an implementation for IMessageLock."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should change this to InvalidOperationException
(?)
@@ -49,14 +48,19 @@ internal sealed class MessageHandlerWrapper | |||
throw new Exception("IMessageLock is null. You need to specify an implementation for IMessageLock."); | |||
} | |||
|
|||
if (uniqueKeySelector == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should throw this in the caller before we start to do any work, rather than in the delegate further along the call path.
@@ -93,7 +92,7 @@ public SqsNotificationListener WithMessageProcessingStrategy(IMessageProcessingS | |||
return this; | |||
} | |||
|
|||
public void AddMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler) where T : Message | |||
public void AddMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler, Func<T, string> uniqueKeySelector = default) where T : class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is optional, how does it avoid causing the ArgumentNullException
later? Or is this causing the test failure you mentioned?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow. I didn't know there was a default
for Func<T, string>
!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There isn't, well there is, it's just null
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, haha. I don't know why I assumed otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
default
is just syntactic sugar added in C# 8 (at least I think it was 8, maybe a minor revision of 7) for default(Type)
. It's null
for any reference type and the "zero" value for value types (0
, 0l, etc.). The same behaviour you'd get from LINQ's
FirstOrDefault()`.
@@ -11,6 +10,6 @@ public class SnsWriteConfiguration | |||
/// Extension point enabling custom error handling on a per notification basis, including ability handle raised exceptions. | |||
/// </summary> | |||
/// <returns>Boolean indicating whether the exception has been handled</returns> | |||
public Func<Exception, Message, bool> HandleException { get; set; } | |||
public Func<Exception, object, bool> HandleException { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we'd need to bite the bullet and introduce the generics you mentioned? Otherwise, using the error handler is going to be quite bleurgh from a caller having to cast/pattern-match the type all the time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this is the bit that persuaded me we need to do better. As it stands there are islands of generic type information, we can fix that by having more generic apis at the top level and flowing it down. A big change with this approach would be IMessagePublisher<T>
and reworking the builder APIs (yet again).
Or we do the message envelope thing, which is looking more appealing. It would contain Id
& Timestamp
(perhaps) and a TMessage
property, but other than that, is really just a way of allowing people to cleanly "pick out" their message types by defining a concrete envelope, then they get the nice typed guide rails they'd come to expect.
The fiddly bit for us with this approach is then splitting out the properties on (de)serializing, but shouldn't be too tricky.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite the conundrum! I'm not such a fan of the idea of IMessagePublisher<T>
, although if we did choose to go down that route, we could probably easily have some sort of IAnyMessagePublisher
.
I think it's worth thinking about what use there was in having this typed as Message
in the first place. What sort of error handling code would we like to be able to write? If you want to write some generic error handling code then you can't write that in terms of any specific domain message type anyway. In that case you'd rather want to write a try-catch in your handler for that specific message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's my thoughts too @shaynevanasperen, I think a message envelope concept doesn't solve this problem, it acts as a potential way to separate the non-domain stuff from the transporty stuff.
When the base class was Message
, you were either:
- using
Id
,Tenant
etc... onMessage
- ignoring the message and doing some generic error reporting (I think this is the primary use case)
- upcasting to the known inheriting types
You could still do those last two with a message of type object
, it just looks bad.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I just realised that this is for handling SNS publishing errors! I thought it was for errors in implementations of IHandlerAsync<T>
. I've had a look around the code now and I see that the equivalent of this on the other side (SQS) is in SqsReadConfiguration. Over there, we work exclusively with the Amazon.SQS.Model.Message
type (with no strong typing of our domain message).
So why not have symmetry with that approach and just do the equivalent here? That would mean using Amazon.SimpleNotificationService.Model.PublishRequest
making the signature public Func<Exception, PublishRequest, bool> HandleException { get; set; }
. The PublishRequest
object has the message as a serialized string, so the error logging can easily use that. This abstraction seems like it should remain at the raw transport level, and if consumers want more control they can always wrap the IMessagePublisher
this library returns with some decorator that adds custom error logging and possibly even things like a retry mechanism etc.
While looking around at the code which uses this configuration delegate, I noticed something else that we might want to do there. The error handling there only handles exceptions but won't call our error handler if the response.HttpStatusCode
is not 200. I haven't checked this though (maybe the Client.PublishAsync
call does throw for non-success status codes?).
{ | ||
throw new ArgumentNullException(nameof(handler)); | ||
} | ||
var wrappedHandler = new Func<Exception, object, bool>((ex, message) => handler(ex, (T)message)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like here 😄
|
||
namespace JustSaying | ||
{ | ||
public interface IPublishConfiguration | ||
{ | ||
int PublishFailureReAttempts { get; set; } | ||
TimeSpan PublishFailureBackoff { get; set; } | ||
Action<MessageResponse, Message> MessageResponseLogger { get; set; } | ||
Action<MessageResponse, object> MessageResponseLogger { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly here.
@@ -93,7 +92,7 @@ public SqsNotificationListener WithMessageProcessingStrategy(IMessageProcessingS | |||
return this; | |||
} | |||
|
|||
public void AddMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler) where T : Message | |||
public void AddMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler, Func<T, string> uniqueKeySelector = default) where T : class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow. I didn't know there was a default
for Func<T, string>
!
@@ -23,19 +22,19 @@ internal sealed class MessageHandlerWrapper | |||
_loggerFactory = loggerFactory; | |||
} | |||
|
|||
public Func<Message, Task<bool>> WrapMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler) where T : Message | |||
public Func<object, Task<bool>> WrapMessageHandler<T>(Func<IHandlerAsync<T>> futureHandler, Func<T, string> uniqueKeySelector = default) where T : class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it not possible to just use the SNS/SQS message ID for the unique key? Why does it have to be a value in the domain message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original thinking behind this was that only the developer knows what determines the messages identity, and what is a duplicate. Perhaps the event publisher has OrderId and that is the real identity of the message, or a composite of OrderId and Status. Maybe it's a hash of all of the properties on the message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. And with the benefit of hindsight, has that thinking paid off? Perhaps it's better to remove this "exactly once" functionality from this library and force consumers to make their solutions inherently idempotent in the domain rather than only at the message bus layer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You know: Smart Endpoints and Dumb Pipes
@@ -11,6 +10,6 @@ public class SnsWriteConfiguration | |||
/// Extension point enabling custom error handling on a per notification basis, including ability handle raised exceptions. | |||
/// </summary> | |||
/// <returns>Boolean indicating whether the exception has been handled</returns> | |||
public Func<Exception, Message, bool> HandleException { get; set; } | |||
public Func<Exception, object, bool> HandleException { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite the conundrum! I'm not such a fan of the idea of IMessagePublisher<T>
, although if we did choose to go down that route, we could probably easily have some sort of IAnyMessagePublisher
.
I think it's worth thinking about what use there was in having this typed as Message
in the first place. What sort of error handling code would we like to be able to write? If you want to write some generic error handling code then you can't write that in terms of any specific domain message type anyway. In that case you'd rather want to write a try-catch in your handler for that specific message.
After some discussion, while this is possible it's not something we are prioritising at the moment. Having a little pain involved in sharing types as contracts will nudge people in the direction we want, which is to declare their own type (share contracts not types). |
A draft to start discussion around removing the JS dependency on domain messages.
I think the api would be more intuitive if we go down the route of message envelopes. You get to keep your domain message clean, and still feel like the apis are steering you with types, where as with an unbounded
TMessage
if feels like you could easily put any parameter in there and not find out till runtime you messed up.