snapshot of https://github.com/ThreeDotsLabs/watermill.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
master / ci (push) Failing after 0s
Some checks failed
master / ci (push) Failing after 0s
This commit is contained in:
168
components/cqrs/command_bus.go
Normal file
168
components/cqrs/command_bus.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type CommandBusConfig struct {
|
||||
// GeneratePublishTopic is used to generate topic for publishing command.
|
||||
GeneratePublishTopic CommandBusGeneratePublishTopicFn
|
||||
|
||||
// OnSend is called before publishing the command.
|
||||
// The *message.Message can be modified.
|
||||
//
|
||||
// This option is not required.
|
||||
OnSend CommandBusOnSendFn
|
||||
|
||||
// Marshaler is used to marshal and unmarshal commands.
|
||||
// It is required.
|
||||
Marshaler CommandEventMarshaler
|
||||
|
||||
// Logger instance used to log.
|
||||
// If not provided, watermill.NopLogger is used.
|
||||
Logger watermill.LoggerAdapter
|
||||
}
|
||||
|
||||
func (c *CommandBusConfig) setDefaults() {
|
||||
if c.Logger == nil {
|
||||
c.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c CommandBusConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Marshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing Marshaler"))
|
||||
}
|
||||
|
||||
if c.GeneratePublishTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing GeneratePublishTopic"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type CommandBusGeneratePublishTopicFn func(CommandBusGeneratePublishTopicParams) (string, error)
|
||||
|
||||
type CommandBusGeneratePublishTopicParams struct {
|
||||
CommandName string
|
||||
Command any
|
||||
}
|
||||
|
||||
type CommandBusOnSendFn func(params CommandBusOnSendParams) error
|
||||
|
||||
type CommandBusOnSendParams struct {
|
||||
CommandName string
|
||||
Command any
|
||||
|
||||
// Message is never nil and can be modified.
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// CommandBus transports commands to command handlers.
|
||||
type CommandBus struct {
|
||||
publisher message.Publisher
|
||||
|
||||
config CommandBusConfig
|
||||
}
|
||||
|
||||
// NewCommandBusWithConfig creates a new CommandBus.
|
||||
func NewCommandBusWithConfig(publisher message.Publisher, config CommandBusConfig) (*CommandBus, error) {
|
||||
if publisher == nil {
|
||||
return nil, errors.New("missing publisher")
|
||||
}
|
||||
|
||||
config.setDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config")
|
||||
}
|
||||
|
||||
return &CommandBus{publisher, config}, nil
|
||||
}
|
||||
|
||||
// NewCommandBus creates a new CommandBus.
|
||||
// Deprecated: use NewCommandBusWithConfig instead.
|
||||
func NewCommandBus(
|
||||
publisher message.Publisher,
|
||||
generateTopic func(commandName string) string,
|
||||
marshaler CommandEventMarshaler,
|
||||
) (*CommandBus, error) {
|
||||
if publisher == nil {
|
||||
return nil, errors.New("missing publisher")
|
||||
}
|
||||
if generateTopic == nil {
|
||||
return nil, errors.New("missing generateTopic")
|
||||
}
|
||||
if marshaler == nil {
|
||||
return nil, errors.New("missing marshaler")
|
||||
}
|
||||
|
||||
return &CommandBus{publisher, CommandBusConfig{
|
||||
GeneratePublishTopic: func(params CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return generateTopic(params.CommandName), nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// Send sends command to the command bus.
|
||||
func (c CommandBus) Send(ctx context.Context, cmd any) error {
|
||||
return c.SendWithModifiedMessage(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func (c CommandBus) SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error {
|
||||
msg, topicName, err := c.newMessage(ctx, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if modify != nil {
|
||||
if err := modify(msg); err != nil {
|
||||
return errors.Wrap(err, "cannot modify message")
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.publisher.Publish(topicName, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c CommandBus) newMessage(ctx context.Context, command any) (*message.Message, string, error) {
|
||||
msg, err := c.config.Marshaler.Marshal(command)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
commandName := c.config.Marshaler.Name(command)
|
||||
topicName, err := c.config.GeneratePublishTopic(CommandBusGeneratePublishTopicParams{
|
||||
CommandName: commandName,
|
||||
Command: command,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", errors.Wrap(err, "cannot generate topic name")
|
||||
}
|
||||
|
||||
msg.SetContext(ctx)
|
||||
|
||||
if c.config.OnSend != nil {
|
||||
err := c.config.OnSend(CommandBusOnSendParams{
|
||||
CommandName: commandName,
|
||||
Command: command,
|
||||
Message: msg,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", errors.Wrap(err, "cannot execute OnSend")
|
||||
}
|
||||
}
|
||||
|
||||
return msg, topicName, nil
|
||||
}
|
||||
224
components/cqrs/command_bus_test.go
Normal file
224
components/cqrs/command_bus_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
)
|
||||
|
||||
func TestCommandBusConfig_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ModifyValidConfig func(*cqrs.CommandBusConfig)
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "valid_config",
|
||||
ModifyValidConfig: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing_Marshaler",
|
||||
ModifyValidConfig: func(c *cqrs.CommandBusConfig) {
|
||||
c.Marshaler = nil
|
||||
},
|
||||
ExpectedErr: errors.Errorf("missing Marshaler"),
|
||||
},
|
||||
{
|
||||
Name: "missing_GeneratePublishTopic",
|
||||
ModifyValidConfig: func(c *cqrs.CommandBusConfig) {
|
||||
c.GeneratePublishTopic = nil
|
||||
},
|
||||
ExpectedErr: errors.Errorf("missing GeneratePublishTopic"),
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
validConfig := cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
if tc.ModifyValidConfig != nil {
|
||||
tc.ModifyValidConfig(&validConfig)
|
||||
}
|
||||
|
||||
err := validConfig.Validate()
|
||||
if tc.ExpectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.ExpectedErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandBus(t *testing.T) {
|
||||
pub := newPublisherStub()
|
||||
|
||||
config := cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
require.NoError(t, config.Validate())
|
||||
|
||||
cb, err := cqrs.NewCommandBusWithConfig(pub, config)
|
||||
assert.NotNil(t, cb)
|
||||
assert.NoError(t, err)
|
||||
|
||||
config.GeneratePublishTopic = nil
|
||||
require.Error(t, config.Validate())
|
||||
|
||||
cb, err = cqrs.NewCommandBusWithConfig(pub, config)
|
||||
assert.Nil(t, cb)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
func TestCommandBus_Send_ContextPropagation(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
commandBus, err := cqrs.NewCommandBusWithConfig(
|
||||
publisher,
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
|
||||
|
||||
err = commandBus.Send(ctx, "message")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ctx, publisher.messages["whatever"][0].Context())
|
||||
}
|
||||
|
||||
func TestCommandBus_Send_topic_name(t *testing.T) {
|
||||
cb, err := cqrs.NewCommandBusWithConfig(
|
||||
assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestCommand", T: t},
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return params.CommandName, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cb.Send(context.Background(), TestCommand{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCommandBus_Send_OnSend(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
cb, err := cqrs.NewCommandBusWithConfig(
|
||||
publisher,
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
OnSend: func(params cqrs.CommandBusOnSendParams) error {
|
||||
params.Message.Metadata.Set("key", "value")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cb.Send(context.Background(), TestCommand{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
|
||||
}
|
||||
|
||||
func TestCommandBus_SendWithModifiedMessage(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
cb, err := cqrs.NewCommandBusWithConfig(
|
||||
publisher,
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cb.SendWithModifiedMessage(context.Background(), TestCommand{}, func(message *message.Message) error {
|
||||
message.Metadata.Set("key", "value")
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
|
||||
}
|
||||
|
||||
func TestCommandBus_SendWithModifiedMessage_modify_error(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
cb, err := cqrs.NewCommandBusWithConfig(
|
||||
publisher,
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
err = cb.SendWithModifiedMessage(
|
||||
context.Background(),
|
||||
TestCommand{},
|
||||
func(message *message.Message) error {
|
||||
return expectedErr
|
||||
},
|
||||
)
|
||||
assert.ErrorContains(t, err, expectedErr.Error())
|
||||
}
|
||||
|
||||
func TestCommandBus_Send_OnSend_error(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
cb, err := cqrs.NewCommandBusWithConfig(
|
||||
publisher,
|
||||
cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
OnSend: func(params cqrs.CommandBusOnSendParams) error {
|
||||
return expectedErr
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cb.Send(context.Background(), TestCommand{})
|
||||
require.EqualError(t, err, "cannot execute OnSend: some error")
|
||||
}
|
||||
59
components/cqrs/command_handler.go
Normal file
59
components/cqrs/command_handler.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// CommandHandler receives a command defined by NewCommand and handles it with the Handle method.
|
||||
// If using DDD, CommandHandler may modify and persist the aggregate.
|
||||
//
|
||||
// In contrast to EventHandler, every Command must have only one CommandHandler.
|
||||
//
|
||||
// One instance of CommandHandler is used during handling messages.
|
||||
// When multiple commands are delivered at the same time, Handle method can be executed multiple times at the same time.
|
||||
// Because of that, Handle method needs to be thread safe!
|
||||
type CommandHandler interface {
|
||||
// HandlerName is the name used in message.Router while creating handler.
|
||||
//
|
||||
// It will be also passed to CommandsSubscriberConstructor.
|
||||
// May be useful, for example, to create a consumer group per each handler.
|
||||
//
|
||||
// WARNING: If HandlerName was changed and is used for generating consumer groups,
|
||||
// it may result with **reconsuming all messages**!
|
||||
HandlerName() string
|
||||
|
||||
NewCommand() any
|
||||
|
||||
Handle(ctx context.Context, cmd any) error
|
||||
}
|
||||
|
||||
type genericCommandHandler[Command any] struct {
|
||||
handleFunc func(ctx context.Context, cmd *Command) error
|
||||
handlerName string
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new CommandHandler implementation based on provided function
|
||||
// and command type inferred from function argument.
|
||||
func NewCommandHandler[Command any](
|
||||
handlerName string,
|
||||
handleFunc func(ctx context.Context, cmd *Command) error,
|
||||
) CommandHandler {
|
||||
return &genericCommandHandler[Command]{
|
||||
handleFunc: handleFunc,
|
||||
handlerName: handlerName,
|
||||
}
|
||||
}
|
||||
|
||||
func (c genericCommandHandler[Command]) HandlerName() string {
|
||||
return c.handlerName
|
||||
}
|
||||
|
||||
func (c genericCommandHandler[Command]) NewCommand() any {
|
||||
tVar := new(Command)
|
||||
return tVar
|
||||
}
|
||||
|
||||
func (c genericCommandHandler[Command]) Handle(ctx context.Context, cmd any) error {
|
||||
command := cmd.(*Command)
|
||||
return c.handleFunc(ctx, command)
|
||||
}
|
||||
32
components/cqrs/command_handler_test.go
Normal file
32
components/cqrs/command_handler_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type SomeCommand struct {
|
||||
Foo string
|
||||
}
|
||||
|
||||
func TestNewCommandHandler(t *testing.T) {
|
||||
cmdToSend := &SomeCommand{"bar"}
|
||||
|
||||
ch := cqrs.NewCommandHandler(
|
||||
"some_handler",
|
||||
func(ctx context.Context, cmd *SomeCommand) error {
|
||||
assert.Equal(t, cmdToSend, cmd)
|
||||
return fmt.Errorf("some error")
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, "some_handler", ch.HandlerName())
|
||||
assert.Equal(t, &SomeCommand{}, ch.NewCommand())
|
||||
|
||||
err := ch.Handle(context.Background(), cmdToSend)
|
||||
assert.EqualError(t, err, "some error")
|
||||
}
|
||||
382
components/cqrs/command_processor.go
Normal file
382
components/cqrs/command_processor.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type CommandProcessorConfig struct {
|
||||
// GenerateSubscribeTopic is used to generate topic for subscribing command.
|
||||
GenerateSubscribeTopic CommandProcessorGenerateSubscribeTopicFn
|
||||
|
||||
// SubscriberConstructor is used to create subscriber for CommandHandler.
|
||||
SubscriberConstructor CommandProcessorSubscriberConstructorFn
|
||||
|
||||
// OnHandle is called before handling command.
|
||||
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling a command.
|
||||
//
|
||||
// Because of that, you need to explicitly call params.Handler.Handle() to handle the command.
|
||||
// func(params CommandProcessorOnHandleParams) (err error) {
|
||||
// // logic before handle
|
||||
// // (...)
|
||||
//
|
||||
// err := params.Handler.Handle(params.Message.Context(), params.Command)
|
||||
//
|
||||
// // logic after handle
|
||||
// // (...)
|
||||
//
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// This option is not required.
|
||||
OnHandle CommandProcessorOnHandleFn
|
||||
|
||||
// Marshaler is used to marshal and unmarshal commands.
|
||||
// It is required.
|
||||
Marshaler CommandEventMarshaler
|
||||
|
||||
// Logger instance used to log.
|
||||
// If not provided, watermill.NopLogger is used.
|
||||
Logger watermill.LoggerAdapter
|
||||
|
||||
// If true, CommandProcessor will ack messages even if CommandHandler returns an error.
|
||||
// If RequestReplyBackend is not null and sending reply fails, the message will be nack-ed anyway.
|
||||
//
|
||||
// Warning: It's not recommended to use this option when you are using requestreply component
|
||||
// (requestreply.NewCommandHandler or requestreply.NewCommandHandlerWithResult), as it may ack the
|
||||
// command when sending reply failed.
|
||||
//
|
||||
// When you are using requestreply, you should use requestreply.PubSubBackendConfig.AckCommandErrors.
|
||||
AckCommandHandlingErrors bool
|
||||
|
||||
// disableRouterAutoAddHandlers is used to keep backwards compatibility.
|
||||
// it is set when CommandProcessor is created by NewCommandProcessor.
|
||||
// Deprecated: please migrate to NewCommandProcessorWithConfig.
|
||||
disableRouterAutoAddHandlers bool
|
||||
}
|
||||
|
||||
func (c *CommandProcessorConfig) setDefaults() {
|
||||
if c.Logger == nil {
|
||||
c.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c CommandProcessorConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Marshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing Marshaler"))
|
||||
}
|
||||
|
||||
if c.GenerateSubscribeTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing GenerateSubscribeTopic"))
|
||||
}
|
||||
if c.SubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type CommandProcessorGenerateSubscribeTopicFn func(CommandProcessorGenerateSubscribeTopicParams) (string, error)
|
||||
|
||||
type CommandProcessorGenerateSubscribeTopicParams struct {
|
||||
CommandName string
|
||||
CommandHandler CommandHandler
|
||||
}
|
||||
|
||||
// CommandProcessorSubscriberConstructorFn creates subscriber for CommandHandler.
|
||||
// It allows you to create a separate customized Subscriber for every command handler.
|
||||
type CommandProcessorSubscriberConstructorFn func(CommandProcessorSubscriberConstructorParams) (message.Subscriber, error)
|
||||
|
||||
type CommandProcessorSubscriberConstructorParams struct {
|
||||
CommandName string
|
||||
HandlerName string
|
||||
Handler CommandHandler
|
||||
}
|
||||
|
||||
type CommandProcessorOnHandleFn func(params CommandProcessorOnHandleParams) error
|
||||
|
||||
type CommandProcessorOnHandleParams struct {
|
||||
Handler CommandHandler
|
||||
|
||||
CommandName string
|
||||
Command any
|
||||
|
||||
// Message is never nil and can be modified.
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// CommandProcessor determines which CommandHandler should handle the command received from the command bus.
|
||||
type CommandProcessor struct {
|
||||
router *message.Router
|
||||
|
||||
handlers []CommandHandler
|
||||
|
||||
config CommandProcessorConfig
|
||||
}
|
||||
|
||||
func NewCommandProcessorWithConfig(router *message.Router, config CommandProcessorConfig) (*CommandProcessor, error) {
|
||||
config.setDefaults()
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if router == nil && !config.disableRouterAutoAddHandlers {
|
||||
return nil, errors.New("missing router")
|
||||
}
|
||||
|
||||
return &CommandProcessor{
|
||||
router: router,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCommandProcessor creates a new CommandProcessor.
|
||||
// Deprecated. Use NewCommandProcessorWithConfig instead.
|
||||
func NewCommandProcessor(
|
||||
handlers []CommandHandler,
|
||||
generateTopic func(commandName string) string,
|
||||
subscriberConstructor CommandsSubscriberConstructor,
|
||||
marshaler CommandEventMarshaler,
|
||||
logger watermill.LoggerAdapter,
|
||||
) (*CommandProcessor, error) {
|
||||
if len(handlers) == 0 {
|
||||
return nil, errors.New("missing handlers")
|
||||
}
|
||||
if generateTopic == nil {
|
||||
return nil, errors.New("missing generateTopic")
|
||||
}
|
||||
if subscriberConstructor == nil {
|
||||
return nil, errors.New("missing subscriberConstructor")
|
||||
}
|
||||
|
||||
cp, err := NewCommandProcessorWithConfig(
|
||||
nil,
|
||||
CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return generateTopic(params.CommandName), nil
|
||||
},
|
||||
SubscriberConstructor: func(params CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return subscriberConstructor(params.HandlerName)
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
disableRouterAutoAddHandlers: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
if err := cp.AddHandlers(handler); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// CommandsSubscriberConstructor creates subscriber for CommandHandler.
|
||||
// It allows you to create a separate customized Subscriber for every command handler.
|
||||
//
|
||||
// Deprecated: please use CommandProcessorSubscriberConstructorFn instead.
|
||||
type CommandsSubscriberConstructor func(handlerName string) (message.Subscriber, error)
|
||||
|
||||
// AddHandlers adds a new CommandHandler to the CommandProcessor and adds it to the router.
|
||||
func (p *CommandProcessor) AddHandlers(handlers ...CommandHandler) error {
|
||||
handledCommands := map[string]struct{}{}
|
||||
for _, handler := range handlers {
|
||||
commandName := p.config.Marshaler.Name(handler.NewCommand())
|
||||
if _, ok := handledCommands[commandName]; ok {
|
||||
return DuplicateCommandHandlerError{commandName}
|
||||
}
|
||||
|
||||
handledCommands[commandName] = struct{}{}
|
||||
}
|
||||
|
||||
if p.config.disableRouterAutoAddHandlers {
|
||||
p.handlers = append(p.handlers, handlers...)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
if _, err := p.addHandlerToRouter(p.router, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.handlers = append(p.handlers, handler)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddHandler adds a new CommandHandler to the CommandProcessor and adds it to the router.
|
||||
func (p *CommandProcessor) AddHandler(handler CommandHandler) (*message.Handler, error) {
|
||||
if p.config.disableRouterAutoAddHandlers {
|
||||
p.handlers = append(p.handlers, handler)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
h, err := p.addHandlerToRouter(p.router, handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.handlers = append(p.handlers, handler)
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// DuplicateCommandHandlerError occurs when a handler with the same name already exists.
|
||||
type DuplicateCommandHandlerError struct {
|
||||
CommandName string
|
||||
}
|
||||
|
||||
func (d DuplicateCommandHandlerError) Error() string {
|
||||
return fmt.Sprintf("command handler for command %s already exists", d.CommandName)
|
||||
}
|
||||
|
||||
// AddHandlersToRouter adds the CommandProcessor's handlers to the given router.
|
||||
// It should be called only once per CommandProcessor instance.
|
||||
//
|
||||
// It is required to call AddHandlersToRouter only if command processor is created with NewCommandProcessor (disableRouterAutoAddHandlers is set to true).
|
||||
// Deprecated: please migrate to command processor created by NewCommandProcessorWithConfig.
|
||||
func (p CommandProcessor) AddHandlersToRouter(r *message.Router) error {
|
||||
if !p.config.disableRouterAutoAddHandlers {
|
||||
return errors.New("AddHandlersToRouter should be called only when using deprecated NewCommandProcessor")
|
||||
}
|
||||
|
||||
for i := range p.Handlers() {
|
||||
handler := p.handlers[i]
|
||||
|
||||
if _, err := p.addHandlerToRouter(r, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p CommandProcessor) addHandlerToRouter(r *message.Router, handler CommandHandler) (*message.Handler, error) {
|
||||
handlerName := handler.HandlerName()
|
||||
commandName := p.config.Marshaler.Name(handler.NewCommand())
|
||||
|
||||
topicName, err := p.config.GenerateSubscribeTopic(CommandProcessorGenerateSubscribeTopicParams{
|
||||
CommandName: commandName,
|
||||
CommandHandler: handler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "cannot generate topic for command handler %s", handlerName)
|
||||
}
|
||||
|
||||
logger := p.config.Logger.With(watermill.LogFields{
|
||||
"command_handler_name": handlerName,
|
||||
"topic": topicName,
|
||||
})
|
||||
|
||||
handlerFunc, err := p.routerHandlerFunc(handler, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("Adding CQRS command handler to router", nil)
|
||||
|
||||
subscriber, err := p.config.SubscriberConstructor(CommandProcessorSubscriberConstructorParams{
|
||||
CommandName: commandName,
|
||||
HandlerName: handlerName,
|
||||
Handler: handler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create subscriber for command processor")
|
||||
}
|
||||
|
||||
return r.AddConsumerHandler(
|
||||
handlerName,
|
||||
topicName,
|
||||
subscriber,
|
||||
handlerFunc,
|
||||
), nil
|
||||
}
|
||||
|
||||
// Handlers returns the CommandProcessor's handlers.
|
||||
func (p CommandProcessor) Handlers() []CommandHandler {
|
||||
return p.handlers
|
||||
}
|
||||
|
||||
func (p CommandProcessor) routerHandlerFunc(handler CommandHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
|
||||
cmd := handler.NewCommand()
|
||||
cmdName := p.config.Marshaler.Name(cmd)
|
||||
|
||||
if err := p.validateCommand(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func(msg *message.Message) error {
|
||||
cmd := handler.NewCommand()
|
||||
messageCmdName := p.config.Marshaler.NameFromMessage(msg)
|
||||
|
||||
if messageCmdName != cmdName {
|
||||
logger.Trace("Received different command type than expected, ignoring", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"expected_command_type": cmdName,
|
||||
"received_command_type": messageCmdName,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("Handling command", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"received_command_type": messageCmdName,
|
||||
})
|
||||
|
||||
ctx := CtxWithOriginalMessage(msg.Context(), msg)
|
||||
msg.SetContext(ctx)
|
||||
|
||||
if err := p.config.Marshaler.Unmarshal(msg, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := func(params CommandProcessorOnHandleParams) (err error) {
|
||||
return params.Handler.Handle(ctx, params.Command)
|
||||
}
|
||||
if p.config.OnHandle != nil {
|
||||
handle = p.config.OnHandle
|
||||
}
|
||||
|
||||
err := handle(CommandProcessorOnHandleParams{
|
||||
Handler: handler,
|
||||
CommandName: messageCmdName,
|
||||
Command: cmd,
|
||||
Message: msg,
|
||||
})
|
||||
|
||||
if p.config.AckCommandHandlingErrors && err != nil {
|
||||
logger.Error("Error when handling command, acking (AckCommandHandlingErrors is enabled)", err, nil)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
logger.Debug("Error when handling command, nacking", watermill.LogFields{"err": err})
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p CommandProcessor) validateCommand(cmd interface{}) error {
|
||||
// CommandHandler's NewCommand must return a pointer, because it is used to unmarshal
|
||||
if err := isPointer(cmd); err != nil {
|
||||
return errors.Wrap(err, "command must be a non-nil pointer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
522
components/cqrs/command_processor_test.go
Normal file
522
components/cqrs/command_processor_test.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommandProcessorConfig_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ModifyValidConfig func(*cqrs.CommandProcessorConfig)
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "valid_config",
|
||||
ModifyValidConfig: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing_Marshaler",
|
||||
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
|
||||
c.Marshaler = nil
|
||||
},
|
||||
ExpectedErr: errors.Errorf("missing Marshaler"),
|
||||
},
|
||||
{
|
||||
Name: "missing_SubscriberConstructor",
|
||||
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
|
||||
c.SubscriberConstructor = nil
|
||||
},
|
||||
ExpectedErr: errors.Errorf("missing SubscriberConstructor"),
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateHandlerSubscribeTopic",
|
||||
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
|
||||
c.GenerateSubscribeTopic = nil
|
||||
},
|
||||
ExpectedErr: errors.Errorf("missing GenerateSubscribeTopic"),
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
validConfig := cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
if tc.ModifyValidConfig != nil {
|
||||
tc.ModifyValidConfig(&validConfig)
|
||||
}
|
||||
|
||||
err := validConfig.Validate()
|
||||
if tc.ExpectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.ExpectedErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandProcessor(t *testing.T) {
|
||||
config := cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
require.NoError(t, config.Validate())
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, watermill.NewStdLogger(false, false))
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewCommandProcessorWithConfig(router, config)
|
||||
assert.NotNil(t, cp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
config.SubscriberConstructor = nil
|
||||
require.Error(t, config.Validate())
|
||||
|
||||
cp, err = cqrs.NewCommandProcessorWithConfig(router, config)
|
||||
assert.Nil(t, cp)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
type nonPointerCommandHandler struct {
|
||||
}
|
||||
|
||||
func (nonPointerCommandHandler) HandlerName() string {
|
||||
return "nonPointerCommandHandler"
|
||||
}
|
||||
|
||||
func (nonPointerCommandHandler) NewCommand() interface{} {
|
||||
return TestCommand{}
|
||||
}
|
||||
|
||||
func (nonPointerCommandHandler) Handle(ctx context.Context, cmd interface{}) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func TestCommandProcessor_non_pointer_command(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
handler := nonPointerCommandHandler{}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = commandProcessor.AddHandlers(handler)
|
||||
assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err))
|
||||
}
|
||||
|
||||
// TestCommandProcessor_multiple_same_command_handlers checks, that we don't register multiple handlers for the same command.
|
||||
func TestCommandProcessor_multiple_same_command_handlers(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = commandProcessor.AddHandlers(
|
||||
&CaptureCommandHandler{},
|
||||
&CaptureCommandHandler{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.EqualValues(t, cqrs.DuplicateCommandHandlerError{CommandName: "cqrs_test.TestCommand"}, err)
|
||||
assert.Equal(t, "command handler for command cqrs_test.TestCommand already exists", err.Error())
|
||||
}
|
||||
|
||||
type mockSubscriber struct {
|
||||
MessagesToSend []*message.Message
|
||||
WaitForAckBeforeSendingNext bool
|
||||
|
||||
out chan *message.Message
|
||||
}
|
||||
|
||||
func (m *mockSubscriber) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) {
|
||||
m.out = make(chan *message.Message)
|
||||
|
||||
go func() {
|
||||
for _, msg := range m.MessagesToSend {
|
||||
m.out <- msg
|
||||
|
||||
if m.WaitForAckBeforeSendingNext {
|
||||
<-msg.Acked()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return m.out, nil
|
||||
}
|
||||
|
||||
func (m mockSubscriber) Close() error {
|
||||
close(m.out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCommandProcessor_AckCommandHandlingErrors_option_true(t *testing.T) {
|
||||
logger := watermill.NewCaptureLogger()
|
||||
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msgToSend,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
AckCommandHandlingErrors: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedErr := errors.New("test error")
|
||||
|
||||
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
|
||||
"handler", func(ctx context.Context, cmd *TestCommand) error {
|
||||
return expectedErr
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msgToSend.Acked():
|
||||
// ok
|
||||
case <-msgToSend.Nacked():
|
||||
// nack received
|
||||
t.Fatal("nack received, message should be acked")
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for ack")
|
||||
}
|
||||
|
||||
// it's pretty important to not ack message silently, so let's assert if it's logged properly
|
||||
expectedLogMessage := watermill.CapturedMessage{
|
||||
Level: watermill.ErrorLogLevel,
|
||||
Fields: map[string]any{
|
||||
"command_handler_name": "handler",
|
||||
"topic": "commands",
|
||||
},
|
||||
Msg: "Error when handling command, acking (AckCommandHandlingErrors is enabled)",
|
||||
Err: expectedErr,
|
||||
}
|
||||
assert.True(
|
||||
t,
|
||||
logger.Has(expectedLogMessage),
|
||||
"expected log message not found, logs: %#v",
|
||||
logger.Captured(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestCommandProcessor_AckCommandHandlingErrors_option_false(t *testing.T) {
|
||||
logger := watermill.NewCaptureLogger()
|
||||
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msgToSend,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
AckCommandHandlingErrors: false,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedErr := errors.New("test error")
|
||||
|
||||
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
|
||||
"handler", func(ctx context.Context, cmd *TestCommand) error {
|
||||
return expectedErr
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msgToSend.Acked():
|
||||
// nack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
case <-msgToSend.Nacked():
|
||||
// ok
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for ack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandProcessor_OnHandle(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg1, err := ts.Marshaler.Marshal(&TestCommand{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg2, err := ts.Marshaler.Marshal(&TestCommand{ID: "2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg1,
|
||||
msg2,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
handlerCalled := 0
|
||||
|
||||
defer func() {
|
||||
// for msg 1 we are not calling handler - but returning before
|
||||
assert.Equal(t, 1, handlerCalled)
|
||||
}()
|
||||
|
||||
handler := cqrs.NewCommandHandler("test", func(ctx context.Context, cmd *TestCommand) error {
|
||||
handlerCalled++
|
||||
return nil
|
||||
})
|
||||
|
||||
onHandleCalled := int64(0)
|
||||
|
||||
config := cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
OnHandle: func(params cqrs.CommandProcessorOnHandleParams) error {
|
||||
atomic.AddInt64(&onHandleCalled, 1)
|
||||
|
||||
assert.IsType(t, &TestCommand{}, params.Command)
|
||||
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
|
||||
assert.Equal(t, handler, params.Handler)
|
||||
|
||||
if params.Command.(*TestCommand).ID == "1" {
|
||||
assert.Equal(t, msg1, params.Message)
|
||||
return errors.New("test error")
|
||||
} else {
|
||||
assert.Equal(t, msg2, params.Message)
|
||||
}
|
||||
|
||||
return params.Handler.Handle(params.Message.Context(), params.Command)
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
}
|
||||
cp, err := cqrs.NewCommandProcessorWithConfig(router, config)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlers(handler)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg1.Nacked():
|
||||
// ok
|
||||
case <-msg1.Acked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-msg2.Acked():
|
||||
// ok
|
||||
case <-msg2.Nacked():
|
||||
// nack received
|
||||
}
|
||||
|
||||
assert.EqualValues(t, 2, onHandleCalled)
|
||||
}
|
||||
|
||||
func TestCommandProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return ts.CommandsPubSub, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersToRouter(router)
|
||||
assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewCommandProcessor")
|
||||
}
|
||||
|
||||
func TestCommandProcessor_original_msg_set_to_ctx(t *testing.T) {
|
||||
logger := watermill.NewCaptureLogger()
|
||||
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msgToSend,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
AckCommandHandlingErrors: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var msgFromCtx *message.Message
|
||||
|
||||
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
|
||||
"handler", func(ctx context.Context, cmd *TestCommand) error {
|
||||
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msgToSend.Acked():
|
||||
// ok
|
||||
case <-msgToSend.Nacked():
|
||||
// nack received
|
||||
t.Fatal("nack received, message should be acked")
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for ack")
|
||||
}
|
||||
|
||||
require.NotNil(t, msgFromCtx)
|
||||
assert.Equal(t, msgToSend, msgFromCtx)
|
||||
}
|
||||
199
components/cqrs/cqrs.go
Normal file
199
components/cqrs/cqrs.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Deprecated: use CommandProcessor and EventProcessor instead.
|
||||
type FacadeConfig struct {
|
||||
// GenerateCommandsTopic generates topic name based on the command name.
|
||||
// Command name is generated by CommandEventMarshaler's Name method.
|
||||
//
|
||||
// It allows you to use topic per command or one topic for every command.
|
||||
GenerateCommandsTopic func(commandName string) string
|
||||
|
||||
// CommandHandlers return command handlers which should be executed.
|
||||
CommandHandlers func(commandBus *CommandBus, eventBus *EventBus) []CommandHandler
|
||||
|
||||
// CommandsPublisher is Publisher used to publish commands.
|
||||
CommandsPublisher message.Publisher
|
||||
|
||||
// CommandsSubscriberConstructor is constructor for subscribers which will subscribe for messages.
|
||||
// It will be called for every command handler.
|
||||
// It allows you to create separated customized Subscriber for every command handler.
|
||||
CommandsSubscriberConstructor CommandsSubscriberConstructor
|
||||
|
||||
// GenerateEventsTopic generates topic name based on the event name.
|
||||
// Event name is generated by CommandEventMarshaler's Name method.
|
||||
//
|
||||
// It allows you to use topic per command or one topic for every command.
|
||||
GenerateEventsTopic func(eventName string) string
|
||||
|
||||
// EventHandlers return event handlers which should be executed.
|
||||
EventHandlers func(commandBus *CommandBus, eventBus *EventBus) []EventHandler
|
||||
|
||||
// EventsPublisher is Publisher used to publish commands.
|
||||
EventsPublisher message.Publisher
|
||||
|
||||
// EventsSubscriberConstructor is constructor for subscribers which will subscribe for messages.
|
||||
// It will be called for every event handler.
|
||||
// It allows you to create separated customized Subscriber for every event handler.
|
||||
EventsSubscriberConstructor EventsSubscriberConstructor
|
||||
|
||||
// Router is a Watermill router, which will be used to handle events and commands.
|
||||
// Router handlers will be automatically generated by AddHandlersToRouter of Command and Event handlers.
|
||||
Router *message.Router
|
||||
|
||||
CommandEventMarshaler CommandEventMarshaler
|
||||
|
||||
Logger watermill.LoggerAdapter
|
||||
}
|
||||
|
||||
func (c FacadeConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.CommandsEnabled() {
|
||||
if c.GenerateCommandsTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("GenerateCommandsTopic is nil"))
|
||||
}
|
||||
if c.CommandsSubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("CommandsSubscriberConstructor is nil"))
|
||||
}
|
||||
if c.CommandsPublisher == nil {
|
||||
err = stdErrors.Join(err, errors.New("CommandsPublisher is nil"))
|
||||
}
|
||||
}
|
||||
if c.EventsEnabled() {
|
||||
if c.GenerateEventsTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("GenerateEventsTopic is nil"))
|
||||
}
|
||||
if c.EventsSubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("EventsSubscriberConstructor is nil"))
|
||||
}
|
||||
if c.EventsPublisher == nil {
|
||||
err = stdErrors.Join(err, errors.New("EventsPublisher is nil"))
|
||||
}
|
||||
}
|
||||
|
||||
if c.Router == nil {
|
||||
err = stdErrors.Join(err, errors.New("Router is nil"))
|
||||
}
|
||||
if c.Logger == nil {
|
||||
err = stdErrors.Join(err, errors.New("Logger is nil"))
|
||||
}
|
||||
if c.CommandEventMarshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("CommandEventMarshaler is nil"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c FacadeConfig) EventsEnabled() bool {
|
||||
return c.GenerateEventsTopic != nil || c.EventsPublisher != nil || c.EventsSubscriberConstructor != nil
|
||||
}
|
||||
|
||||
func (c FacadeConfig) CommandsEnabled() bool {
|
||||
return c.GenerateCommandsTopic != nil || c.CommandsPublisher != nil || c.CommandsSubscriberConstructor != nil
|
||||
}
|
||||
|
||||
// Deprecated: use CommandHandler and EventHandler instead.
|
||||
//
|
||||
// Facade is a facade for creating the Command and Event buses and processors.
|
||||
// It was created to avoid boilerplate, when using CQRS in the standard way.
|
||||
// You can also create buses and processors manually, drawing inspiration from how it's done in NewFacade.
|
||||
type Facade struct {
|
||||
commandsTopic func(commandName string) string
|
||||
commandBus *CommandBus
|
||||
|
||||
eventsTopic func(eventName string) string
|
||||
eventBus *EventBus
|
||||
|
||||
commandEventMarshaler CommandEventMarshaler
|
||||
}
|
||||
|
||||
func (f Facade) CommandBus() *CommandBus {
|
||||
return f.commandBus
|
||||
}
|
||||
|
||||
func (f Facade) EventBus() *EventBus {
|
||||
return f.eventBus
|
||||
}
|
||||
|
||||
func (f Facade) CommandEventMarshaler() CommandEventMarshaler {
|
||||
return f.commandEventMarshaler
|
||||
}
|
||||
|
||||
// Deprecated: use CommandHandler and EventHandler instead.
|
||||
func NewFacade(config FacadeConfig) (*Facade, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config")
|
||||
}
|
||||
|
||||
c := &Facade{
|
||||
commandsTopic: config.GenerateCommandsTopic,
|
||||
eventsTopic: config.GenerateEventsTopic,
|
||||
commandEventMarshaler: config.CommandEventMarshaler,
|
||||
}
|
||||
|
||||
if config.CommandsEnabled() {
|
||||
var err error
|
||||
c.commandBus, err = NewCommandBus(
|
||||
config.CommandsPublisher,
|
||||
config.GenerateCommandsTopic,
|
||||
config.CommandEventMarshaler,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create command bus")
|
||||
}
|
||||
} else {
|
||||
config.Logger.Info("Empty GenerateCommandsTopic, command bus will be not created", nil)
|
||||
}
|
||||
if config.EventsEnabled() {
|
||||
var err error
|
||||
c.eventBus, err = NewEventBus(config.EventsPublisher, config.GenerateEventsTopic, config.CommandEventMarshaler)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create event bus")
|
||||
}
|
||||
} else {
|
||||
config.Logger.Info("Empty GenerateEventsTopic, event bus will be not created", nil)
|
||||
}
|
||||
|
||||
if config.CommandHandlers != nil {
|
||||
commandProcessor, err := NewCommandProcessor(
|
||||
config.CommandHandlers(c.commandBus, c.eventBus),
|
||||
config.GenerateCommandsTopic,
|
||||
config.CommandsSubscriberConstructor,
|
||||
config.CommandEventMarshaler,
|
||||
config.Logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create command processor")
|
||||
}
|
||||
|
||||
if err := commandProcessor.AddHandlersToRouter(config.Router); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if config.EventHandlers != nil {
|
||||
eventProcessor, err := NewEventProcessor(
|
||||
config.EventHandlers(c.commandBus, c.eventBus),
|
||||
config.GenerateEventsTopic,
|
||||
config.EventsSubscriberConstructor,
|
||||
config.CommandEventMarshaler,
|
||||
config.Logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create event processor")
|
||||
}
|
||||
|
||||
if err := eventProcessor.AddHandlersToRouter(config.Router); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
490
components/cqrs/cqrs_test.go
Normal file
490
components/cqrs/cqrs_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
)
|
||||
|
||||
// TestCQRS is functional test of CQRS command handler and event handler.
|
||||
func TestCQRS(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
CreateCqrs func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus)
|
||||
}{
|
||||
{
|
||||
// facade is deprecated, testing backwards compatibility
|
||||
Name: "facade",
|
||||
CreateCqrs: func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) {
|
||||
router, cqrsFacade := createRouterAndFacade(t, cc, ce)
|
||||
return router, cqrsFacade.CommandBus(), cqrsFacade.EventBus()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "constructors",
|
||||
CreateCqrs: createCqrsComponents,
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
captureCommandHandler := &CaptureCommandHandler{}
|
||||
captureEventHandler := &CaptureEventHandler{}
|
||||
|
||||
router, commandBus, eventBus := tc.CreateCqrs(t, captureCommandHandler, captureEventHandler)
|
||||
|
||||
pointerCmd := &TestCommand{ID: watermill.NewULID()}
|
||||
require.NoError(t, commandBus.Send(context.Background(), pointerCmd))
|
||||
assert.EqualValues(t, []interface{}{pointerCmd}, captureCommandHandler.HandledCommands())
|
||||
captureCommandHandler.Reset()
|
||||
|
||||
nonPointerCmd := TestCommand{ID: watermill.NewULID()}
|
||||
require.NoError(t, commandBus.Send(context.Background(), nonPointerCmd))
|
||||
// command is always unmarshaled to pointer value
|
||||
assert.EqualValues(t, []interface{}{&nonPointerCmd}, captureCommandHandler.HandledCommands())
|
||||
captureCommandHandler.Reset()
|
||||
|
||||
pointerEvent := &TestEvent{ID: watermill.NewULID()}
|
||||
require.NoError(t, eventBus.Publish(context.Background(), pointerEvent))
|
||||
assert.EqualValues(t, []interface{}{pointerEvent}, captureEventHandler.HandledEvents())
|
||||
captureEventHandler.Reset()
|
||||
|
||||
nonPointerEvent := TestEvent{ID: watermill.NewULID()}
|
||||
require.NoError(t, eventBus.Publish(context.Background(), nonPointerEvent))
|
||||
// event is always unmarshaled to pointer value
|
||||
assert.EqualValues(t, []interface{}{&nonPointerEvent}, captureEventHandler.HandledEvents())
|
||||
captureEventHandler.Reset()
|
||||
|
||||
assert.NoError(t, router.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createCqrsComponents(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return params.EventName, nil
|
||||
},
|
||||
AckOnUnknownEvent: true,
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
assert.Equal(t, "CaptureEventHandler", params.HandlerName)
|
||||
|
||||
assert.Implements(t, new(cqrs.EventHandler), params.EventHandler)
|
||||
assert.NotNil(t, params.EventHandler)
|
||||
|
||||
return ts.EventsPubSub, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eventProcessor.AddHandlers(eventHandler)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventBus, err := cqrs.NewEventBusWithConfig(
|
||||
ts.EventsPubSub,
|
||||
cqrs.EventBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
|
||||
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
|
||||
|
||||
switch cmd := params.Event.(type) {
|
||||
case *TestEvent:
|
||||
assert.NotEmpty(t, cmd.ID)
|
||||
case TestEvent:
|
||||
assert.NotEmpty(t, cmd.ID)
|
||||
default:
|
||||
assert.Fail(t, "unexpected command type: %T", cmd)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, params.Event)
|
||||
|
||||
return params.EventName, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
||||
router,
|
||||
cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
|
||||
|
||||
assert.Implements(t, new(cqrs.CommandHandler), params.CommandHandler)
|
||||
assert.NotNil(t, params.CommandHandler)
|
||||
|
||||
return params.CommandName, nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
assert.Equal(t, "CaptureCommandHandler", params.HandlerName)
|
||||
|
||||
return ts.CommandsPubSub, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
AckCommandHandlingErrors: false,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = commandProcessor.AddHandlers(commandHandler)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandBus, err := cqrs.NewCommandBusWithConfig(ts.CommandsPubSub, cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
|
||||
|
||||
switch cmd := params.Command.(type) {
|
||||
case *TestCommand:
|
||||
assert.NotEmpty(t, cmd.ID)
|
||||
case TestCommand:
|
||||
assert.NotEmpty(t, cmd.ID)
|
||||
default:
|
||||
assert.Fail(t, "unexpected command type: %T", cmd)
|
||||
}
|
||||
|
||||
assert.NotNil(t, params.Command)
|
||||
|
||||
return params.CommandName, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, router.Run(context.Background()))
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
return router, commandBus, eventBus
|
||||
}
|
||||
|
||||
func createRouterAndFacade(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.Facade) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
c, err := cqrs.NewFacade(cqrs.FacadeConfig{
|
||||
GenerateCommandsTopic: func(commandName string) string {
|
||||
assert.Equal(t, "cqrs_test.TestCommand", commandName)
|
||||
|
||||
return commandName
|
||||
},
|
||||
GenerateEventsTopic: func(eventName string) string {
|
||||
assert.Equal(t, "cqrs_test.TestEvent", eventName)
|
||||
|
||||
return eventName
|
||||
},
|
||||
CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler {
|
||||
require.NotNil(t, cb)
|
||||
require.NotNil(t, eb)
|
||||
|
||||
return []cqrs.CommandHandler{commandHandler}
|
||||
},
|
||||
EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler {
|
||||
require.NotNil(t, cb)
|
||||
require.NotNil(t, eb)
|
||||
|
||||
return []cqrs.EventHandler{eventHandler}
|
||||
},
|
||||
Router: router,
|
||||
CommandsPublisher: ts.CommandsPubSub,
|
||||
CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
|
||||
assert.Equal(t, "CaptureCommandHandler", handlerName)
|
||||
|
||||
return ts.CommandsPubSub, nil
|
||||
},
|
||||
EventsPublisher: ts.EventsPubSub,
|
||||
EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
|
||||
assert.Equal(t, "CaptureEventHandler", handlerName)
|
||||
|
||||
return ts.EventsPubSub, nil
|
||||
},
|
||||
Logger: ts.Logger,
|
||||
CommandEventMarshaler: ts.Marshaler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, router.Run(context.Background()))
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
assert.Equal(t, c.CommandEventMarshaler(), ts.Marshaler)
|
||||
|
||||
return router, c
|
||||
}
|
||||
|
||||
type TestServices struct {
|
||||
Logger watermill.LoggerAdapter
|
||||
CommandsPubSub *gochannel.GoChannel
|
||||
EventsPubSub *gochannel.GoChannel
|
||||
Marshaler cqrs.CommandEventMarshaler
|
||||
}
|
||||
|
||||
func NewTestServices() TestServices {
|
||||
logger := watermill.NewStdLogger(true, true)
|
||||
|
||||
return TestServices{
|
||||
Logger: logger,
|
||||
CommandsPubSub: gochannel.NewGoChannel(
|
||||
gochannel.Config{BlockPublishUntilSubscriberAck: true},
|
||||
logger,
|
||||
),
|
||||
EventsPubSub: gochannel.NewGoChannel(
|
||||
gochannel.Config{BlockPublishUntilSubscriberAck: true},
|
||||
logger,
|
||||
),
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
}
|
||||
|
||||
type TestCommand struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CaptureCommandHandler struct {
|
||||
handledCommands []interface{}
|
||||
}
|
||||
|
||||
func (h CaptureCommandHandler) HandlerName() string {
|
||||
return "CaptureCommandHandler"
|
||||
}
|
||||
|
||||
func (h CaptureCommandHandler) HandledCommands() []interface{} {
|
||||
return h.handledCommands
|
||||
}
|
||||
|
||||
func (h *CaptureCommandHandler) Reset() {
|
||||
h.handledCommands = nil
|
||||
}
|
||||
|
||||
func (CaptureCommandHandler) NewCommand() interface{} {
|
||||
return &TestCommand{}
|
||||
}
|
||||
|
||||
func (h *CaptureCommandHandler) Handle(ctx context.Context, cmd interface{}) error {
|
||||
h.handledCommands = append(h.handledCommands, cmd.(*TestCommand))
|
||||
return nil
|
||||
}
|
||||
|
||||
type TestEvent struct {
|
||||
ID string
|
||||
When time.Time
|
||||
}
|
||||
|
||||
type AnotherTestEvent struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CaptureEventHandler struct {
|
||||
handledEvents []interface{}
|
||||
}
|
||||
|
||||
func (h CaptureEventHandler) HandlerName() string {
|
||||
return "CaptureEventHandler"
|
||||
}
|
||||
|
||||
func (h CaptureEventHandler) HandledEvents() []interface{} {
|
||||
return h.handledEvents
|
||||
}
|
||||
|
||||
func (h *CaptureEventHandler) Reset() {
|
||||
h.handledEvents = nil
|
||||
}
|
||||
|
||||
func (CaptureEventHandler) NewEvent() interface{} {
|
||||
return &TestEvent{}
|
||||
}
|
||||
|
||||
func (h *CaptureEventHandler) Handle(ctx context.Context, event interface{}) error {
|
||||
h.handledEvents = append(h.handledEvents, event.(*TestEvent))
|
||||
return nil
|
||||
}
|
||||
|
||||
type assertPublishTopicPublisher struct {
|
||||
ExpectedTopic string
|
||||
T *testing.T
|
||||
}
|
||||
|
||||
func (a assertPublishTopicPublisher) Publish(topic string, messages ...*message.Message) error {
|
||||
assert.Equal(a.T, a.ExpectedTopic, topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (assertPublishTopicPublisher) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type publisherStub struct {
|
||||
messages map[string]message.Messages
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPublisherStub() *publisherStub {
|
||||
return &publisherStub{
|
||||
messages: make(map[string]message.Messages),
|
||||
}
|
||||
}
|
||||
|
||||
func (*publisherStub) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *publisherStub) Publish(topic string, messages ...*message.Message) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.messages[topic] = append(p.messages[topic], messages...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFacadeConfig_Validate(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
validConfig := cqrs.FacadeConfig{
|
||||
GenerateCommandsTopic: func(commandName string) string {
|
||||
return commandName
|
||||
},
|
||||
GenerateEventsTopic: func(eventName string) string {
|
||||
return eventName
|
||||
},
|
||||
CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler {
|
||||
return []cqrs.CommandHandler{}
|
||||
},
|
||||
EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler {
|
||||
return []cqrs.EventHandler{}
|
||||
},
|
||||
Router: router,
|
||||
CommandsPublisher: ts.CommandsPubSub,
|
||||
CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
|
||||
return ts.CommandsPubSub, nil
|
||||
},
|
||||
EventsPublisher: ts.EventsPubSub,
|
||||
EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
|
||||
return ts.EventsPubSub, nil
|
||||
},
|
||||
Logger: ts.Logger,
|
||||
CommandEventMarshaler: ts.Marshaler,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Config cqrs.FacadeConfig
|
||||
Valid bool
|
||||
}{
|
||||
{
|
||||
Name: "valid",
|
||||
Config: validConfig,
|
||||
Valid: true,
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateCommandsTopic",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.GenerateCommandsTopic = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_CommandsSubscriberConstructor",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.CommandsSubscriberConstructor = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_CommandsPublisher",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.CommandsPublisher = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateEventsTopic",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.GenerateEventsTopic = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateEventsTopic",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.EventsSubscriberConstructor = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_EventsPublisher",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.EventsPublisher = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_Router",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.Router = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_Logger",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.Logger = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
{
|
||||
Name: "missing_CommandEventMarshaler",
|
||||
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
|
||||
config.CommandEventMarshaler = nil
|
||||
}),
|
||||
Valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
if c.Valid {
|
||||
assert.NoError(t, c.Config.Validate())
|
||||
} else {
|
||||
assert.Error(t, c.Config.Validate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func transformConfig(config cqrs.FacadeConfig, transformFn func(config *cqrs.FacadeConfig)) cqrs.FacadeConfig {
|
||||
transformFn(&config)
|
||||
return config
|
||||
}
|
||||
27
components/cqrs/ctx.go
Normal file
27
components/cqrs/ctx.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
originalMessage ctxKey = "original_message"
|
||||
)
|
||||
|
||||
// OriginalMessageFromCtx returns the original message that was received by the event/command handler.
|
||||
func OriginalMessageFromCtx(ctx context.Context) *message.Message {
|
||||
val, ok := ctx.Value(originalMessage).(*message.Message)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// CtxWithOriginalMessage returns a new context with the original message attached.
|
||||
func CtxWithOriginalMessage(ctx context.Context, msg *message.Message) context.Context {
|
||||
return context.WithValue(ctx, originalMessage, msg)
|
||||
}
|
||||
3
components/cqrs/doc.go
Normal file
3
components/cqrs/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Detailed CQRS documentation can be found in https://watermill.io/docs/cqrs/
|
||||
|
||||
package cqrs
|
||||
146
components/cqrs/event_bus.go
Normal file
146
components/cqrs/event_bus.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type EventBusConfig struct {
|
||||
// GeneratePublishTopic is used to generate topic name for publishing event.
|
||||
GeneratePublishTopic GenerateEventPublishTopicFn
|
||||
|
||||
// OnPublish is called before sending the event.
|
||||
// The *message.Message can be modified.
|
||||
//
|
||||
// This option is not required.
|
||||
OnPublish OnEventSendFn
|
||||
|
||||
// Marshaler is used to marshal and unmarshal events.
|
||||
// It is required.
|
||||
Marshaler CommandEventMarshaler
|
||||
|
||||
// Logger instance used to log.
|
||||
// If not provided, watermill.NopLogger is used.
|
||||
Logger watermill.LoggerAdapter
|
||||
}
|
||||
|
||||
func (c *EventBusConfig) setDefaults() {
|
||||
if c.Logger == nil {
|
||||
c.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c EventBusConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Marshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing Marshaler"))
|
||||
}
|
||||
|
||||
if c.GeneratePublishTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type GenerateEventPublishTopicFn func(GenerateEventPublishTopicParams) (string, error)
|
||||
|
||||
type GenerateEventPublishTopicParams struct {
|
||||
EventName string
|
||||
Event any
|
||||
}
|
||||
|
||||
type OnEventSendFn func(params OnEventSendParams) error
|
||||
|
||||
type OnEventSendParams struct {
|
||||
EventName string
|
||||
Event any
|
||||
|
||||
// Message is never nil and can be modified.
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// EventBus transports events to event handlers.
|
||||
type EventBus struct {
|
||||
publisher message.Publisher
|
||||
config EventBusConfig
|
||||
}
|
||||
|
||||
// NewEventBus creates a new CommandBus.
|
||||
// Deprecated: use NewEventBusWithConfig instead.
|
||||
func NewEventBus(
|
||||
publisher message.Publisher,
|
||||
generateTopic func(eventName string) string,
|
||||
marshaler CommandEventMarshaler,
|
||||
) (*EventBus, error) {
|
||||
if publisher == nil {
|
||||
return nil, errors.New("missing publisher")
|
||||
}
|
||||
if generateTopic == nil {
|
||||
return nil, errors.New("missing generateTopic")
|
||||
}
|
||||
if marshaler == nil {
|
||||
return nil, errors.New("missing marshaler")
|
||||
}
|
||||
|
||||
return &EventBus{
|
||||
publisher: publisher,
|
||||
config: EventBusConfig{
|
||||
GeneratePublishTopic: func(params GenerateEventPublishTopicParams) (string, error) {
|
||||
return generateTopic(params.EventName), nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewEventBusWithConfig creates a new EventBus.
|
||||
func NewEventBusWithConfig(publisher message.Publisher, config EventBusConfig) (*EventBus, error) {
|
||||
if publisher == nil {
|
||||
return nil, errors.New("missing publisher")
|
||||
}
|
||||
|
||||
config.setDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config")
|
||||
}
|
||||
|
||||
return &EventBus{publisher, config}, nil
|
||||
}
|
||||
|
||||
// Publish sends event to the event bus.
|
||||
func (c EventBus) Publish(ctx context.Context, event any) error {
|
||||
msg, err := c.config.Marshaler.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventName := c.config.Marshaler.Name(event)
|
||||
topicName, err := c.config.GeneratePublishTopic(GenerateEventPublishTopicParams{
|
||||
EventName: eventName,
|
||||
Event: event,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot generate topic")
|
||||
}
|
||||
|
||||
msg.SetContext(ctx)
|
||||
|
||||
if c.config.OnPublish != nil {
|
||||
err := c.config.OnPublish(OnEventSendParams{
|
||||
EventName: eventName,
|
||||
Event: event,
|
||||
Message: msg,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot execute OnPublish")
|
||||
}
|
||||
}
|
||||
|
||||
return c.publisher.Publish(topicName, msg)
|
||||
}
|
||||
168
components/cqrs/event_bus_test.go
Normal file
168
components/cqrs/event_bus_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEventBusConfig_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ModifyValidConfig func(*cqrs.EventBusConfig)
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "valid_config",
|
||||
ModifyValidConfig: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateEventPublishTopic",
|
||||
ModifyValidConfig: func(config *cqrs.EventBusConfig) {
|
||||
config.GeneratePublishTopic = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"),
|
||||
},
|
||||
{
|
||||
Name: "missing_marshaler",
|
||||
ModifyValidConfig: func(config *cqrs.EventBusConfig) {
|
||||
config.Marshaler = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing Marshaler"),
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
validConfig := cqrs.EventBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
if tc.ModifyValidConfig != nil {
|
||||
tc.ModifyValidConfig(&validConfig)
|
||||
}
|
||||
|
||||
err := validConfig.Validate()
|
||||
if tc.ExpectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.ExpectedErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventBus(t *testing.T) {
|
||||
pub := newPublisherStub()
|
||||
generateTopic := func(commandName string) string {
|
||||
return ""
|
||||
}
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
cb, err := cqrs.NewEventBus(pub, generateTopic, marshaler)
|
||||
assert.NotNil(t, cb)
|
||||
assert.NoError(t, err)
|
||||
|
||||
cb, err = cqrs.NewEventBus(nil, generateTopic, marshaler)
|
||||
assert.Nil(t, cb)
|
||||
assert.Error(t, err)
|
||||
|
||||
cb, err = cqrs.NewEventBus(pub, nil, marshaler)
|
||||
assert.Nil(t, cb)
|
||||
assert.Error(t, err)
|
||||
|
||||
cb, err = cqrs.NewEventBus(pub, generateTopic, nil)
|
||||
assert.Nil(t, cb)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEventBus_Send_ContextPropagation(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
eventBus, err := cqrs.NewEventBus(
|
||||
publisher,
|
||||
func(eventName string) string {
|
||||
return "whatever"
|
||||
},
|
||||
cqrs.JSONMarshaler{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
|
||||
|
||||
err = eventBus.Publish(ctx, "message")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ctx, publisher.messages["whatever"][0].Context())
|
||||
}
|
||||
|
||||
func TestEventBus_Send_topic_name(t *testing.T) {
|
||||
cb, err := cqrs.NewEventBus(
|
||||
assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestEvent", T: t},
|
||||
func(commandName string) string {
|
||||
return commandName
|
||||
},
|
||||
cqrs.JSONMarshaler{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cb.Publish(context.Background(), TestEvent{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEventBus_Send_OnPublish(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
eb, err := cqrs.NewEventBusWithConfig(
|
||||
publisher,
|
||||
cqrs.EventBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
OnPublish: func(params cqrs.OnEventSendParams) error {
|
||||
params.Message.Metadata.Set("key", "value")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eb.Publish(context.Background(), TestEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
|
||||
}
|
||||
|
||||
func TestEventBus_Send_OnPublish_error(t *testing.T) {
|
||||
publisher := newPublisherStub()
|
||||
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
eb, err := cqrs.NewEventBusWithConfig(
|
||||
publisher,
|
||||
cqrs.EventBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
|
||||
return "whatever", nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
OnPublish: func(params cqrs.OnEventSendParams) error {
|
||||
return expectedErr
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eb.Publish(context.Background(), TestEvent{})
|
||||
require.EqualError(t, err, "cannot execute OnPublish: some error")
|
||||
}
|
||||
73
components/cqrs/event_handler.go
Normal file
73
components/cqrs/event_handler.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// EventHandler receives events defined by NewEvent and handles them with its Handle method.
|
||||
// If using DDD, CommandHandler may modify and persist the aggregate.
|
||||
// It can also invoke a process manager, a saga or just build a read model.
|
||||
//
|
||||
// In contrast to CommandHandler, every Event can have multiple EventHandlers.
|
||||
//
|
||||
// One instance of EventHandler is used during handling messages.
|
||||
// When multiple events are delivered at the same time, Handle method can be executed multiple times at the same time.
|
||||
// Because of that, Handle method needs to be thread safe!
|
||||
type EventHandler interface {
|
||||
// HandlerName is the name used in message.Router while creating handler.
|
||||
//
|
||||
// It will be also passed to EventsSubscriberConstructor.
|
||||
// May be useful, for example, to create a consumer group per each handler.
|
||||
//
|
||||
// WARNING: If HandlerName was changed and is used for generating consumer groups,
|
||||
// it may result with **reconsuming all messages** !!!
|
||||
HandlerName() string
|
||||
|
||||
NewEvent() any
|
||||
|
||||
Handle(ctx context.Context, event any) error
|
||||
}
|
||||
|
||||
type genericEventHandler[T any] struct {
|
||||
handleFunc func(ctx context.Context, event *T) error
|
||||
handlerName string
|
||||
}
|
||||
|
||||
// NewEventHandler creates a new EventHandler implementation based on provided function
|
||||
// and event type inferred from function argument.
|
||||
func NewEventHandler[T any](
|
||||
handlerName string,
|
||||
handleFunc func(ctx context.Context, event *T) error,
|
||||
) EventHandler {
|
||||
return &genericEventHandler[T]{
|
||||
handleFunc: handleFunc,
|
||||
handlerName: handlerName,
|
||||
}
|
||||
}
|
||||
|
||||
func (c genericEventHandler[T]) HandlerName() string {
|
||||
return c.handlerName
|
||||
}
|
||||
|
||||
func (c genericEventHandler[T]) NewEvent() any {
|
||||
tVar := new(T)
|
||||
return tVar
|
||||
}
|
||||
|
||||
func (c genericEventHandler[T]) Handle(ctx context.Context, e any) error {
|
||||
event := e.(*T)
|
||||
return c.handleFunc(ctx, event)
|
||||
}
|
||||
|
||||
type GroupEventHandler interface {
|
||||
NewEvent() interface{}
|
||||
Handle(ctx context.Context, event interface{}) error
|
||||
}
|
||||
|
||||
// NewGroupEventHandler creates a new GroupEventHandler implementation based on provided function
|
||||
// and event type inferred from function argument.
|
||||
func NewGroupEventHandler[T any](handleFunc func(ctx context.Context, event *T) error) GroupEventHandler {
|
||||
return &genericEventHandler[T]{
|
||||
handleFunc: handleFunc,
|
||||
}
|
||||
}
|
||||
48
components/cqrs/event_handler_test.go
Normal file
48
components/cqrs/event_handler_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type SomeEvent struct {
|
||||
Foo string
|
||||
}
|
||||
|
||||
func TestNewEventHandler(t *testing.T) {
|
||||
cmdToSend := &SomeEvent{"bar"}
|
||||
|
||||
ch := cqrs.NewEventHandler(
|
||||
"some_handler",
|
||||
func(ctx context.Context, cmd *SomeEvent) error {
|
||||
assert.Equal(t, cmdToSend, cmd)
|
||||
return fmt.Errorf("some error")
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, "some_handler", ch.HandlerName())
|
||||
assert.Equal(t, &SomeEvent{}, ch.NewEvent())
|
||||
|
||||
err := ch.Handle(context.Background(), cmdToSend)
|
||||
assert.EqualError(t, err, "some error")
|
||||
}
|
||||
|
||||
func TestNewGroupEventHandler(t *testing.T) {
|
||||
cmdToSend := &SomeEvent{"bar"}
|
||||
|
||||
ch := cqrs.NewGroupEventHandler(
|
||||
func(ctx context.Context, cmd *SomeEvent) error {
|
||||
assert.Equal(t, cmdToSend, cmd)
|
||||
return fmt.Errorf("some error")
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, &SomeEvent{}, ch.NewEvent())
|
||||
|
||||
err := ch.Handle(context.Background(), cmdToSend)
|
||||
assert.EqualError(t, err, "some error")
|
||||
}
|
||||
380
components/cqrs/event_processor.go
Normal file
380
components/cqrs/event_processor.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type EventProcessorConfig struct {
|
||||
// GenerateSubscribeTopic is used to generate topic for subscribing to events.
|
||||
// If event processor is using handler groups, GenerateSubscribeTopic is used instead.
|
||||
GenerateSubscribeTopic EventProcessorGenerateSubscribeTopicFn
|
||||
|
||||
// SubscriberConstructor is used to create subscriber for EventHandler.
|
||||
//
|
||||
// This function is called for every EventHandler instance.
|
||||
// If you want to re-use one subscriber for multiple handlers, use GroupEventProcessor instead.
|
||||
SubscriberConstructor EventProcessorSubscriberConstructorFn
|
||||
|
||||
// OnHandle is called before handling event.
|
||||
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event.
|
||||
//
|
||||
// Because of that, you need to explicitly call params.Handler.Handle() to handle the event.
|
||||
//
|
||||
// func(params EventProcessorOnHandleParams) (err error) {
|
||||
// // logic before handle
|
||||
// // (...)
|
||||
//
|
||||
// err := params.Handler.Handle(params.Message.Context(), params.Event)
|
||||
//
|
||||
// // logic after handle
|
||||
// // (...)
|
||||
//
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// This option is not required.
|
||||
OnHandle EventProcessorOnHandleFn
|
||||
|
||||
// AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined.
|
||||
AckOnUnknownEvent bool
|
||||
|
||||
// Marshaler is used to marshal and unmarshal events.
|
||||
// It is required.
|
||||
Marshaler CommandEventMarshaler
|
||||
|
||||
// Logger instance used to log.
|
||||
// If not provided, watermill.NopLogger is used.
|
||||
Logger watermill.LoggerAdapter
|
||||
|
||||
// disableRouterAutoAddHandlers is used to keep backwards compatibility.
|
||||
// it is set when EventProcessor is created by NewEventProcessor.
|
||||
// Deprecated: please migrate to NewEventProcessorWithConfig.
|
||||
disableRouterAutoAddHandlers bool
|
||||
}
|
||||
|
||||
func (c *EventProcessorConfig) setDefaults() {
|
||||
if c.Logger == nil {
|
||||
c.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c EventProcessorConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Marshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing Marshaler"))
|
||||
}
|
||||
|
||||
if c.GenerateSubscribeTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic"))
|
||||
}
|
||||
if c.SubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type EventProcessorGenerateSubscribeTopicFn func(EventProcessorGenerateSubscribeTopicParams) (string, error)
|
||||
|
||||
type EventProcessorGenerateSubscribeTopicParams struct {
|
||||
EventName string
|
||||
EventHandler EventHandler
|
||||
}
|
||||
|
||||
type EventProcessorSubscriberConstructorFn func(EventProcessorSubscriberConstructorParams) (message.Subscriber, error)
|
||||
|
||||
type EventProcessorSubscriberConstructorParams struct {
|
||||
EventName string
|
||||
HandlerName string
|
||||
EventHandler EventHandler
|
||||
}
|
||||
|
||||
type EventProcessorOnHandleFn func(params EventProcessorOnHandleParams) error
|
||||
|
||||
type EventProcessorOnHandleParams struct {
|
||||
Handler EventHandler
|
||||
|
||||
Event any
|
||||
EventName string
|
||||
|
||||
// Message is never nil and can be modified.
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// EventProcessor determines which EventHandler should handle event received from event bus.
|
||||
type EventProcessor struct {
|
||||
router *message.Router
|
||||
handlers []EventHandler
|
||||
config EventProcessorConfig
|
||||
}
|
||||
|
||||
// NewEventProcessorWithConfig creates a new EventProcessor.
|
||||
func NewEventProcessorWithConfig(router *message.Router, config EventProcessorConfig) (*EventProcessor, error) {
|
||||
config.setDefaults()
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config EventProcessor")
|
||||
}
|
||||
if router == nil && !config.disableRouterAutoAddHandlers {
|
||||
return nil, errors.New("missing router")
|
||||
}
|
||||
|
||||
return &EventProcessor{
|
||||
router: router,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewEventProcessor creates a new EventProcessor.
|
||||
// Deprecated. Use NewEventProcessorWithConfig instead.
|
||||
func NewEventProcessor(
|
||||
individualHandlers []EventHandler,
|
||||
generateTopic func(eventName string) string,
|
||||
subscriberConstructor EventsSubscriberConstructor,
|
||||
marshaler CommandEventMarshaler,
|
||||
logger watermill.LoggerAdapter,
|
||||
) (*EventProcessor, error) {
|
||||
if len(individualHandlers) == 0 {
|
||||
return nil, errors.New("missing handlers")
|
||||
}
|
||||
if generateTopic == nil {
|
||||
return nil, errors.New("nil generateTopic")
|
||||
}
|
||||
if subscriberConstructor == nil {
|
||||
return nil, errors.New("missing subscriberConstructor")
|
||||
}
|
||||
if marshaler == nil {
|
||||
return nil, errors.New("missing marshaler")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = watermill.NopLogger{}
|
||||
}
|
||||
|
||||
eventProcessorConfig := EventProcessorConfig{
|
||||
AckOnUnknownEvent: true, // this is the previous default behaviour - keeping backwards compatibility
|
||||
GenerateSubscribeTopic: func(params EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return generateTopic(params.EventName), nil
|
||||
},
|
||||
SubscriberConstructor: func(params EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return subscriberConstructor(params.HandlerName)
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
disableRouterAutoAddHandlers: true,
|
||||
}
|
||||
eventProcessorConfig.setDefaults()
|
||||
|
||||
ep, err := NewEventProcessorWithConfig(nil, eventProcessorConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, handler := range individualHandlers {
|
||||
if err := ep.AddHandlers(handler); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return ep, nil
|
||||
}
|
||||
|
||||
// EventsSubscriberConstructor creates a subscriber for EventHandler.
|
||||
// It allows you to create separated customized Subscriber for every command handler.
|
||||
//
|
||||
// When handler groups are used, handler group is passed as handlerName.
|
||||
// Deprecated: please use EventProcessorSubscriberConstructorFn instead.
|
||||
type EventsSubscriberConstructor func(handlerName string) (message.Subscriber, error)
|
||||
|
||||
// AddHandlers adds a new EventHandler to the EventProcessor and adds it to the router.
|
||||
func (p *EventProcessor) AddHandlers(handlers ...EventHandler) error {
|
||||
if p.config.disableRouterAutoAddHandlers {
|
||||
p.handlers = append(p.handlers, handlers...)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
if _, err := p.addHandlerToRouter(p.router, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.handlers = append(p.handlers, handler)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddHandler adds a new EventHandler to the EventProcessor and adds it to the router.
|
||||
func (p *EventProcessor) AddHandler(handler EventHandler) (*message.Handler, error) {
|
||||
if p.config.disableRouterAutoAddHandlers {
|
||||
p.handlers = append(p.handlers, handler)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
h, err := p.addHandlerToRouter(p.router, handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.handlers = append(p.handlers, handler)
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// AddHandlersToRouter adds the EventProcessor's handlers to the given router.
|
||||
// It should be called only once per EventProcessor instance.
|
||||
//
|
||||
// It is required to call AddHandlersToRouter only if command processor is created with NewEventProcessor (disableRouterAutoAddHandlers is set to true).
|
||||
// Deprecated: please migrate to event processor created by NewEventProcessorWithConfig.
|
||||
func (p EventProcessor) AddHandlersToRouter(r *message.Router) error {
|
||||
if !p.config.disableRouterAutoAddHandlers {
|
||||
return errors.New("AddHandlersToRouter should be called only when using deprecated NewEventProcessor")
|
||||
}
|
||||
|
||||
for i := range p.handlers {
|
||||
handler := p.handlers[i]
|
||||
|
||||
if _, err := p.addHandlerToRouter(r, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p EventProcessor) addHandlerToRouter(r *message.Router, handler EventHandler) (*message.Handler, error) {
|
||||
if err := validateEvent(handler.NewEvent()); err != nil {
|
||||
return nil, errors.Wrapf(err, "invalid event for handler %s", handler.HandlerName())
|
||||
}
|
||||
|
||||
handlerName := handler.HandlerName()
|
||||
eventName := p.config.Marshaler.Name(handler.NewEvent())
|
||||
|
||||
topicName, err := p.config.GenerateSubscribeTopic(EventProcessorGenerateSubscribeTopicParams{
|
||||
EventName: eventName,
|
||||
EventHandler: handler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "cannot generate topic name for handler %s", handlerName)
|
||||
}
|
||||
|
||||
logger := p.config.Logger.With(watermill.LogFields{
|
||||
"event_handler_name": handlerName,
|
||||
"topic": topicName,
|
||||
})
|
||||
|
||||
handlerFunc, err := p.routerHandlerFunc(handler, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.config.SubscriberConstructor == nil {
|
||||
return nil, errors.New("missing SubscriberConstructor config option")
|
||||
}
|
||||
|
||||
subscriber, err := p.config.SubscriberConstructor(EventProcessorSubscriberConstructorParams{
|
||||
EventName: eventName,
|
||||
HandlerName: handlerName,
|
||||
EventHandler: handler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create subscriber for event processor")
|
||||
}
|
||||
|
||||
return addHandlerToRouter(p.config.Logger, r, handlerName, topicName, handlerFunc, subscriber), nil
|
||||
}
|
||||
|
||||
func (p EventProcessor) Handlers() []EventHandler {
|
||||
return p.handlers
|
||||
}
|
||||
|
||||
func addHandlerToRouter(logger watermill.LoggerAdapter, r *message.Router, handlerName string, topicName string, handlerFunc message.NoPublishHandlerFunc, subscriber message.Subscriber) *message.Handler {
|
||||
logger = logger.With(watermill.LogFields{
|
||||
"event_handler_name": handlerName,
|
||||
"topic": topicName,
|
||||
})
|
||||
|
||||
logger.Debug("Adding CQRS event handler to router", nil)
|
||||
|
||||
return r.AddConsumerHandler(
|
||||
handlerName,
|
||||
topicName,
|
||||
subscriber,
|
||||
handlerFunc,
|
||||
)
|
||||
}
|
||||
|
||||
func (p EventProcessor) routerHandlerFunc(handler EventHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
|
||||
initEvent := handler.NewEvent()
|
||||
expectedEventName := p.config.Marshaler.Name(initEvent)
|
||||
|
||||
if err := validateEvent(initEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func(msg *message.Message) error {
|
||||
event := handler.NewEvent()
|
||||
messageEventName := p.config.Marshaler.NameFromMessage(msg)
|
||||
|
||||
if messageEventName != expectedEventName {
|
||||
if !p.config.AckOnUnknownEvent {
|
||||
return fmt.Errorf("received unexpected event type %s, expected %s", messageEventName, expectedEventName)
|
||||
} else {
|
||||
logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"expected_event_type": expectedEventName,
|
||||
"received_event_type": messageEventName,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Handling event", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"received_event_type": messageEventName,
|
||||
})
|
||||
|
||||
ctx := CtxWithOriginalMessage(msg.Context(), msg)
|
||||
msg.SetContext(ctx)
|
||||
|
||||
if err := p.config.Marshaler.Unmarshal(msg, event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := func(params EventProcessorOnHandleParams) error {
|
||||
return params.Handler.Handle(ctx, params.Event)
|
||||
}
|
||||
if p.config.OnHandle != nil {
|
||||
handle = p.config.OnHandle
|
||||
}
|
||||
|
||||
err := handle(EventProcessorOnHandleParams{
|
||||
Handler: handler,
|
||||
Event: event,
|
||||
EventName: messageEventName,
|
||||
Message: msg,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("Error when handling event", watermill.LogFields{"err": err})
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateEvent(event interface{}) error {
|
||||
// EventHandler's NewEvent must return a pointer, because it is used to unmarshal
|
||||
if err := isPointer(event); err != nil {
|
||||
return errors.Wrap(err, "command must be a non-nil pointer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
272
components/cqrs/event_processor_group.go
Normal file
272
components/cqrs/event_processor_group.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type EventGroupProcessorConfig struct {
|
||||
// GenerateSubscribeTopic is used to generate topic for subscribing to events for handler groups.
|
||||
// This option is required for EventProcessor if handler groups are used.
|
||||
GenerateSubscribeTopic EventGroupProcessorGenerateSubscribeTopicFn
|
||||
|
||||
// SubscriberConstructor is used to create subscriber for GroupEventHandler.
|
||||
// This function is called for every events group once - thanks to that it's possible to have one subscription per group.
|
||||
// It's useful, when we are processing events from one stream and we want to do it in order.
|
||||
SubscriberConstructor EventGroupProcessorSubscriberConstructorFn
|
||||
|
||||
// OnHandle is called before handling event.
|
||||
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event.
|
||||
//
|
||||
// Because of that, you need to explicitly call params.Handler.Handle() to handle the event.
|
||||
//
|
||||
// func(params EventGroupProcessorOnHandleParams) (err error) {
|
||||
// // logic before handle
|
||||
// // (...)
|
||||
//
|
||||
// err := params.Handler.Handle(params.Message.Context(), params.Event)
|
||||
//
|
||||
// // logic after handle
|
||||
// // (...)
|
||||
//
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// This option is not required.
|
||||
OnHandle EventGroupProcessorOnHandleFn
|
||||
|
||||
// AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined.
|
||||
AckOnUnknownEvent bool
|
||||
|
||||
// Marshaler is used to marshal and unmarshal events.
|
||||
// It is required.
|
||||
Marshaler CommandEventMarshaler
|
||||
|
||||
// Logger instance used to log.
|
||||
// If not provided, watermill.NopLogger is used.
|
||||
Logger watermill.LoggerAdapter
|
||||
}
|
||||
|
||||
func (c *EventGroupProcessorConfig) setDefaults() {
|
||||
if c.Logger == nil {
|
||||
c.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c EventGroupProcessorConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Marshaler == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing Marshaler"))
|
||||
}
|
||||
|
||||
if c.GenerateSubscribeTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing GenerateHandlerGroupTopic"))
|
||||
}
|
||||
if c.SubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type EventGroupProcessorGenerateSubscribeTopicFn func(EventGroupProcessorGenerateSubscribeTopicParams) (string, error)
|
||||
|
||||
type EventGroupProcessorGenerateSubscribeTopicParams struct {
|
||||
EventGroupName string
|
||||
EventGroupHandlers []GroupEventHandler
|
||||
}
|
||||
|
||||
type EventGroupProcessorSubscriberConstructorFn func(EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error)
|
||||
|
||||
type EventGroupProcessorSubscriberConstructorParams struct {
|
||||
EventGroupName string
|
||||
EventGroupHandlers []GroupEventHandler
|
||||
}
|
||||
|
||||
type EventGroupProcessorOnHandleFn func(params EventGroupProcessorOnHandleParams) error
|
||||
|
||||
type EventGroupProcessorOnHandleParams struct {
|
||||
GroupName string
|
||||
Handler GroupEventHandler
|
||||
|
||||
Event any
|
||||
EventName string
|
||||
|
||||
// Message is never nil and can be modified.
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// EventGroupProcessor determines which EventHandler should handle event received from event bus.
|
||||
// Compared to EventProcessor, EventGroupProcessor allows to have multiple handlers that share the same subscriber instance.
|
||||
type EventGroupProcessor struct {
|
||||
router *message.Router
|
||||
|
||||
groupEventHandlers map[string][]GroupEventHandler
|
||||
|
||||
config EventGroupProcessorConfig
|
||||
}
|
||||
|
||||
// NewEventGroupProcessorWithConfig creates a new EventGroupProcessor.
|
||||
func NewEventGroupProcessorWithConfig(router *message.Router, config EventGroupProcessorConfig) (*EventGroupProcessor, error) {
|
||||
config.setDefaults()
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config EventProcessor")
|
||||
}
|
||||
if router == nil {
|
||||
return nil, errors.New("missing router")
|
||||
}
|
||||
|
||||
return &EventGroupProcessor{
|
||||
router: router,
|
||||
groupEventHandlers: map[string][]GroupEventHandler{},
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddHandlersGroup adds a new list of GroupEventHandler to the EventGroupProcessor and adds it to the router.
|
||||
//
|
||||
// Compared to AddHandlers, AddHandlersGroup allows to have multiple handlers that share the same subscriber instance.
|
||||
//
|
||||
// It's allowed to have multiple handlers for the same event type in one group, but we recommend to not do that.
|
||||
// Please keep in mind that those handlers will be processed within the same message.
|
||||
// If first handler succeeds and the second fails, the message will be re-delivered and the first will be re-executed.
|
||||
//
|
||||
// Handlers group needs to be unique within the EventProcessor instance.
|
||||
//
|
||||
// Handler group name is used as handler's name in router.
|
||||
func (p *EventGroupProcessor) AddHandlersGroup(groupName string, handlers ...GroupEventHandler) error {
|
||||
if len(handlers) == 0 {
|
||||
return errors.New("no handlers provided")
|
||||
}
|
||||
if _, ok := p.groupEventHandlers[groupName]; ok {
|
||||
return fmt.Errorf("event handler group '%s' already exists", groupName)
|
||||
}
|
||||
|
||||
if err := p.addHandlerToRouter(p.router, groupName, handlers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.groupEventHandlers[groupName] = handlers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p EventGroupProcessor) addHandlerToRouter(r *message.Router, groupName string, handlersGroup []GroupEventHandler) error {
|
||||
for i, handler := range handlersGroup {
|
||||
if err := validateEvent(handler.NewEvent()); err != nil {
|
||||
return errors.Wrapf(
|
||||
err,
|
||||
"invalid event for handler %T (num %d) in group %s",
|
||||
handler,
|
||||
i,
|
||||
groupName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
topicName, err := p.config.GenerateSubscribeTopic(EventGroupProcessorGenerateSubscribeTopicParams{
|
||||
EventGroupName: groupName,
|
||||
EventGroupHandlers: handlersGroup,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "cannot generate topic name for handler group %s", groupName)
|
||||
}
|
||||
|
||||
logger := p.config.Logger.With(watermill.LogFields{
|
||||
"event_handler_group_name": groupName,
|
||||
"topic": topicName,
|
||||
})
|
||||
|
||||
handlerFunc, err := p.routerHandlerGroupFunc(handlersGroup, groupName, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscriber, err := p.config.SubscriberConstructor(EventGroupProcessorSubscriberConstructorParams{
|
||||
EventGroupName: groupName,
|
||||
EventGroupHandlers: handlersGroup,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot create subscriber for event processor")
|
||||
}
|
||||
|
||||
_ = addHandlerToRouter(p.config.Logger, r, groupName, topicName, handlerFunc, subscriber)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p EventGroupProcessor) routerHandlerGroupFunc(handlers []GroupEventHandler, groupName string, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
|
||||
return func(msg *message.Message) error {
|
||||
messageEventName := p.config.Marshaler.NameFromMessage(msg)
|
||||
|
||||
handledAnyEvent := false
|
||||
|
||||
for _, handler := range handlers {
|
||||
initEvent := handler.NewEvent()
|
||||
expectedEventName := p.config.Marshaler.Name(initEvent)
|
||||
|
||||
event := handler.NewEvent()
|
||||
|
||||
if messageEventName != expectedEventName {
|
||||
logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"expected_event_type": expectedEventName,
|
||||
"received_event_type": messageEventName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug("Handling event", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"received_event_type": messageEventName,
|
||||
})
|
||||
|
||||
ctx := CtxWithOriginalMessage(msg.Context(), msg)
|
||||
msg.SetContext(ctx)
|
||||
|
||||
if err := p.config.Marshaler.Unmarshal(msg, event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := func(params EventGroupProcessorOnHandleParams) error {
|
||||
return params.Handler.Handle(ctx, params.Event)
|
||||
}
|
||||
if p.config.OnHandle != nil {
|
||||
handle = p.config.OnHandle
|
||||
}
|
||||
|
||||
err := handle(EventGroupProcessorOnHandleParams{
|
||||
GroupName: groupName,
|
||||
Handler: handler,
|
||||
EventName: messageEventName,
|
||||
Event: event,
|
||||
Message: msg,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("Error when handling event", watermill.LogFields{"err": err})
|
||||
return err
|
||||
}
|
||||
|
||||
handledAnyEvent = true
|
||||
}
|
||||
if handledAnyEvent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !p.config.AckOnUnknownEvent {
|
||||
return fmt.Errorf("no handler found for event %s", p.config.Marshaler.NameFromMessage(msg))
|
||||
} else {
|
||||
logger.Trace("Received event can't be handled by any handler in handler group", watermill.LogFields{
|
||||
"message_uuid": msg.UUID,
|
||||
"received_event_type": messageEventName,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
457
components/cqrs/event_processor_group_test.go
Normal file
457
components/cqrs/event_processor_group_test.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEventGroupProcessorConfig_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ModifyValidConfig func(*cqrs.EventGroupProcessorConfig)
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "valid_config",
|
||||
ModifyValidConfig: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "valid_with_group_handlers",
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing_GroupSubscriberConstructor",
|
||||
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
|
||||
config.SubscriberConstructor = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing SubscriberConstructor"),
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateHandlerGroupSubscribeTopic",
|
||||
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
|
||||
config.GenerateSubscribeTopic = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing GenerateHandlerGroupTopic"),
|
||||
},
|
||||
{
|
||||
Name: "missing_marshaler",
|
||||
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
|
||||
config.Marshaler = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing Marshaler"),
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
validConfig := cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
if tc.ModifyValidConfig != nil {
|
||||
tc.ModifyValidConfig(&validConfig)
|
||||
}
|
||||
|
||||
err := validConfig.Validate()
|
||||
if tc.ExpectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.ExpectedErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_OnGroupHandle(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg1,
|
||||
msg2,
|
||||
},
|
||||
}
|
||||
|
||||
handlerCalled := 0
|
||||
|
||||
defer func() {
|
||||
// for msg 1 we are not calling handler - but returning before
|
||||
assert.Equal(t, 1, handlerCalled)
|
||||
}()
|
||||
|
||||
handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
handlerCalled++
|
||||
return nil
|
||||
})
|
||||
|
||||
onHandleCalled := int64(0)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
config := cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
OnHandle: func(params cqrs.EventGroupProcessorOnHandleParams) error {
|
||||
atomic.AddInt64(&onHandleCalled, 1)
|
||||
|
||||
assert.Equal(t, "some_group", params.GroupName)
|
||||
|
||||
assert.IsType(t, &TestEvent{}, params.Event)
|
||||
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
|
||||
assert.Equal(t, handler, params.Handler)
|
||||
|
||||
if params.Event.(*TestEvent).ID == "1" {
|
||||
assert.Equal(t, msg1, params.Message)
|
||||
return errors.New("test error")
|
||||
} else {
|
||||
assert.Equal(t, msg2, params.Message)
|
||||
}
|
||||
|
||||
return params.Handler.Handle(params.Message.Context(), params.Event)
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
}
|
||||
cp, err := cqrs.NewEventGroupProcessorWithConfig(router, config)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersGroup("some_group", handler)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg1.Nacked():
|
||||
// ok
|
||||
case <-msg1.Acked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-msg2.Acked():
|
||||
// ok
|
||||
case <-msg2.Nacked():
|
||||
// nack received
|
||||
}
|
||||
|
||||
assert.EqualValues(t, 2, onHandleCalled)
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_AckOnUnknownEvent_handler_group(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventGroupProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: true,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersGroup(
|
||||
"foo",
|
||||
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
// ok
|
||||
case <-msg.Nacked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_AckOnUnknownEvent_disabled_handler_group(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventGroupProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: false,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersGroup(
|
||||
"foo",
|
||||
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Nacked():
|
||||
// ok
|
||||
case <-msg.Acked():
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventProcessor_handler_group(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
event1 := &TestEvent{ID: "1"}
|
||||
|
||||
msg1, err := ts.Marshaler.Marshal(event1)
|
||||
require.NoError(t, err)
|
||||
|
||||
event2 := &AnotherTestEvent{ID: "2"}
|
||||
|
||||
msg2, err := ts.Marshaler.Marshal(event2)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg1,
|
||||
msg2,
|
||||
},
|
||||
WaitForAckBeforeSendingNext: true,
|
||||
}
|
||||
|
||||
var handlersCalls []int
|
||||
|
||||
handlers := []cqrs.GroupEventHandler{
|
||||
cqrs.NewGroupEventHandler(func(ctx context.Context, event *TestEvent) error {
|
||||
assert.EqualValues(t, event1, event)
|
||||
|
||||
handlersCalls = append(handlersCalls, 1)
|
||||
|
||||
return nil
|
||||
}),
|
||||
cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error {
|
||||
assert.EqualValues(t, event2, event)
|
||||
|
||||
handlersCalls = append(handlersCalls, 2)
|
||||
|
||||
return nil
|
||||
}),
|
||||
cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error {
|
||||
assert.EqualValues(t, event2, event)
|
||||
|
||||
handlersCalls = append(handlersCalls, 3)
|
||||
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventProcessor, err := cqrs.NewEventGroupProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
assert.Equal(t, "some_group", params.EventGroupName)
|
||||
assert.Equal(t, handlers, params.EventGroupHandlers)
|
||||
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
assert.Equal(t, "some_group", params.EventGroupName)
|
||||
assert.Equal(t, handlers, params.EventGroupHandlers)
|
||||
|
||||
return mockSub, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eventProcessor.AddHandlersGroup(
|
||||
"some_group",
|
||||
handlers...,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eventProcessor.AddHandlersGroup(
|
||||
"some_group",
|
||||
handlers...,
|
||||
)
|
||||
require.ErrorContains(t, err, "event handler group 'some_group' already exists")
|
||||
|
||||
err = eventProcessor.AddHandlersGroup(
|
||||
"some_group_2",
|
||||
)
|
||||
require.ErrorContains(t, err, "no handlers provided")
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg1.Acked():
|
||||
// ok
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("message 1 not acked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-msg2.Acked():
|
||||
// ok
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("message 2 not acked")
|
||||
}
|
||||
|
||||
assert.Equal(t, []int{1, 2, 3}, handlersCalls)
|
||||
}
|
||||
|
||||
func TestEventGroupProcessor_original_msg_set_to_ctx(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&TestEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventGroupProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventGroupProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: true,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var msgFromCtx *message.Message
|
||||
|
||||
err = cp.AddHandlersGroup(
|
||||
"some_group",
|
||||
cqrs.NewGroupEventHandler(
|
||||
func(ctx context.Context, cmd *TestEvent) error {
|
||||
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
// ok
|
||||
case <-msg.Nacked():
|
||||
// nack received
|
||||
t.Fatal("nack received, message should be acked")
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for ack")
|
||||
}
|
||||
|
||||
require.NotNil(t, msgFromCtx)
|
||||
assert.Equal(t, msg, msgFromCtx)
|
||||
}
|
||||
539
components/cqrs/event_processor_test.go
Normal file
539
components/cqrs/event_processor_test.go
Normal file
@@ -0,0 +1,539 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEventProcessorConfig_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ModifyValidConfig func(*cqrs.EventProcessorConfig)
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "valid_config",
|
||||
ModifyValidConfig: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing_GenerateHandlerSubscribeTopic",
|
||||
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
|
||||
config.GenerateSubscribeTopic = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"),
|
||||
},
|
||||
{
|
||||
Name: "missing_marshaler",
|
||||
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
|
||||
config.Marshaler = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing Marshaler"),
|
||||
},
|
||||
{
|
||||
Name: "missing_subscriber_constructor",
|
||||
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
|
||||
config.SubscriberConstructor = nil
|
||||
},
|
||||
ExpectedErr: fmt.Errorf("missing SubscriberConstructor"),
|
||||
},
|
||||
}
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
validConfig := cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
|
||||
if tc.ModifyValidConfig != nil {
|
||||
tc.ModifyValidConfig(&validConfig)
|
||||
}
|
||||
|
||||
err := validConfig.Validate()
|
||||
if tc.ExpectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.ExpectedErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventProcessor(t *testing.T) {
|
||||
eventConfig := cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: cqrs.JSONMarshaler{},
|
||||
}
|
||||
require.NoError(t, eventConfig.Validate())
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(router, eventConfig)
|
||||
assert.NotNil(t, cp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
eventConfig.SubscriberConstructor = nil
|
||||
require.Error(t, eventConfig.Validate())
|
||||
|
||||
cp, err = cqrs.NewEventProcessorWithConfig(router, eventConfig)
|
||||
assert.Nil(t, cp)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
type nonPointerEventProcessor struct {
|
||||
}
|
||||
|
||||
func (nonPointerEventProcessor) HandlerName() string {
|
||||
return "nonPointerEventProcessor"
|
||||
}
|
||||
|
||||
func (nonPointerEventProcessor) NewEvent() interface{} {
|
||||
return TestEvent{}
|
||||
}
|
||||
|
||||
func (nonPointerEventProcessor) Handle(ctx context.Context, cmd interface{}) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func TestEventProcessor_non_pointer_event(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
handler := nonPointerEventProcessor{}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eventProcessor.AddHandlers(handler)
|
||||
assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err))
|
||||
}
|
||||
|
||||
type duplicateTestEventHandler1 struct{}
|
||||
|
||||
func (h duplicateTestEventHandler1) HandlerName() string {
|
||||
return "duplicateTestEventHandler1"
|
||||
}
|
||||
|
||||
func (duplicateTestEventHandler1) NewEvent() interface{} {
|
||||
return &TestEvent{}
|
||||
}
|
||||
|
||||
func (h *duplicateTestEventHandler1) Handle(ctx context.Context, event interface{}) error { return nil }
|
||||
|
||||
type duplicateTestEventHandler2 struct{}
|
||||
|
||||
func (h duplicateTestEventHandler2) HandlerName() string {
|
||||
return "duplicateTestEventHandler2"
|
||||
}
|
||||
|
||||
func (duplicateTestEventHandler2) NewEvent() interface{} {
|
||||
return &TestEvent{}
|
||||
}
|
||||
|
||||
func (h *duplicateTestEventHandler2) Handle(ctx context.Context, event interface{}) error { return nil }
|
||||
|
||||
func TestEventProcessor_multiple_same_event_handlers(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = eventProcessor.AddHandlers(
|
||||
&duplicateTestEventHandler1{},
|
||||
&duplicateTestEventHandler2{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_OnHandle(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg1,
|
||||
msg2,
|
||||
},
|
||||
}
|
||||
|
||||
handlerCalled := 0
|
||||
|
||||
defer func() {
|
||||
// for msg 1 we are not calling handler - but returning before
|
||||
assert.Equal(t, 1, handlerCalled)
|
||||
}()
|
||||
|
||||
handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
handlerCalled++
|
||||
return nil
|
||||
})
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
onHandleCalled := int64(0)
|
||||
|
||||
config := cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
OnHandle: func(params cqrs.EventProcessorOnHandleParams) error {
|
||||
atomic.AddInt64(&onHandleCalled, 1)
|
||||
|
||||
assert.IsType(t, &TestEvent{}, params.Event)
|
||||
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
|
||||
assert.Equal(t, handler, params.Handler)
|
||||
|
||||
if params.Event.(*TestEvent).ID == "1" {
|
||||
assert.Equal(t, msg1, params.Message)
|
||||
return errors.New("test error")
|
||||
} else {
|
||||
assert.Equal(t, msg2, params.Message)
|
||||
}
|
||||
|
||||
return params.Handler.Handle(params.Message.Context(), params.Event)
|
||||
},
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
}
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(router, config)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlers(handler)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg1.Nacked():
|
||||
// ok
|
||||
case <-msg1.Acked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-msg2.Acked():
|
||||
// ok
|
||||
case <-msg2.Nacked():
|
||||
// nack received
|
||||
}
|
||||
|
||||
assert.EqualValues(t, 2, onHandleCalled)
|
||||
}
|
||||
|
||||
type UnknownEvent struct {
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_AckOnUnknownEvent(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: true,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlers(
|
||||
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
// ok
|
||||
case <-msg.Nacked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_AckOnUnknownEvent_disabled(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: false,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlers(
|
||||
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Nacked():
|
||||
// ok
|
||||
case <-msg.Acked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventProcessor_backward_compatibility_of_AckOnUnknownEvent(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
cp, err := cqrs.NewEventProcessor(
|
||||
[]cqrs.EventHandler{
|
||||
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
func(eventName string) string {
|
||||
return "events"
|
||||
},
|
||||
func(handlerName string) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
ts.Marshaler,
|
||||
ts.Logger,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersToRouter(router)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
// ok
|
||||
case <-msg.Nacked():
|
||||
// ack received
|
||||
t.Fatal("ack received, message should be nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return ts.EventsPubSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: false,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cp.AddHandlersToRouter(router)
|
||||
assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewEventProcessor")
|
||||
}
|
||||
|
||||
func TestEventProcessor_original_msg_set_to_ctx(t *testing.T) {
|
||||
ts := NewTestServices()
|
||||
|
||||
msg, err := ts.Marshaler.Marshal(&TestEvent{})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSub := &mockSubscriber{
|
||||
MessagesToSend: []*message.Message{
|
||||
msg,
|
||||
},
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
cp, err := cqrs.NewEventProcessorWithConfig(
|
||||
router,
|
||||
cqrs.EventProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "events", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return mockSub, nil
|
||||
},
|
||||
AckOnUnknownEvent: true,
|
||||
Marshaler: ts.Marshaler,
|
||||
Logger: ts.Logger,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var msgFromCtx *message.Message
|
||||
|
||||
err = cp.AddHandlers(cqrs.NewEventHandler(
|
||||
"handler", func(ctx context.Context, cmd *TestEvent) error {
|
||||
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
// ok
|
||||
case <-msg.Nacked():
|
||||
// nack received
|
||||
t.Fatal("nack received, message should be acked")
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for ack")
|
||||
}
|
||||
|
||||
require.NotNil(t, msgFromCtx)
|
||||
assert.Equal(t, msg, msgFromCtx)
|
||||
}
|
||||
55
components/cqrs/marshaler.go
Normal file
55
components/cqrs/marshaler.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
// CommandEventMarshaler marshals Commands and Events to Watermill's messages and vice versa.
|
||||
// Payload of the command needs to be marshaled to []bytes.
|
||||
type CommandEventMarshaler interface {
|
||||
// Marshal marshals Command or Event to Watermill's message.
|
||||
Marshal(v interface{}) (*message.Message, error)
|
||||
|
||||
// Unmarshal unmarshals watermill's message to v Command or Event.
|
||||
Unmarshal(msg *message.Message, v interface{}) (err error)
|
||||
|
||||
// Name returns the name of Command or Event.
|
||||
// Name is used to determine, that received command or event is event which we want to handle.
|
||||
Name(v interface{}) string
|
||||
|
||||
// NameFromMessage returns the name of Command or Event from Watermill's message (generated by Marshal).
|
||||
//
|
||||
// When we have Command or Event marshaled to Watermill's message,
|
||||
// we should use NameFromMessage instead of Name to avoid unnecessary unmarshaling.
|
||||
NameFromMessage(msg *message.Message) string
|
||||
}
|
||||
|
||||
// CommandEventMarshalerDecorator decorates CommandEventMarshaler with additional functionality.
|
||||
// It can be used to add additional metadata to the message.
|
||||
type CommandEventMarshalerDecorator struct {
|
||||
CommandEventMarshaler
|
||||
|
||||
// DecorateFunc is called after marshaling the message.
|
||||
DecorateFunc func(v any, msg *message.Message) error
|
||||
}
|
||||
|
||||
// Marshal marshals Command or Event to Watermill's message and decorates it.
|
||||
func (c CommandEventMarshalerDecorator) Marshal(v any) (*message.Message, error) {
|
||||
msg, err := c.CommandEventMarshaler.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.DecorateFunc == nil {
|
||||
return nil, errors.New("DecorateFunc is nil")
|
||||
}
|
||||
|
||||
if err := c.DecorateFunc(v, msg); err != nil {
|
||||
return nil, fmt.Errorf("cannot decorate message: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
53
components/cqrs/marshaler_json.go
Normal file
53
components/cqrs/marshaler_json.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type JSONMarshaler struct {
|
||||
NewUUID func() string
|
||||
GenerateName func(v interface{}) string
|
||||
}
|
||||
|
||||
func (m JSONMarshaler) Marshal(v interface{}) (*message.Message, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := message.NewMessage(
|
||||
m.newUUID(),
|
||||
b,
|
||||
)
|
||||
msg.Metadata.Set("name", m.Name(v))
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m JSONMarshaler) newUUID() string {
|
||||
if m.NewUUID != nil {
|
||||
return m.NewUUID()
|
||||
}
|
||||
|
||||
// default
|
||||
return watermill.NewUUID()
|
||||
}
|
||||
|
||||
func (JSONMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
|
||||
return json.Unmarshal(msg.Payload, v)
|
||||
}
|
||||
|
||||
func (m JSONMarshaler) Name(cmdOrEvent interface{}) string {
|
||||
if m.GenerateName != nil {
|
||||
return m.GenerateName(cmdOrEvent)
|
||||
}
|
||||
|
||||
return FullyQualifiedStructName(cmdOrEvent)
|
||||
}
|
||||
|
||||
func (m JSONMarshaler) NameFromMessage(msg *message.Message) string {
|
||||
return msg.Metadata.Get("name")
|
||||
}
|
||||
56
components/cqrs/marshaler_json_test.go
Normal file
56
components/cqrs/marshaler_json_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
)
|
||||
|
||||
var jsonEventToMarshal = TestEvent{
|
||||
ID: watermill.NewULID(),
|
||||
When: time.Date(2016, time.August, 15, 14, 13, 12, 0, time.UTC),
|
||||
}
|
||||
|
||||
func TestJsonMarshaler(t *testing.T) {
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
msg, err := marshaler.Marshal(jsonEventToMarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventToUnmarshal := TestEvent{}
|
||||
err = marshaler.Unmarshal(msg, &eventToUnmarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, jsonEventToMarshal, eventToUnmarshal)
|
||||
}
|
||||
|
||||
func TestJSONMarshaler_Marshal_new_uuid_set(t *testing.T) {
|
||||
marshaler := cqrs.JSONMarshaler{
|
||||
NewUUID: func() string {
|
||||
return "foo"
|
||||
},
|
||||
}
|
||||
|
||||
msg, err := marshaler.Marshal(jsonEventToMarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, msg.UUID, "foo")
|
||||
}
|
||||
|
||||
func TestJSONMarshaler_Marshal_generate_name(t *testing.T) {
|
||||
marshaler := cqrs.JSONMarshaler{
|
||||
GenerateName: func(v interface{}) string {
|
||||
return "foo"
|
||||
},
|
||||
}
|
||||
|
||||
msg, err := marshaler.Marshal(jsonEventToMarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, msg.Metadata.Get("name"), "foo")
|
||||
}
|
||||
85
components/cqrs/marshaler_protobuf.go
Normal file
85
components/cqrs/marshaler_protobuf.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ProtoMarshaler is the default Protocol Buffers marshaler.
|
||||
type ProtoMarshaler struct {
|
||||
NewUUID func() string
|
||||
GenerateName func(v interface{}) string
|
||||
}
|
||||
|
||||
// NoProtoMessageError is returned when the given value does not implement proto.Message.
|
||||
type NoProtoMessageError struct {
|
||||
v interface{}
|
||||
}
|
||||
|
||||
func (e NoProtoMessageError) Error() string {
|
||||
rv := reflect.ValueOf(e.v)
|
||||
if rv.Kind() != reflect.Pointer {
|
||||
return "v is not proto.Message, you must pass pointer value to implement proto.Message"
|
||||
}
|
||||
|
||||
return "v is not proto.Message"
|
||||
}
|
||||
|
||||
// Marshal marshals the given protobuf's message into watermill's Message.
|
||||
func (m ProtoMarshaler) Marshal(v interface{}) (*message.Message, error) {
|
||||
protoMsg, ok := v.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errors.WithStack(NoProtoMessageError{v})
|
||||
}
|
||||
|
||||
b, err := proto.Marshal(protoMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := message.NewMessage(
|
||||
m.newUUID(),
|
||||
b,
|
||||
)
|
||||
msg.Metadata.Set("name", m.Name(v))
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m ProtoMarshaler) newUUID() string {
|
||||
if m.NewUUID != nil {
|
||||
return m.NewUUID()
|
||||
}
|
||||
|
||||
// default
|
||||
return watermill.NewUUID()
|
||||
}
|
||||
|
||||
// Unmarshal unmarshals given watermill's Message into protobuf's message.
|
||||
func (ProtoMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
|
||||
protoV, ok := v.(proto.Message)
|
||||
if !ok {
|
||||
return errors.WithStack(NoProtoMessageError{v})
|
||||
}
|
||||
|
||||
return proto.Unmarshal(msg.Payload, protoV)
|
||||
}
|
||||
|
||||
// Name returns the command or event's name.
|
||||
func (m ProtoMarshaler) Name(cmdOrEvent interface{}) string {
|
||||
if m.GenerateName != nil {
|
||||
return m.GenerateName(cmdOrEvent)
|
||||
}
|
||||
|
||||
return FullyQualifiedStructName(cmdOrEvent)
|
||||
}
|
||||
|
||||
// NameFromMessage returns the metadata name value for a given Message.
|
||||
func (m ProtoMarshaler) NameFromMessage(msg *message.Message) string {
|
||||
return msg.Metadata.Get("name")
|
||||
}
|
||||
483
components/cqrs/marshaler_protobuf_events_new_test.go
Normal file
483
components/cqrs/marshaler_protobuf_events_new_test.go
Normal file
@@ -0,0 +1,483 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.31.0
|
||||
// protoc v4.24.4
|
||||
// source: events.proto
|
||||
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type Status int32
|
||||
|
||||
const (
|
||||
Status_STATUS_UNSPECIFIED Status = 0
|
||||
Status_ACTIVE Status = 1
|
||||
Status_DELETED Status = 2
|
||||
)
|
||||
|
||||
// Enum value maps for Status.
|
||||
var (
|
||||
Status_name = map[int32]string{
|
||||
0: "STATUS_UNSPECIFIED",
|
||||
1: "ACTIVE",
|
||||
2: "DELETED",
|
||||
}
|
||||
Status_value = map[string]int32{
|
||||
"STATUS_UNSPECIFIED": 0,
|
||||
"ACTIVE": 1,
|
||||
"DELETED": 2,
|
||||
}
|
||||
)
|
||||
|
||||
func (x Status) Enum() *Status {
|
||||
p := new(Status)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x Status) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (Status) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_events_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (Status) Type() protoreflect.EnumType {
|
||||
return &file_events_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x Status) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Status.Descriptor instead.
|
||||
func (Status) EnumDescriptor() ([]byte, []int) {
|
||||
return file_events_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type TestProtobufLegacyEvent struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
|
||||
}
|
||||
|
||||
func (x *TestProtobufLegacyEvent) Reset() {
|
||||
*x = TestProtobufLegacyEvent{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_events_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *TestProtobufLegacyEvent) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TestProtobufLegacyEvent) ProtoMessage() {}
|
||||
|
||||
func (x *TestProtobufLegacyEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_events_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TestProtobufLegacyEvent.ProtoReflect.Descriptor instead.
|
||||
func (*TestProtobufLegacyEvent) Descriptor() ([]byte, []int) {
|
||||
return file_events_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *TestProtobufLegacyEvent) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TestProtobufLegacyEvent) GetWhen() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.When
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SubEvent struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Tags []string `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||
Flags map[string]bool `protobuf:"bytes,2,rep,name=flags,proto3" json:"flags,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
|
||||
}
|
||||
|
||||
func (x *SubEvent) Reset() {
|
||||
*x = SubEvent{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_events_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SubEvent) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SubEvent) ProtoMessage() {}
|
||||
|
||||
func (x *SubEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_events_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SubEvent.ProtoReflect.Descriptor instead.
|
||||
func (*SubEvent) Descriptor() ([]byte, []int) {
|
||||
return file_events_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *SubEvent) GetTags() []string {
|
||||
if x != nil {
|
||||
return x.Tags
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SubEvent) GetFlags() map[string]bool {
|
||||
if x != nil {
|
||||
return x.Flags
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TestComplexProtobufEvent struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
|
||||
When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
|
||||
// Complex fields to test edge cases
|
||||
NestedMap map[string]*SubEvent `protobuf:"bytes,4,rep,name=nested_map,json=nestedMap,proto3" json:"nested_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
Events []*SubEvent `protobuf:"bytes,5,rep,name=events,proto3" json:"events,omitempty"`
|
||||
// Types that are assignable to Result:
|
||||
//
|
||||
// *TestComplexProtobufEvent_Success
|
||||
// *TestComplexProtobufEvent_Error
|
||||
// *TestComplexProtobufEvent_Fallback
|
||||
Result isTestComplexProtobufEvent_Result `protobuf_oneof:"result"`
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) Reset() {
|
||||
*x = TestComplexProtobufEvent{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_events_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TestComplexProtobufEvent) ProtoMessage() {}
|
||||
|
||||
func (x *TestComplexProtobufEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_events_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TestComplexProtobufEvent.ProtoReflect.Descriptor instead.
|
||||
func (*TestComplexProtobufEvent) Descriptor() ([]byte, []int) {
|
||||
return file_events_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetData() []byte {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetWhen() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.When
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetNestedMap() map[string]*SubEvent {
|
||||
if x != nil {
|
||||
return x.NestedMap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetEvents() []*SubEvent {
|
||||
if x != nil {
|
||||
return x.Events
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *TestComplexProtobufEvent) GetResult() isTestComplexProtobufEvent_Result {
|
||||
if m != nil {
|
||||
return m.Result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetSuccess() *SubEvent {
|
||||
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Success); ok {
|
||||
return x.Success
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetError() string {
|
||||
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Error); ok {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TestComplexProtobufEvent) GetFallback() Status {
|
||||
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Fallback); ok {
|
||||
return x.Fallback
|
||||
}
|
||||
return Status_STATUS_UNSPECIFIED
|
||||
}
|
||||
|
||||
type isTestComplexProtobufEvent_Result interface {
|
||||
isTestComplexProtobufEvent_Result()
|
||||
}
|
||||
|
||||
type TestComplexProtobufEvent_Success struct {
|
||||
Success *SubEvent `protobuf:"bytes,6,opt,name=success,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TestComplexProtobufEvent_Error struct {
|
||||
Error string `protobuf:"bytes,7,opt,name=error,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TestComplexProtobufEvent_Fallback struct {
|
||||
Fallback Status `protobuf:"varint,8,opt,name=fallback,proto3,enum=cqrs_test.Status,oneof"`
|
||||
}
|
||||
|
||||
func (*TestComplexProtobufEvent_Success) isTestComplexProtobufEvent_Result() {}
|
||||
|
||||
func (*TestComplexProtobufEvent_Error) isTestComplexProtobufEvent_Result() {}
|
||||
|
||||
func (*TestComplexProtobufEvent_Fallback) isTestComplexProtobufEvent_Result() {}
|
||||
|
||||
var File_events_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_events_proto_rawDesc = []byte{
|
||||
0x0a, 0x0c, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09,
|
||||
0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73,
|
||||
0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x59, 0x0a, 0x17, 0x54, 0x65,
|
||||
0x73, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x4c, 0x65, 0x67, 0x61, 0x63, 0x79,
|
||||
0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
|
||||
0x04, 0x77, 0x68, 0x65, 0x6e, 0x22, 0x8e, 0x01, 0x0a, 0x08, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65,
|
||||
0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09,
|
||||
0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18,
|
||||
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73,
|
||||
0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x1a, 0x38, 0x0a, 0x0a,
|
||||
0x46, 0x6c, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xcb, 0x03, 0x0a, 0x18, 0x54, 0x65, 0x73, 0x74, 0x43,
|
||||
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76,
|
||||
0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x12, 0x51, 0x0a, 0x0a, 0x6e, 0x65, 0x73, 0x74, 0x65,
|
||||
0x64, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x63, 0x71,
|
||||
0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70,
|
||||
0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52,
|
||||
0x09, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x2b, 0x0a, 0x06, 0x65, 0x76,
|
||||
0x65, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72,
|
||||
0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52,
|
||||
0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2f, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65,
|
||||
0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f,
|
||||
0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52,
|
||||
0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f,
|
||||
0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
|
||||
0x12, 0x2f, 0x0a, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x08, 0x20, 0x01,
|
||||
0x28, 0x0e, 0x32, 0x11, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63,
|
||||
0x6b, 0x1a, 0x51, 0x0a, 0x0e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74,
|
||||
0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x3a, 0x02, 0x38, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x4a, 0x04,
|
||||
0x08, 0x17, 0x10, 0x1f, 0x2a, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16,
|
||||
0x0a, 0x12, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49,
|
||||
0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45,
|
||||
0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x42,
|
||||
0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x62, 0x06,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_events_proto_rawDescOnce sync.Once
|
||||
file_events_proto_rawDescData = file_events_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_events_proto_rawDescGZIP() []byte {
|
||||
file_events_proto_rawDescOnce.Do(func() {
|
||||
file_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_events_proto_rawDescData)
|
||||
})
|
||||
return file_events_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_events_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_events_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_events_proto_goTypes = []interface{}{
|
||||
(Status)(0), // 0: cqrs_test.Status
|
||||
(*TestProtobufLegacyEvent)(nil), // 1: cqrs_test.TestProtobufLegacyEvent
|
||||
(*SubEvent)(nil), // 2: cqrs_test.SubEvent
|
||||
(*TestComplexProtobufEvent)(nil), // 3: cqrs_test.TestComplexProtobufEvent
|
||||
nil, // 4: cqrs_test.SubEvent.FlagsEntry
|
||||
nil, // 5: cqrs_test.TestComplexProtobufEvent.NestedMapEntry
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
}
|
||||
var file_events_proto_depIdxs = []int32{
|
||||
6, // 0: cqrs_test.TestProtobufLegacyEvent.when:type_name -> google.protobuf.Timestamp
|
||||
4, // 1: cqrs_test.SubEvent.flags:type_name -> cqrs_test.SubEvent.FlagsEntry
|
||||
6, // 2: cqrs_test.TestComplexProtobufEvent.when:type_name -> google.protobuf.Timestamp
|
||||
5, // 3: cqrs_test.TestComplexProtobufEvent.nested_map:type_name -> cqrs_test.TestComplexProtobufEvent.NestedMapEntry
|
||||
2, // 4: cqrs_test.TestComplexProtobufEvent.events:type_name -> cqrs_test.SubEvent
|
||||
2, // 5: cqrs_test.TestComplexProtobufEvent.success:type_name -> cqrs_test.SubEvent
|
||||
0, // 6: cqrs_test.TestComplexProtobufEvent.fallback:type_name -> cqrs_test.Status
|
||||
2, // 7: cqrs_test.TestComplexProtobufEvent.NestedMapEntry.value:type_name -> cqrs_test.SubEvent
|
||||
8, // [8:8] is the sub-list for method output_type
|
||||
8, // [8:8] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_events_proto_init() }
|
||||
func file_events_proto_init() {
|
||||
if File_events_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_events_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*TestProtobufLegacyEvent); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_events_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SubEvent); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_events_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*TestComplexProtobufEvent); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
file_events_proto_msgTypes[2].OneofWrappers = []interface{}{
|
||||
(*TestComplexProtobufEvent_Success)(nil),
|
||||
(*TestComplexProtobufEvent_Error)(nil),
|
||||
(*TestComplexProtobufEvent_Fallback)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_events_proto_rawDesc,
|
||||
NumEnums: 1,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_events_proto_goTypes,
|
||||
DependencyIndexes: file_events_proto_depIdxs,
|
||||
EnumInfos: file_events_proto_enumTypes,
|
||||
MessageInfos: file_events_proto_msgTypes,
|
||||
}.Build()
|
||||
File_events_proto = out.File
|
||||
file_events_proto_rawDesc = nil
|
||||
file_events_proto_goTypes = nil
|
||||
file_events_proto_depIdxs = nil
|
||||
}
|
||||
90
components/cqrs/marshaler_protobuf_events_test.go
Normal file
90
components/cqrs/marshaler_protobuf_events_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// source: testdata/events.proto
|
||||
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
math "math"
|
||||
|
||||
proto "github.com/golang/protobuf/proto"
|
||||
timestamp "github.com/golang/protobuf/ptypes/timestamp"
|
||||
)
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ = proto.Marshal
|
||||
var _ = fmt.Errorf
|
||||
var _ = math.Inf
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the proto package it is being compiled against.
|
||||
// A compilation error at this line likely means your copy of the
|
||||
// proto package needs to be updated.
|
||||
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package
|
||||
|
||||
type TestProtobufEvent struct {
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
When *timestamp.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *TestProtobufEvent) Reset() { *m = TestProtobufEvent{} }
|
||||
func (m *TestProtobufEvent) String() string { return proto.CompactTextString(m) }
|
||||
func (*TestProtobufEvent) ProtoMessage() {}
|
||||
func (*TestProtobufEvent) Descriptor() ([]byte, []int) {
|
||||
return fileDescriptor_37faf0ac8d97ee4c, []int{0}
|
||||
}
|
||||
|
||||
func (m *TestProtobufEvent) XXX_Unmarshal(b []byte) error {
|
||||
return xxx_messageInfo_TestProtobufEvent.Unmarshal(m, b)
|
||||
}
|
||||
func (m *TestProtobufEvent) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
|
||||
return xxx_messageInfo_TestProtobufEvent.Marshal(b, m, deterministic)
|
||||
}
|
||||
func (m *TestProtobufEvent) XXX_Merge(src proto.Message) {
|
||||
xxx_messageInfo_TestProtobufEvent.Merge(m, src)
|
||||
}
|
||||
func (m *TestProtobufEvent) XXX_Size() int {
|
||||
return xxx_messageInfo_TestProtobufEvent.Size(m)
|
||||
}
|
||||
func (m *TestProtobufEvent) XXX_DiscardUnknown() {
|
||||
xxx_messageInfo_TestProtobufEvent.DiscardUnknown(m)
|
||||
}
|
||||
|
||||
var xxx_messageInfo_TestProtobufEvent proto.InternalMessageInfo
|
||||
|
||||
func (m *TestProtobufEvent) GetId() string {
|
||||
if m != nil {
|
||||
return m.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *TestProtobufEvent) GetWhen() *timestamp.Timestamp {
|
||||
if m != nil {
|
||||
return m.When
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
proto.RegisterType((*TestProtobufEvent)(nil), "cqrs_test.TestProtobufEvent")
|
||||
}
|
||||
|
||||
func init() { proto.RegisterFile("testdata/events.proto", fileDescriptor_37faf0ac8d97ee4c) }
|
||||
|
||||
var fileDescriptor_37faf0ac8d97ee4c = []byte{
|
||||
// 146 bytes of a gzipped FileDescriptorProto
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2d, 0x49, 0x2d, 0x2e,
|
||||
0x49, 0x49, 0x2c, 0x49, 0xd4, 0x4f, 0x2d, 0x4b, 0xcd, 0x2b, 0x29, 0xd6, 0x2b, 0x28, 0xca, 0x2f,
|
||||
0xc9, 0x17, 0xe2, 0x4c, 0x2e, 0x2c, 0x2a, 0x8e, 0x07, 0xc9, 0x49, 0xc9, 0xa7, 0xe7, 0xe7, 0xa7,
|
||||
0xe7, 0xa4, 0xea, 0x83, 0x25, 0x92, 0x4a, 0xd3, 0xf4, 0x4b, 0x32, 0x73, 0x53, 0x8b, 0x4b, 0x12,
|
||||
0x73, 0x0b, 0x20, 0x6a, 0x95, 0x82, 0xb9, 0x04, 0x43, 0x52, 0x8b, 0x4b, 0x02, 0xa0, 0xf2, 0xae,
|
||||
0x20, 0x73, 0x84, 0xf8, 0xb8, 0x98, 0x32, 0x53, 0x24, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0x98,
|
||||
0x32, 0x53, 0x84, 0xf4, 0xb8, 0x58, 0xca, 0x33, 0x52, 0xf3, 0x24, 0x98, 0x15, 0x18, 0x35, 0xb8,
|
||||
0x8d, 0xa4, 0xf4, 0x20, 0x86, 0xea, 0xc1, 0x0c, 0xd5, 0x0b, 0x81, 0x19, 0x1a, 0x04, 0x56, 0x97,
|
||||
0xc4, 0x06, 0x96, 0x31, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0xff, 0x4e, 0x39, 0x0e, 0xa0, 0x00,
|
||||
0x00, 0x00,
|
||||
}
|
||||
127
components/cqrs/marshaler_protobuf_gogo.go
Normal file
127
components/cqrs/marshaler_protobuf_gogo.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
stderrors "errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"github.com/pkg/errors"
|
||||
stdproto "google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// ProtobufMarshaler a protobuf marshaler using github.com/gogo/protobuf/proto (deprecated).
|
||||
//
|
||||
// DEPRECATED: Use ProtoMarshaler instead. This marshaler will not work with newer protobuf files.
|
||||
// IMPORTANT: This marshaler is backward and forward compatible with ProtoMarshaler.
|
||||
// ProtobufMarshaler from Watermill versions until v1.4.3 are not forward compatible with ProtoMarshaler.
|
||||
// Suggested migration steps:
|
||||
// 1. Update Watermill to v1.4.4 or newer, so all publishers and subscribers will be forward and backward compatible.
|
||||
// 2. Change all usages of ProtobufMarshaler to ProtoMarshaler.
|
||||
type ProtobufMarshaler struct {
|
||||
NewUUID func() string
|
||||
GenerateName func(v interface{}) string
|
||||
|
||||
// DisableStdProtoFallback disables fallback to github.com/golang/protobuf/proto when github.com/gogo/protobuf/proto
|
||||
// because receiving a message that was marshaled with github.com/golang/protobuf/proto.
|
||||
// Fallback is enabled by default to enable migration to ProtoMarshaler.
|
||||
DisableStdProtoFallback bool
|
||||
}
|
||||
|
||||
// Marshal marshals the given protobuf's message into watermill's Message.
|
||||
func (m ProtobufMarshaler) Marshal(v interface{}) (msg *message.Message, err error) {
|
||||
defer func() {
|
||||
// gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof)
|
||||
if r := recover(); r != nil {
|
||||
err = stderrors.Join(err, fmt.Errorf(
|
||||
"github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s",
|
||||
r,
|
||||
string(debug.Stack()),
|
||||
))
|
||||
}
|
||||
|
||||
if err != nil && !m.DisableStdProtoFallback {
|
||||
_, isStdProtoMsg := v.(stdproto.Message)
|
||||
|
||||
if isStdProtoMsg {
|
||||
msg, err = m.ToProtoMarshaler().Marshal(v)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
protoMsg, ok := v.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errors.WithStack(NoProtoMessageError{v})
|
||||
}
|
||||
|
||||
b, err := proto.Marshal(protoMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg = message.NewMessage(
|
||||
m.newUUID(),
|
||||
b,
|
||||
)
|
||||
msg.Metadata.Set("name", m.Name(v))
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m ProtobufMarshaler) newUUID() string {
|
||||
if m.NewUUID != nil {
|
||||
return m.NewUUID()
|
||||
}
|
||||
|
||||
// default
|
||||
return watermill.NewUUID()
|
||||
}
|
||||
|
||||
// Unmarshal unmarshals given watermill's Message into protobuf's message.
|
||||
func (m ProtobufMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
|
||||
protoV, ok := v.(proto.Message)
|
||||
if !ok {
|
||||
return errors.WithStack(NoProtoMessageError{v})
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof)
|
||||
if r := recover(); r != nil {
|
||||
err = stderrors.Join(err, fmt.Errorf(
|
||||
"github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s",
|
||||
r,
|
||||
string(debug.Stack()),
|
||||
))
|
||||
}
|
||||
|
||||
if err != nil && !m.DisableStdProtoFallback {
|
||||
err = m.ToProtoMarshaler().Unmarshal(msg, v)
|
||||
}
|
||||
}()
|
||||
|
||||
return proto.Unmarshal(msg.Payload, protoV)
|
||||
}
|
||||
|
||||
func (m ProtobufMarshaler) ToProtoMarshaler() ProtoMarshaler {
|
||||
return ProtoMarshaler{
|
||||
NewUUID: m.NewUUID,
|
||||
GenerateName: m.GenerateName,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the command or event's name.
|
||||
func (m ProtobufMarshaler) Name(cmdOrEvent interface{}) string {
|
||||
if m.GenerateName != nil {
|
||||
return m.GenerateName(cmdOrEvent)
|
||||
}
|
||||
|
||||
return FullyQualifiedStructName(cmdOrEvent)
|
||||
}
|
||||
|
||||
// NameFromMessage returns the metadata name value for a given Message.
|
||||
func (m ProtobufMarshaler) NameFromMessage(msg *message.Message) string {
|
||||
return msg.Metadata.Get("name")
|
||||
}
|
||||
142
components/cqrs/marshaler_protobuf_gogo_test.go
Normal file
142
components/cqrs/marshaler_protobuf_gogo_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProtobufMarshaler_with_fallback(t *testing.T) {
|
||||
marshaler := cqrs.ProtobufMarshaler{}
|
||||
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
marshaler,
|
||||
marshaler,
|
||||
newProtoTestComplexEvent(),
|
||||
&TestComplexProtobufEvent{},
|
||||
"cqrs_test.TestComplexProtobufEvent",
|
||||
)
|
||||
|
||||
legacyEvent, _ := newProtoLegacyTestEvent()
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
marshaler,
|
||||
marshaler,
|
||||
legacyEvent,
|
||||
&TestProtobufEvent{},
|
||||
"cqrs_test.TestProtobufEvent",
|
||||
)
|
||||
}
|
||||
|
||||
func TestProtobufMarshaler_without_fallback_legacy_event(t *testing.T) {
|
||||
legacyEvent, _ := newProtoLegacyTestEvent()
|
||||
|
||||
marshaler := cqrs.ProtobufMarshaler{
|
||||
DisableStdProtoFallback: true,
|
||||
}
|
||||
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
marshaler,
|
||||
marshaler,
|
||||
legacyEvent,
|
||||
&TestProtobufEvent{},
|
||||
"cqrs_test.TestProtobufEvent",
|
||||
)
|
||||
}
|
||||
|
||||
func TestProtobufMarshaler_Marshal_generated_name(t *testing.T) {
|
||||
marshaler := cqrs.ProtobufMarshaler{
|
||||
NewUUID: func() string {
|
||||
return "foo"
|
||||
},
|
||||
}
|
||||
|
||||
msg, err := marshaler.Marshal(newProtoTestComplexEvent())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, msg.UUID, "foo")
|
||||
}
|
||||
|
||||
func TestProtobufMarshaler_catch_panic(t *testing.T) {
|
||||
marshalerNoFallback := cqrs.ProtobufMarshaler{
|
||||
DisableStdProtoFallback: true,
|
||||
}
|
||||
marshalerWithFallback := cqrs.ProtobufMarshaler{
|
||||
DisableStdProtoFallback: false,
|
||||
}
|
||||
|
||||
complexEvent := newProtoTestComplexEvent()
|
||||
|
||||
msg, err := marshalerNoFallback.Marshal(complexEvent)
|
||||
assert.Nil(t, msg)
|
||||
assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)")
|
||||
assert.ErrorContains(t, err, "invalid memory address or nil pointer dereference")
|
||||
assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic")
|
||||
assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace")
|
||||
|
||||
// let's simulate situation when publishing service uses fallback and consuming service does not
|
||||
msg, err = marshalerWithFallback.Marshal(complexEvent)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = marshalerNoFallback.Unmarshal(msg, &TestComplexProtobufEvent{})
|
||||
assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)")
|
||||
assert.ErrorContains(t, err, "protobuf tag not enough fields in TestComplexProtobufEvent.state")
|
||||
assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic")
|
||||
assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace")
|
||||
|
||||
// marshaler with fallback should handle this message
|
||||
err = marshalerWithFallback.Unmarshal(msg, &TestComplexProtobufEvent{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestProtobufMarshaler_compatible_with_ProtoMarshaler(t *testing.T) {
|
||||
legacyEvent, legacyEventRegenerated := newProtoLegacyTestEvent()
|
||||
complexEvent := newProtoTestComplexEvent()
|
||||
|
||||
deprecatedMarshaler := cqrs.ProtobufMarshaler{}
|
||||
newMarshaler := cqrs.ProtoMarshaler{}
|
||||
|
||||
t.Run("from_deprecated_to_new", func(t *testing.T) {
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
deprecatedMarshaler,
|
||||
newMarshaler,
|
||||
complexEvent,
|
||||
&TestComplexProtobufEvent{},
|
||||
"cqrs_test.TestComplexProtobufEvent",
|
||||
)
|
||||
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
deprecatedMarshaler,
|
||||
newMarshaler,
|
||||
legacyEvent,
|
||||
&TestProtobufLegacyEvent{},
|
||||
"cqrs_test.TestProtobufEvent",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("from_new_to_deprecated", func(t *testing.T) {
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
newMarshaler,
|
||||
deprecatedMarshaler,
|
||||
complexEvent,
|
||||
&TestComplexProtobufEvent{},
|
||||
"cqrs_test.TestComplexProtobufEvent",
|
||||
)
|
||||
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
newMarshaler,
|
||||
deprecatedMarshaler,
|
||||
legacyEventRegenerated,
|
||||
&TestProtobufEvent{},
|
||||
"cqrs_test.TestProtobufLegacyEvent",
|
||||
)
|
||||
})
|
||||
}
|
||||
114
components/cqrs/marshaler_protobuf_test.go
Normal file
114
components/cqrs/marshaler_protobuf_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
)
|
||||
|
||||
func TestProtoMarshaler(t *testing.T) {
|
||||
assertProtoMarshalUnmarshal(
|
||||
t,
|
||||
cqrs.ProtoMarshaler{},
|
||||
cqrs.ProtoMarshaler{},
|
||||
newProtoTestComplexEvent(),
|
||||
&TestComplexProtobufEvent{},
|
||||
"cqrs_test.TestComplexProtobufEvent",
|
||||
)
|
||||
}
|
||||
|
||||
func TestProtoMarshaler_Marshal_generated_name(t *testing.T) {
|
||||
marshaler := cqrs.ProtoMarshaler{
|
||||
NewUUID: func() string {
|
||||
return "foo"
|
||||
},
|
||||
}
|
||||
|
||||
msg, err := marshaler.Marshal(newProtoTestComplexEvent())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, msg.UUID, "foo")
|
||||
}
|
||||
|
||||
// newProtoLegacyTestEvent returns the same event in two different protobuf versions
|
||||
func newProtoLegacyTestEvent() (*TestProtobufEvent, *TestProtobufLegacyEvent) {
|
||||
when := timestamppb.New(time.Now())
|
||||
id := watermill.NewULID()
|
||||
|
||||
legacy := &TestProtobufEvent{
|
||||
Id: id,
|
||||
When: when,
|
||||
}
|
||||
|
||||
regenerated := &TestProtobufLegacyEvent{
|
||||
Id: id,
|
||||
When: when,
|
||||
}
|
||||
|
||||
return legacy, regenerated
|
||||
}
|
||||
|
||||
func newProtoTestComplexEvent() *TestComplexProtobufEvent {
|
||||
when := timestamppb.New(time.Now())
|
||||
|
||||
eventToMarshal := &TestComplexProtobufEvent{
|
||||
Id: watermill.NewULID(),
|
||||
Data: []byte("data"),
|
||||
When: when,
|
||||
NestedMap: map[string]*SubEvent{
|
||||
"foo": {
|
||||
Tags: []string{"tag1", "tag2"},
|
||||
Flags: map[string]bool{"flag1": true, "flag2": false},
|
||||
},
|
||||
},
|
||||
Events: []*SubEvent{
|
||||
{
|
||||
Tags: []string{"tag1", "tag2"},
|
||||
},
|
||||
{
|
||||
Tags: []string{"tag3", "tag4"},
|
||||
},
|
||||
},
|
||||
Result: &TestComplexProtobufEvent_Success{
|
||||
Success: &SubEvent{
|
||||
Tags: []string{"tag10"},
|
||||
Flags: map[string]bool{"flag10": true},
|
||||
},
|
||||
},
|
||||
}
|
||||
return eventToMarshal
|
||||
}
|
||||
|
||||
func assertProtoMarshalUnmarshal[T1, T2 fmt.Stringer](
|
||||
t *testing.T,
|
||||
marshaler cqrs.CommandEventMarshaler,
|
||||
unmarshaler cqrs.CommandEventMarshaler,
|
||||
eventToMarshal T1,
|
||||
eventToUnmarshal T2,
|
||||
expectedEventName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
msg, err := marshaler.Marshal(eventToMarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = unmarshaler.Unmarshal(msg, eventToUnmarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventToMarshalJson, err := json.Marshal(eventToMarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
eventToUnmarshalJson, err := json.Marshal(eventToUnmarshal)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, string(eventToMarshalJson), string(eventToUnmarshalJson))
|
||||
assert.Equal(t, expectedEventName, msg.Metadata.Get("name"))
|
||||
}
|
||||
58
components/cqrs/name.go
Normal file
58
components/cqrs/name.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FullyQualifiedStructName returns object name in format [package].[type name].
|
||||
// For example, for the struct:
|
||||
//
|
||||
// package events
|
||||
// type UserCreated struct {}
|
||||
//
|
||||
// it will return "events.UserCreated".
|
||||
//
|
||||
// It ignores if the value is a pointer or not.
|
||||
func FullyQualifiedStructName(v interface{}) string {
|
||||
s := fmt.Sprintf("%T", v)
|
||||
s = strings.TrimLeft(s, "*")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// StructName returns struct name in format [type name].
|
||||
// For example, for the struct:
|
||||
//
|
||||
// package events
|
||||
// type UserCreated struct {}
|
||||
//
|
||||
// it will return "UserCreated".
|
||||
//
|
||||
// It ignores if the value is a pointer or not.
|
||||
func StructName(v interface{}) string {
|
||||
segments := strings.Split(fmt.Sprintf("%T", v), ".")
|
||||
|
||||
return segments[len(segments)-1]
|
||||
}
|
||||
|
||||
type namedStruct interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
// NamedStruct returns the name from a message implementing the following interface:
|
||||
//
|
||||
// type namedStruct interface {
|
||||
// Name() string
|
||||
// }
|
||||
//
|
||||
// It ignores if the value is a pointer or not.
|
||||
func NamedStruct(fallback func(v interface{}) string) func(v interface{}) string {
|
||||
return func(v interface{}) string {
|
||||
if v, ok := v.(namedStruct); ok {
|
||||
return v.Name()
|
||||
}
|
||||
|
||||
return fallback(v)
|
||||
}
|
||||
}
|
||||
49
components/cqrs/name_test.go
Normal file
49
components/cqrs/name_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cqrs_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
)
|
||||
|
||||
func TestFullyQualifiedStructName(t *testing.T) {
|
||||
type Object struct{}
|
||||
|
||||
assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(Object{}))
|
||||
assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(&Object{}))
|
||||
}
|
||||
|
||||
func BenchmarkFullyQualifiedStructName(b *testing.B) {
|
||||
type Object struct{}
|
||||
o := Object{}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
cqrs.FullyQualifiedStructName(o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructName(t *testing.T) {
|
||||
type Object struct{}
|
||||
|
||||
assert.Equal(t, "Object", cqrs.StructName(Object{}))
|
||||
assert.Equal(t, "Object", cqrs.StructName(&Object{}))
|
||||
}
|
||||
|
||||
func TestNamedStruct(t *testing.T) {
|
||||
assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(namedObject{}))
|
||||
assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(&namedObject{}))
|
||||
|
||||
// Test fallback
|
||||
type Object struct{}
|
||||
|
||||
assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(Object{}))
|
||||
assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(&Object{}))
|
||||
}
|
||||
|
||||
type namedObject struct{}
|
||||
|
||||
func (namedObject) Name() string {
|
||||
return "named object"
|
||||
}
|
||||
23
components/cqrs/object.go
Normal file
23
components/cqrs/object.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package cqrs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func isPointer(v interface{}) error {
|
||||
rv := reflect.ValueOf(v)
|
||||
|
||||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||
return NonPointerError{rv.Type()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type NonPointerError struct {
|
||||
Type reflect.Type
|
||||
}
|
||||
|
||||
func (e NonPointerError) Error() string {
|
||||
return "non-pointer command: " + e.Type.String() + ", handler.NewCommand() should return pointer to the command"
|
||||
}
|
||||
38
components/cqrs/testdata/events.proto
vendored
Normal file
38
components/cqrs/testdata/events.proto
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
syntax = "proto3";
|
||||
package cqrs_test;
|
||||
option go_package = "./cqrs_test";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
message TestProtobufLegacyEvent {
|
||||
string id = 1;
|
||||
google.protobuf.Timestamp when = 3;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
ACTIVE = 1;
|
||||
DELETED = 2;
|
||||
}
|
||||
|
||||
message SubEvent {
|
||||
repeated string tags = 1;
|
||||
map<string, bool> flags = 2;
|
||||
}
|
||||
|
||||
message TestComplexProtobufEvent {
|
||||
string id = 1;
|
||||
bytes data = 2;
|
||||
google.protobuf.Timestamp when = 3;
|
||||
|
||||
map<string, SubEvent> nested_map = 4;
|
||||
repeated SubEvent events = 5;
|
||||
|
||||
oneof result {
|
||||
SubEvent success = 6;
|
||||
string error = 7;
|
||||
Status fallback = 8;
|
||||
}
|
||||
|
||||
reserved 23 to 30;
|
||||
}
|
||||
68
components/delay/delay.go
Normal file
68
components/delay/delay.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package delay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
// Delay represents a message's delay.
|
||||
// It can be either a delay until a specific time or a delay for a specific duration.
|
||||
// The zero value of Delay is a zero delay.
|
||||
//
|
||||
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
|
||||
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
|
||||
type Delay struct {
|
||||
time time.Time
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
func (d Delay) IsZero() bool {
|
||||
return d.time.IsZero()
|
||||
}
|
||||
|
||||
// Until returns a delay of the given time.
|
||||
func Until(delayedUntil time.Time) Delay {
|
||||
return Delay{
|
||||
time: delayedUntil,
|
||||
duration: delayedUntil.Sub(time.Now().UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
// For returns a delay of now plus the given duration.
|
||||
func For(delayedFor time.Duration) Delay {
|
||||
return Delay{
|
||||
time: time.Now().UTC().Add(delayedFor),
|
||||
duration: delayedFor,
|
||||
}
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
var (
|
||||
delayContextKey = contextKey("delay")
|
||||
)
|
||||
|
||||
// WithContext returns a new context with the given delay.
|
||||
// If used together with a publisher wrapped with NewPublisher, the delay will be applied to the message.
|
||||
//
|
||||
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
|
||||
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
|
||||
func WithContext(ctx context.Context, delay Delay) context.Context {
|
||||
return context.WithValue(ctx, delayContextKey, delay)
|
||||
}
|
||||
|
||||
const (
|
||||
DelayedUntilKey = "_watermill_delayed_until"
|
||||
DelayedForKey = "_watermill_delayed_for"
|
||||
)
|
||||
|
||||
// Message sets the delay metadata on the message.
|
||||
//
|
||||
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
|
||||
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
|
||||
func Message(msg *message.Message, delay Delay) {
|
||||
msg.Metadata.Set(DelayedUntilKey, delay.time.Format(time.RFC3339))
|
||||
msg.Metadata.Set(DelayedForKey, delay.duration.String())
|
||||
}
|
||||
83
components/delay/publisher.go
Normal file
83
components/delay/publisher.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package delay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type DefaultDelayGeneratorParams struct {
|
||||
Topic string
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// PublisherConfig is a configuration for the delay publisher.
|
||||
type PublisherConfig struct {
|
||||
// DefaultDelayGenerator is a function that generates the default delay for a message.
|
||||
// If the message doesn't have the delay metadata set, the default delay will be applied.
|
||||
DefaultDelayGenerator func(params DefaultDelayGeneratorParams) (Delay, error)
|
||||
|
||||
// AllowNoDelay allows publishing messages without a delay set.
|
||||
// By default, the publisher returns an error when a message is published without a delay and no default delay generator is provided.
|
||||
AllowNoDelay bool
|
||||
}
|
||||
|
||||
// NewPublisher wraps a publisher with a delay mechanism.
|
||||
// A message can be published with delay metadata set in the context by using the WithContext function.
|
||||
// If the message doesn't have the delay metadata set, the default delay will be applied, if provided.
|
||||
func NewPublisher(pub message.Publisher, config PublisherConfig) (message.Publisher, error) {
|
||||
return &publisher{
|
||||
pub: pub,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type publisher struct {
|
||||
pub message.Publisher
|
||||
config PublisherConfig
|
||||
}
|
||||
|
||||
func (p *publisher) Publish(topic string, messages ...*message.Message) error {
|
||||
for i := range messages {
|
||||
err := p.applyDelay(topic, messages[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return p.pub.Publish(topic, messages...)
|
||||
}
|
||||
|
||||
func (p *publisher) Close() error {
|
||||
return p.pub.Close()
|
||||
}
|
||||
|
||||
func (p *publisher) applyDelay(topic string, msg *message.Message) error {
|
||||
if msg.Metadata.Get(DelayedForKey) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if msg.Context().Value(delayContextKey) != nil {
|
||||
delay := msg.Context().Value(delayContextKey).(Delay)
|
||||
Message(msg, delay)
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.config.DefaultDelayGenerator != nil {
|
||||
delay, err := p.config.DefaultDelayGenerator(DefaultDelayGeneratorParams{
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Message(msg, delay)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !p.config.AllowNoDelay {
|
||||
return errors.New("message doesn't have a delay set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
178
components/delay/publisher_test.go
Normal file
178
components/delay/publisher_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package delay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/delay"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
)
|
||||
|
||||
func TestPublisher(t *testing.T) {
|
||||
pubSub := gochannel.NewGoChannel(gochannel.Config{}, nil)
|
||||
|
||||
messages, err := pubSub.Subscribe(context.Background(), "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
pub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{})
|
||||
require.NoError(t, err)
|
||||
|
||||
pubAllowNoDelay, err := delay.NewPublisher(pubSub, delay.PublisherConfig{
|
||||
AllowNoDelay: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
defaultDelayPub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{
|
||||
DefaultDelayGenerator: func(params delay.DefaultDelayGeneratorParams) (delay.Delay, error) {
|
||||
return delay.For(1 * time.Second), nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
publisher message.Publisher
|
||||
messageConstructor func(id string) *message.Message
|
||||
expectedError bool
|
||||
expectedDelay time.Duration
|
||||
}{
|
||||
{
|
||||
name: "no delay",
|
||||
publisher: pub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
return message.NewMessage(id, nil)
|
||||
},
|
||||
expectedError: true,
|
||||
expectedDelay: 0,
|
||||
},
|
||||
{
|
||||
name: "no delay but allowed",
|
||||
publisher: pubAllowNoDelay,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
return message.NewMessage(id, nil)
|
||||
},
|
||||
expectedDelay: 0,
|
||||
},
|
||||
{
|
||||
name: "default delay",
|
||||
publisher: defaultDelayPub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
return message.NewMessage(id, nil)
|
||||
},
|
||||
expectedDelay: 1 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "delay from metadata",
|
||||
publisher: pub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
delay.Message(msg, delay.For(2*time.Second))
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 2 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "default delay override with metadata",
|
||||
publisher: defaultDelayPub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
delay.Message(msg, delay.For(2*time.Second))
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 2 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "delay from context",
|
||||
publisher: pub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
ctx := delay.WithContext(context.Background(), delay.For(3*time.Second))
|
||||
msg.SetContext(ctx)
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 3 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "default delay override with context",
|
||||
publisher: defaultDelayPub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
ctx := delay.WithContext(context.Background(), delay.For(3*time.Second))
|
||||
msg.SetContext(ctx)
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 3 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "delay with until",
|
||||
publisher: pub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
delay.Message(msg, delay.Until(time.Now().UTC().Add(4*time.Second)))
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 4 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "both metadata and context set",
|
||||
publisher: defaultDelayPub,
|
||||
messageConstructor: func(id string) *message.Message {
|
||||
msg := message.NewMessage(id, nil)
|
||||
delay.Message(msg, delay.For(5*time.Second))
|
||||
ctx := delay.WithContext(context.Background(), delay.For(6*time.Second))
|
||||
msg.SetContext(ctx)
|
||||
return msg
|
||||
},
|
||||
expectedDelay: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
id := fmt.Sprint(i)
|
||||
|
||||
msg := testCase.messageConstructor(id)
|
||||
err = testCase.publisher.Publish("test", msg)
|
||||
|
||||
if testCase.expectedError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assertMessage(t, messages, id, testCase.expectedDelay)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertMessage(t *testing.T, messages <-chan *message.Message, expectedID string, expectedDelay time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case msg := <-messages:
|
||||
assert.Equal(t, expectedID, msg.UUID)
|
||||
|
||||
if expectedDelay == 0 {
|
||||
assert.Empty(t, msg.Metadata.Get(delay.DelayedUntilKey))
|
||||
assert.Empty(t, msg.Metadata.Get(delay.DelayedForKey))
|
||||
} else {
|
||||
delayedFor, err := time.ParseDuration(msg.Metadata.Get(delay.DelayedForKey))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedDelay, delayedFor.Round(time.Second))
|
||||
|
||||
delayedUntil, err := time.Parse(time.RFC3339, msg.Metadata.Get(delay.DelayedUntilKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.WithinDuration(t, time.Now().UTC().Add(expectedDelay), delayedUntil, 1*time.Second)
|
||||
}
|
||||
|
||||
msg.Ack()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
require.Fail(t, "timeout")
|
||||
}
|
||||
}
|
||||
125
components/fanin/fanin.go
Normal file
125
components/fanin/fanin.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package fanin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// SourceTopics contains topics on which FanIn subscribes.
|
||||
SourceTopics []string
|
||||
|
||||
// TargetTopic determines the topic on which messages from SourceTopics are published.
|
||||
TargetTopic string
|
||||
|
||||
// CloseTimeout determines how long router should work for handlers when closing.
|
||||
CloseTimeout time.Duration
|
||||
}
|
||||
|
||||
// FanIn is a component that receives messages from 1..N topics from a subscriber and publishes them
|
||||
// on a specified topic in the publisher. In effect, messages are "multiplexed".
|
||||
type FanIn struct {
|
||||
router *message.Router
|
||||
config Config
|
||||
logger watermill.LoggerAdapter
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.CloseTimeout == 0 {
|
||||
c.CloseTimeout = time.Second * 30
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.SourceTopics) == 0 {
|
||||
return errors.New("sourceTopics must not be empty")
|
||||
}
|
||||
|
||||
if slices.Contains(c.SourceTopics, "") {
|
||||
return errors.New("sourceTopics must not be empty")
|
||||
}
|
||||
|
||||
if c.TargetTopic == "" {
|
||||
return errors.New("targetTopic must not be empty")
|
||||
}
|
||||
|
||||
if slices.Contains(c.SourceTopics, c.TargetTopic) {
|
||||
return errors.New("sourceTopics must not contain targetTopic")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewFanIn creates a new FanIn.
|
||||
func NewFanIn(
|
||||
subscriber message.Subscriber,
|
||||
publisher message.Publisher,
|
||||
config Config,
|
||||
logger watermill.LoggerAdapter,
|
||||
) (*FanIn, error) {
|
||||
if subscriber == nil {
|
||||
return nil, errors.New("missing subscriber")
|
||||
}
|
||||
if publisher == nil {
|
||||
return nil, errors.New("missing publisher")
|
||||
}
|
||||
|
||||
config.setDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logger == nil {
|
||||
logger = watermill.NopLogger{}
|
||||
}
|
||||
|
||||
routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout}
|
||||
if err := routerConfig.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid router config")
|
||||
}
|
||||
|
||||
router, err := message.NewRouter(routerConfig, logger)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create a router")
|
||||
}
|
||||
|
||||
for _, topic := range config.SourceTopics {
|
||||
router.AddHandler(
|
||||
fmt.Sprintf("fan_in_%s", topic),
|
||||
topic,
|
||||
subscriber,
|
||||
config.TargetTopic,
|
||||
publisher,
|
||||
func(msg *message.Message) ([]*message.Message, error) {
|
||||
return []*message.Message{msg}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return &FanIn{
|
||||
router: router,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run runs the FanIn.
|
||||
func (f *FanIn) Run(ctx context.Context) error {
|
||||
return f.router.Run(ctx)
|
||||
}
|
||||
|
||||
// Running is closed when FanIn is running.
|
||||
func (f *FanIn) Running() chan struct{} {
|
||||
return f.router.Running()
|
||||
}
|
||||
|
||||
// Close gracefully closes the FanIn
|
||||
func (f *FanIn) Close() error {
|
||||
return f.router.Close()
|
||||
}
|
||||
206
components/fanin/fanin_test.go
Normal file
206
components/fanin/fanin_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package fanin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/fanin"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
)
|
||||
|
||||
func TestFanIn(t *testing.T) {
|
||||
const (
|
||||
upstreamTopicPattern = "upstream-topic-%d"
|
||||
downstreamTopic = "downstream-topic"
|
||||
|
||||
cancelAfter = time.Millisecond * 100
|
||||
|
||||
workersCount = 3
|
||||
messagesCount = 10
|
||||
upstreamTopicsCount = 5
|
||||
)
|
||||
|
||||
var upstreamTopics []string
|
||||
for i := 1; i <= upstreamTopicsCount; i++ {
|
||||
topic := fmt.Sprintf(upstreamTopicPattern, i)
|
||||
upstreamTopics = append(upstreamTopics, topic)
|
||||
}
|
||||
|
||||
logger := watermill.NopLogger{}
|
||||
|
||||
pubsub := gochannel.NewGoChannel(gochannel.Config{}, watermill.NopLogger{})
|
||||
|
||||
fi, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{
|
||||
SourceTopics: upstreamTopics,
|
||||
TargetTopic: downstreamTopic,
|
||||
},
|
||||
logger,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedNumberOfMessages := workersCount * messagesCount * upstreamTopicsCount
|
||||
|
||||
receivedMessages := make(chan string, expectedNumberOfMessages)
|
||||
|
||||
for i := 0; i < workersCount; i++ {
|
||||
router.AddConsumerHandler(
|
||||
fmt.Sprintf("worker-%v", i),
|
||||
downstreamTopic,
|
||||
pubsub,
|
||||
func(msg *message.Message) error {
|
||||
payload := string(msg.Payload)
|
||||
receivedMessages <- payload
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cancelAfter)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
err := router.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
err := fi.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-router.Running()
|
||||
<-fi.Running()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(upstreamTopics) * messagesCount)
|
||||
for _, topic := range upstreamTopics {
|
||||
go func(topic string) {
|
||||
for i := 0; i < messagesCount; i++ {
|
||||
msg := message.NewMessage(watermill.NewUUID(), []byte(topic))
|
||||
err := pubsub.Publish(topic, msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
wg.Done()
|
||||
}
|
||||
}(topic)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
counts := map[string]int{}
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case msg := <-receivedMessages:
|
||||
counts[msg]++
|
||||
case <-time.After(cancelAfter):
|
||||
close(receivedMessages)
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
sum := 0
|
||||
require.Len(t, counts, upstreamTopicsCount)
|
||||
for _, count := range counts {
|
||||
require.Equal(t, workersCount*messagesCount, count)
|
||||
sum += count
|
||||
}
|
||||
require.Equal(t, expectedNumberOfMessages, sum)
|
||||
}
|
||||
|
||||
func TestNewFanIn(t *testing.T) {
|
||||
pubsub := gochannel.NewGoChannel(gochannel.Config{}, nil)
|
||||
|
||||
t.Run("error when subscriber nil", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
nil,
|
||||
nil,
|
||||
fanin.Config{},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "missing subscriber")
|
||||
})
|
||||
|
||||
t.Run("error when publisher nil", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
nil,
|
||||
fanin.Config{},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "missing publisher")
|
||||
})
|
||||
|
||||
t.Run("error when sourceTopics empty", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "sourceTopics must not be empty")
|
||||
})
|
||||
|
||||
t.Run("error when sourceTopics empty", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{
|
||||
SourceTopics: []string{""},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "sourceTopics must not be empty")
|
||||
})
|
||||
|
||||
t.Run("error when targetTopic empty", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{
|
||||
SourceTopics: []string{"topic"},
|
||||
TargetTopic: "",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "targetTopic must not be empty")
|
||||
})
|
||||
|
||||
t.Run("error when sourceTopics contains targetTopic", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{
|
||||
SourceTopics: []string{"topic"},
|
||||
TargetTopic: "topic",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
require.EqualError(t, err, "sourceTopics must not contain targetTopic")
|
||||
})
|
||||
|
||||
t.Run("correct", func(t *testing.T) {
|
||||
_, err := fanin.NewFanIn(
|
||||
pubsub,
|
||||
pubsub,
|
||||
fanin.Config{
|
||||
SourceTopics: []string{"topic"},
|
||||
TargetTopic: "targetTopic",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
80
components/forwarder/envelope.go
Normal file
80
components/forwarder/envelope.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// messageEnvelope wraps Watermill message and contains destination topic.
|
||||
type messageEnvelope struct {
|
||||
DestinationTopic string `json:"destination_topic"`
|
||||
|
||||
UUID string `json:"uuid"`
|
||||
Payload []byte `json:"payload"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
func newMessageEnvelope(destTopic string, msg *message.Message) (*messageEnvelope, error) {
|
||||
e := &messageEnvelope{
|
||||
DestinationTopic: destTopic,
|
||||
UUID: msg.UUID,
|
||||
Payload: msg.Payload,
|
||||
Metadata: msg.Metadata,
|
||||
}
|
||||
|
||||
if err := e.validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create a message envelope")
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *messageEnvelope) validate() error {
|
||||
if e.DestinationTopic == "" {
|
||||
return errors.New("unknown destination topic")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapMessageInEnvelope(
|
||||
destinationTopic string,
|
||||
msg *message.Message,
|
||||
marshaler Marshaler,
|
||||
) (*message.Message, error) {
|
||||
envelope, err := newMessageEnvelope(destinationTopic, msg)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot envelope a message")
|
||||
}
|
||||
|
||||
envelopedMessage, err := marshaler.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot marshal a message")
|
||||
}
|
||||
|
||||
wrappedMsg := message.NewMessage(watermill.NewUUID(), envelopedMessage)
|
||||
wrappedMsg.SetContext(msg.Context())
|
||||
|
||||
return wrappedMsg, nil
|
||||
}
|
||||
|
||||
func unwrapMessageFromEnvelope(
|
||||
msg *message.Message,
|
||||
marshaler Marshaler,
|
||||
) (destinationTopic string, unwrappedMsg *message.Message, err error) {
|
||||
envelopedMsg := messageEnvelope{}
|
||||
if err := marshaler.Unmarshal(msg.Payload, &envelopedMsg); err != nil {
|
||||
return "", nil, errors.Wrap(err, "cannot unmarshal message wrapped in an envelope")
|
||||
}
|
||||
|
||||
if err := envelopedMsg.validate(); err != nil {
|
||||
return "", nil, errors.Wrap(err, "an unmarshalled message envelope is invalid")
|
||||
}
|
||||
|
||||
watermillMessage := message.NewMessage(envelopedMsg.UUID, envelopedMsg.Payload)
|
||||
watermillMessage.Metadata = envelopedMsg.Metadata
|
||||
watermillMessage.SetContext(msg.Context())
|
||||
|
||||
return envelopedMsg.DestinationTopic, watermillMessage, nil
|
||||
}
|
||||
46
components/forwarder/envelope_test.go
Normal file
46
components/forwarder/envelope_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
func TestEnvelope(t *testing.T) {
|
||||
expectedUUID := watermill.NewUUID()
|
||||
marshaler := DefaultMarshaler{}
|
||||
expectedPayload := message.Payload("msg content")
|
||||
expectedMetadata := message.Metadata{"key": "value"}
|
||||
expectedDestinationTopic := "dest_topic"
|
||||
|
||||
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
|
||||
|
||||
msg := message.NewMessage(expectedUUID, expectedPayload)
|
||||
msg.Metadata = expectedMetadata
|
||||
msg.SetContext(ctx)
|
||||
|
||||
wrappedMsg, err := wrapMessageInEnvelope(expectedDestinationTopic, msg, marshaler)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, wrappedMsg)
|
||||
v, ok := wrappedMsg.Context().Value(contextKey("key")).(string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "value", v)
|
||||
|
||||
destinationTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(wrappedMsg, marshaler)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, unwrappedMsg)
|
||||
assert.Equal(t, expectedUUID, unwrappedMsg.UUID)
|
||||
assert.Equal(t, expectedPayload, unwrappedMsg.Payload)
|
||||
assert.Equal(t, expectedMetadata, unwrappedMsg.Metadata)
|
||||
assert.Equal(t, expectedDestinationTopic, destinationTopic)
|
||||
|
||||
v, ok = unwrappedMsg.Context().Value(contextKey("key")).(string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "value", v)
|
||||
}
|
||||
169
components/forwarder/forwarder.go
Normal file
169
components/forwarder/forwarder.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultForwarderTopic = "forwarder_topic"
|
||||
defaultHandlerName = "events_forwarder"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// ForwarderTopic is a topic on which the forwarder will be listening to enveloped messages to forward.
|
||||
// Defaults to `forwarder_topic`.
|
||||
ForwarderTopic string
|
||||
|
||||
// HandlerName is the name of the forwarder's handler registered in the router.
|
||||
// Defaults to `events_forwarder`.
|
||||
//
|
||||
// Handler names must be unique within a router, so it needs to be set explicitly
|
||||
// if you want to run multiple forwarders on the same router.
|
||||
HandlerName string
|
||||
|
||||
// Middlewares are used to decorate forwarder's handler function.
|
||||
Middlewares []message.HandlerMiddleware
|
||||
|
||||
// CloseTimeout determines how long router should work for handlers when closing.
|
||||
CloseTimeout time.Duration
|
||||
|
||||
// AckWhenCannotUnwrap enables acking of messages which cannot be unwrapped from an envelope.
|
||||
AckWhenCannotUnwrap bool
|
||||
|
||||
// Router is a router used by the forwarder.
|
||||
// If not provided, a new router will be created.
|
||||
//
|
||||
// If router is provided, it's not necessary to call `Forwarder.Run()` if the router is started with `router.Run()`.
|
||||
Router *message.Router
|
||||
|
||||
// Marshaler is used to deserialize envelopes received on ForwarderTopic.
|
||||
// It must match the Marshaler used by the decorated Publisher.
|
||||
// Defaults to DefaultMarshaler, which uses encoding/json.
|
||||
Marshaler Marshaler
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.CloseTimeout == 0 {
|
||||
c.CloseTimeout = time.Second * 30
|
||||
}
|
||||
if c.ForwarderTopic == "" {
|
||||
c.ForwarderTopic = defaultForwarderTopic
|
||||
}
|
||||
if c.HandlerName == "" {
|
||||
c.HandlerName = defaultHandlerName
|
||||
}
|
||||
if c.Marshaler == nil {
|
||||
c.Marshaler = DefaultMarshaler{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.ForwarderTopic == "" {
|
||||
return errors.New("empty forwarder topic")
|
||||
}
|
||||
if c.HandlerName == "" {
|
||||
return errors.New("empty handler name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forwarder subscribes to the topic provided in the config and publishes them to the destination topic embedded in the enveloped message.
|
||||
type Forwarder struct {
|
||||
router *message.Router
|
||||
publisher message.Publisher
|
||||
logger watermill.LoggerAdapter
|
||||
config Config
|
||||
}
|
||||
|
||||
// NewForwarder creates a forwarder which will subscribe to the topic provided in the config using the provided subscriber.
|
||||
// It will publish messages received on this subscription to the destination topic embedded in the enveloped message using the provided publisher.
|
||||
//
|
||||
// Provided subscriber and publisher can be from different Watermill Pub/Sub implementations, i.e. MySQL subscriber and Google Pub/Sub publisher.
|
||||
//
|
||||
// Note: Keep in mind that by default the forwarder will nack all messages which weren't sent using a decorated publisher.
|
||||
// You can change this behavior by passing a middleware which will ack them instead.
|
||||
func NewForwarder(
|
||||
subscriberIn message.Subscriber,
|
||||
publisherOut message.Publisher,
|
||||
logger watermill.LoggerAdapter,
|
||||
config Config,
|
||||
) (*Forwarder, error) {
|
||||
config.setDefaults()
|
||||
|
||||
routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout}
|
||||
if err := routerConfig.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid router config")
|
||||
}
|
||||
|
||||
var router *message.Router
|
||||
if config.Router != nil {
|
||||
router = config.Router
|
||||
} else {
|
||||
var err error
|
||||
router, err = message.NewRouter(routerConfig, logger)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create a router")
|
||||
}
|
||||
}
|
||||
|
||||
f := &Forwarder{router, publisherOut, logger, config}
|
||||
|
||||
handler := router.AddConsumerHandler(
|
||||
config.HandlerName,
|
||||
config.ForwarderTopic,
|
||||
subscriberIn,
|
||||
f.forwardMessage,
|
||||
)
|
||||
|
||||
handler.AddMiddleware(config.Middlewares...)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Run runs forwarder's handler responsible for forwarding messages.
|
||||
// This call is blocking while the forwarder is running.
|
||||
// ctx will be propagated to the forwarder's subscription.
|
||||
//
|
||||
// To stop Run() you should call Close() on the forwarder.
|
||||
func (f *Forwarder) Run(ctx context.Context) error {
|
||||
return f.router.Run(ctx)
|
||||
}
|
||||
|
||||
// Close stops forwarder's handler.
|
||||
func (f *Forwarder) Close() error {
|
||||
return f.router.Close()
|
||||
}
|
||||
|
||||
// Running returns channel which is closed when the forwarder is running.
|
||||
func (f *Forwarder) Running() chan struct{} {
|
||||
return f.router.Running()
|
||||
}
|
||||
|
||||
func (f *Forwarder) forwardMessage(msg *message.Message) error {
|
||||
destTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(msg, f.config.Marshaler)
|
||||
if err != nil {
|
||||
f.logger.Error("Could not unwrap a message from an envelope", err, watermill.LogFields{
|
||||
"uuid": msg.UUID,
|
||||
"payload": msg.Payload,
|
||||
"metadata": msg.Metadata,
|
||||
"acked": f.config.AckWhenCannotUnwrap,
|
||||
})
|
||||
|
||||
if f.config.AckWhenCannotUnwrap {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(err, "cannot unwrap message from an envelope")
|
||||
}
|
||||
|
||||
if err := f.publisher.Publish(destTopic, unwrappedMsg); err != nil {
|
||||
return errors.Wrap(err, "cannot publish a message")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
263
components/forwarder/forwarder_test.go
Normal file
263
components/forwarder/forwarder_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package forwarder_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/forwarder"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var (
|
||||
logger = watermill.NewStdLogger(true, true)
|
||||
|
||||
forwarderTopic = "forwarder_topic"
|
||||
outTopic = "out_topic"
|
||||
)
|
||||
|
||||
func TestForwarder(t *testing.T) {
|
||||
suite.Run(t, new(ForwarderSuite))
|
||||
}
|
||||
|
||||
// ForwarderSuite tests forwarding messages from PubSubIn to PubSubOut (which are GoChannel implementation underneath).
|
||||
type ForwarderSuite struct {
|
||||
suite.Suite
|
||||
ctx context.Context
|
||||
cancelCtx func()
|
||||
|
||||
publisherIn PubSubInPublisher
|
||||
subscriberIn PubSubInSubscriber
|
||||
|
||||
publisherOut PubSubOutPublisher
|
||||
subscriberOut PubSubOutSubscriber
|
||||
|
||||
decoratedPublisherIn *forwarder.Publisher
|
||||
|
||||
outMessagesCh <-chan *message.Message
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) SetupTest() {
|
||||
// Create test context with a 5 seconds timeout so it will close any subscriptions/handlers running in the background
|
||||
// in case of too long test execution.
|
||||
s.ctx, s.cancelCtx = context.WithTimeout(context.Background(), time.Second*5)
|
||||
|
||||
// Create a set of publisher and subscribers for both In and Out Pub/Subs.
|
||||
s.publisherIn, s.subscriberIn = newPubSubIn()
|
||||
s.publisherOut, s.subscriberOut = newPubSubOut()
|
||||
|
||||
s.decoratedPublisherIn = forwarder.NewPublisher(s.publisherIn, forwarder.PublisherConfig{ForwarderTopic: forwarderTopic})
|
||||
s.listenOnOutTopic()
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) TearDownTest() {
|
||||
s.NoError(s.publisherIn.Close())
|
||||
s.NoError(s.subscriberIn.Close())
|
||||
s.NoError(s.publisherOut.Close())
|
||||
s.NoError(s.subscriberOut.Close())
|
||||
s.cancelCtx()
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) TestForwarder_publish_using_decorated_publisher() {
|
||||
fwd := s.setupForwarder(forwarder.Config{ForwarderTopic: forwarderTopic})
|
||||
defer func() {
|
||||
s.NoError(fwd.Close())
|
||||
}()
|
||||
|
||||
sentMsg := s.sampleMessage()
|
||||
err := s.decoratedPublisherIn.Publish(outTopic, sentMsg)
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.requireFirstMessage(sentMsg)
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher() {
|
||||
msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware()
|
||||
fwd := s.setupForwarder(forwarder.Config{
|
||||
ForwarderTopic: forwarderTopic,
|
||||
Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware},
|
||||
})
|
||||
defer func() {
|
||||
s.NoError(fwd.Close())
|
||||
}()
|
||||
|
||||
sentMsg := s.sampleMessage()
|
||||
err := s.publisherIn.Publish(forwarderTopic, sentMsg)
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.requireFirstAckingResult(msgAckedCh, false)
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher_acking_enabled() {
|
||||
msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware()
|
||||
fwd := s.setupForwarder(forwarder.Config{
|
||||
ForwarderTopic: forwarderTopic,
|
||||
Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware},
|
||||
AckWhenCannotUnwrap: true,
|
||||
})
|
||||
defer func() {
|
||||
s.NoError(fwd.Close())
|
||||
}()
|
||||
|
||||
sentMsg := s.sampleMessage()
|
||||
err := s.publisherIn.Publish(forwarderTopic, sentMsg)
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.requireFirstAckingResult(msgAckedCh, true)
|
||||
}
|
||||
|
||||
// TestForwarder_multiple_forwarders_on_one_router ensures that forwarders sharing a router
|
||||
// can coexist as long as they have unique handler names.
|
||||
func TestForwarder_multiple_forwarders_on_one_router(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
pubSubIn := gochannel.NewGoChannel(gochannel.Config{}, logger)
|
||||
pubSubOut := gochannel.NewGoChannel(gochannel.Config{}, logger)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
handlerNames := []string{"first_forwarder", "second_forwarder"}
|
||||
|
||||
for _, handlerName := range handlerNames {
|
||||
_, err := forwarder.NewForwarder(pubSubIn, pubSubOut, logger, forwarder.Config{
|
||||
ForwarderTopic: handlerName + "_topic",
|
||||
HandlerName: handlerName,
|
||||
Router: router,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
assert.NoError(t, router.Run(ctx))
|
||||
}()
|
||||
defer func() {
|
||||
assert.NoError(t, router.Close())
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-router.Running():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("router not running")
|
||||
}
|
||||
|
||||
outMessagesCh, err := pubSubOut.Subscribe(ctx, outTopic)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, handlerName := range handlerNames {
|
||||
publisher := forwarder.NewPublisher(pubSubIn, forwarder.PublisherConfig{
|
||||
ForwarderTopic: handlerName + "_topic",
|
||||
})
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), message.Payload(handlerName))
|
||||
require.NoError(t, publisher.Publish(outTopic, msg))
|
||||
}
|
||||
|
||||
// Both forwarders publish to the same out topic, so messages can arrive in any order.
|
||||
receivedPayloads := map[string]bool{}
|
||||
for range handlerNames {
|
||||
select {
|
||||
case receivedMsg := <-outMessagesCh:
|
||||
receivedPayloads[string(receivedMsg.Payload)] = true
|
||||
receivedMsg.Ack()
|
||||
case <-time.After(time.Second * 3):
|
||||
t.Fatal("didn't receive all forwarded messages")
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, map[string]bool{"first_forwarder": true, "second_forwarder": true}, receivedPayloads)
|
||||
}
|
||||
|
||||
type PubSubInPublisher struct {
|
||||
message.Publisher
|
||||
}
|
||||
type PubSubInSubscriber struct {
|
||||
message.Subscriber
|
||||
}
|
||||
|
||||
type PubSubOutPublisher struct {
|
||||
message.Publisher
|
||||
}
|
||||
type PubSubOutSubscriber struct {
|
||||
message.Subscriber
|
||||
}
|
||||
|
||||
func newPubSubIn() (PubSubInPublisher, PubSubInSubscriber) {
|
||||
channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
|
||||
return PubSubInPublisher{channelPubSub}, PubSubInSubscriber{channelPubSub}
|
||||
}
|
||||
|
||||
func newPubSubOut() (PubSubOutPublisher, PubSubOutSubscriber) {
|
||||
channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
|
||||
return PubSubOutPublisher{channelPubSub}, PubSubOutSubscriber{channelPubSub}
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) setupForwarder(config forwarder.Config) *forwarder.Forwarder {
|
||||
f, err := forwarder.NewForwarder(s.subscriberIn, s.publisherOut, logger, config)
|
||||
s.Require().NoError(err)
|
||||
|
||||
go func() {
|
||||
s.Require().NoError(f.Run(s.ctx))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-f.Running():
|
||||
case <-s.ctx.Done():
|
||||
s.T().Fatal("forwarder not running")
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) listenOnOutTopic() {
|
||||
var err error
|
||||
s.outMessagesCh, err = s.subscriberOut.Subscribe(s.ctx, outTopic)
|
||||
s.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) requireFirstMessage(expectedMessage *message.Message) {
|
||||
select {
|
||||
case receivedMessage := <-s.outMessagesCh:
|
||||
s.Require().NotNil(receivedMessage)
|
||||
s.Require().Truef(receivedMessage.Equals(expectedMessage), "received message: '%s', expected: '%s'", receivedMessage, expectedMessage)
|
||||
receivedMessage.Ack()
|
||||
case <-time.After(time.Second):
|
||||
s.T().Fatal("didn't receive any message after 1 sec")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) setupMessageAckedDetectorMiddleware() (message.HandlerMiddleware, <-chan bool) {
|
||||
messageAckedCh := make(chan bool, 1)
|
||||
messageAckedDetector := func(handlerFunc message.HandlerFunc) message.HandlerFunc {
|
||||
return func(msg *message.Message) ([]*message.Message, error) {
|
||||
msgs, err := handlerFunc(msg)
|
||||
messageAckedCh <- err == nil
|
||||
|
||||
// Always return nil as we don't want to nack the message in tests.
|
||||
return msgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
return messageAckedDetector, messageAckedCh
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) requireFirstAckingResult(msgAckedCh <-chan bool, expected bool) {
|
||||
select {
|
||||
case msgAcked := <-msgAckedCh:
|
||||
s.Require().Equal(expected, msgAcked)
|
||||
case <-time.After(time.Second):
|
||||
s.T().Fatal("acking result not received after 1 sec")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForwarderSuite) sampleMessage() *message.Message {
|
||||
msg := message.NewMessage(watermill.NewUUID(), message.Payload("message payload"))
|
||||
msg.Metadata = message.Metadata{"key": "value"}
|
||||
return msg
|
||||
}
|
||||
27
components/forwarder/marshaler.go
Normal file
27
components/forwarder/marshaler.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package forwarder
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Marshaler is used by the Forwarder Publisher to serialize the envelope sent
|
||||
// to the forwarder topic, and by the Forwarder consumer to deserialize it.
|
||||
//
|
||||
// The interface matches the signatures of encoding/json's Marshal and Unmarshal,
|
||||
// so any drop-in JSON library (e.g. github.com/bytedance/sonic, github.com/goccy/go-json)
|
||||
// can be adapted with a small wrapper.
|
||||
//
|
||||
// The default is DefaultMarshaler, which uses encoding/json.
|
||||
type Marshaler interface {
|
||||
Marshal(v any) ([]byte, error)
|
||||
Unmarshal(data []byte, v any) error
|
||||
}
|
||||
|
||||
// DefaultMarshaler uses the standard library's encoding/json package.
|
||||
type DefaultMarshaler struct{}
|
||||
|
||||
func (DefaultMarshaler) Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (DefaultMarshaler) Unmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
73
components/forwarder/publisher.go
Normal file
73
components/forwarder/publisher.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// PublisherConfig configures the decorating Publisher returned by NewPublisher.
|
||||
type PublisherConfig struct {
|
||||
// ForwarderTopic is a topic which the forwarder is listening to. Publisher will send enveloped messages to this topic.
|
||||
// Defaults to `forwarder_topic`.
|
||||
ForwarderTopic string
|
||||
|
||||
// Marshaler is used to serialize envelopes sent to ForwarderTopic.
|
||||
// It must match the Marshaler used by the Forwarder consuming the topic.
|
||||
// Defaults to DefaultMarshaler, which uses encoding/json.
|
||||
Marshaler Marshaler
|
||||
}
|
||||
|
||||
func (c *PublisherConfig) setDefaults() {
|
||||
if c.ForwarderTopic == "" {
|
||||
c.ForwarderTopic = defaultForwarderTopic
|
||||
}
|
||||
if c.Marshaler == nil {
|
||||
c.Marshaler = DefaultMarshaler{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PublisherConfig) Validate() error {
|
||||
if c.ForwarderTopic == "" {
|
||||
return errors.New("empty forwarder topic")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Publisher changes `Publish` method behavior so it wraps a sent message in an envelope
|
||||
// and sends it to the forwarder topic provided in the config.
|
||||
type Publisher struct {
|
||||
wrappedPublisher message.Publisher
|
||||
config PublisherConfig
|
||||
}
|
||||
|
||||
func NewPublisher(publisher message.Publisher, config PublisherConfig) *Publisher {
|
||||
config.setDefaults()
|
||||
|
||||
return &Publisher{
|
||||
wrappedPublisher: publisher,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Publisher) Publish(topic string, messages ...*message.Message) error {
|
||||
envelopedMessages := make([]*message.Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
envelopedMsg, err := wrapMessageInEnvelope(topic, msg, p.config.Marshaler)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "cannot wrap message, target topic: '%s', uuid: '%s'", topic, msg.UUID)
|
||||
}
|
||||
|
||||
envelopedMessages = append(envelopedMessages, envelopedMsg)
|
||||
}
|
||||
|
||||
if err := p.wrappedPublisher.Publish(p.config.ForwarderTopic, envelopedMessages...); err != nil {
|
||||
return errors.Wrapf(err, "cannot publish messages to forwarder topic: '%s'", p.config.ForwarderTopic)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Publisher) Close() error {
|
||||
return p.wrappedPublisher.Close()
|
||||
}
|
||||
140
components/metrics/builder.go
Normal file
140
components/metrics/builder.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/ThreeDotsLabs/watermill/internal"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type PrometheusMetricsBuilderConfig struct {
|
||||
Namespace string
|
||||
Subsystem string
|
||||
AdditionalLabels []MetricLabel
|
||||
}
|
||||
|
||||
func NewPrometheusMetricsBuilderWithConfig(prometheusRegistry prometheus.Registerer, config PrometheusMetricsBuilderConfig) PrometheusMetricsBuilder {
|
||||
builder := PrometheusMetricsBuilder{
|
||||
Namespace: config.Namespace,
|
||||
Subsystem: config.Subsystem,
|
||||
PrometheusRegistry: prometheusRegistry,
|
||||
additionalLabels: config.AdditionalLabels,
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func NewPrometheusMetricsBuilder(prometheusRegistry prometheus.Registerer, namespace string, subsystem string) PrometheusMetricsBuilder {
|
||||
return NewPrometheusMetricsBuilderWithConfig(prometheusRegistry, PrometheusMetricsBuilderConfig{
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
})
|
||||
}
|
||||
|
||||
// PrometheusMetricsBuilder provides methods to decorate publishers, subscribers and handlers.
|
||||
type PrometheusMetricsBuilder struct {
|
||||
// PrometheusRegistry may be filled with a pre-existing Prometheus registry, or left empty for the default registry.
|
||||
PrometheusRegistry prometheus.Registerer
|
||||
|
||||
Namespace string
|
||||
Subsystem string
|
||||
// PublishBuckets defines the histogram buckets for publish time histogram, defaulted if nil.
|
||||
PublishBuckets []float64
|
||||
// HandlerBuckets defines the histogram buckets for handle execution time histogram, defaulted to watermill's default.
|
||||
HandlerBuckets []float64
|
||||
|
||||
additionalLabels []MetricLabel
|
||||
}
|
||||
|
||||
// AddPrometheusRouterMetrics is a convenience function that acts on the message router to add the metrics middleware
|
||||
// to all its handlers. The handlers' publishers and subscribers are also decorated.
|
||||
// The default buckets are used for the handler execution time histogram (use your own provisioning
|
||||
// with NewRouterMiddlewareWithConfig if needed).
|
||||
func (b PrometheusMetricsBuilder) AddPrometheusRouterMetrics(r *message.Router) {
|
||||
r.AddPublisherDecorators(b.DecoratePublisher)
|
||||
r.AddSubscriberDecorators(b.DecorateSubscriber)
|
||||
r.AddMiddleware(b.NewRouterMiddleware().Middleware)
|
||||
}
|
||||
|
||||
// DecoratePublisher wraps the underlying publisher with Prometheus metrics.
|
||||
func (b PrometheusMetricsBuilder) DecoratePublisher(pub message.Publisher) (message.Publisher, error) {
|
||||
var err error
|
||||
d := PublisherPrometheusMetricsDecorator{
|
||||
pub: pub,
|
||||
publisherName: internal.StructName(pub),
|
||||
additionalLabels: b.additionalLabels,
|
||||
}
|
||||
|
||||
d.publishTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: b.Namespace,
|
||||
Subsystem: b.Subsystem,
|
||||
Name: "publish_time_seconds",
|
||||
Help: "The time that a publishing attempt (success or not) took in seconds",
|
||||
Buckets: b.PublishBuckets,
|
||||
},
|
||||
toLabelsSlice(publisherLabelKeys, b.additionalLabels),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not register publish time metric")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// DecorateSubscriber wraps the underlying subscriber with Prometheus metrics.
|
||||
func (b PrometheusMetricsBuilder) DecorateSubscriber(sub message.Subscriber) (message.Subscriber, error) {
|
||||
var err error
|
||||
d := &SubscriberPrometheusMetricsDecorator{
|
||||
closing: make(chan struct{}),
|
||||
subscriberName: internal.StructName(sub),
|
||||
additionalLabels: b.additionalLabels,
|
||||
}
|
||||
|
||||
d.subscriberMessagesReceivedTotal, err = b.registerCounterVec(prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: b.Namespace,
|
||||
Subsystem: b.Subsystem,
|
||||
Name: "subscriber_messages_received_total",
|
||||
Help: "The total number of messages received by the subscriber",
|
||||
},
|
||||
toLabelsSlice(append(subscriberLabelKeys, labelAcked), b.additionalLabels),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not register time to ack metric")
|
||||
}
|
||||
|
||||
d.Subscriber, err = message.MessageTransformSubscriberDecorator(d.recordMetrics)(sub)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not decorate subscriber with metrics decorator")
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (b PrometheusMetricsBuilder) register(c prometheus.Collector) (prometheus.Collector, error) {
|
||||
err := b.PrometheusRegistry.Register(c)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
|
||||
return are.ExistingCollector, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (b PrometheusMetricsBuilder) registerCounterVec(c *prometheus.CounterVec) (*prometheus.CounterVec, error) {
|
||||
col, err := b.register(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return col.(*prometheus.CounterVec), nil
|
||||
}
|
||||
|
||||
func (b PrometheusMetricsBuilder) registerHistogramVec(h *prometheus.HistogramVec) (*prometheus.HistogramVec, error) {
|
||||
col, err := b.register(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return col.(*prometheus.HistogramVec), nil
|
||||
}
|
||||
28
components/metrics/ctx.go
Normal file
28
components/metrics/ctx.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package metrics
|
||||
|
||||
import "context"
|
||||
|
||||
type contextValue int
|
||||
|
||||
const (
|
||||
publishObserved contextValue = iota
|
||||
subscribeObserved
|
||||
)
|
||||
|
||||
// setPublishObservedToCtx is used to achieve metrics idempotency in case of double applied middleware
|
||||
func setPublishObservedToCtx(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, publishObserved, true)
|
||||
}
|
||||
|
||||
func publishAlreadyObserved(ctx context.Context) bool {
|
||||
return ctx.Value(publishObserved) != nil
|
||||
}
|
||||
|
||||
// setSubscribeObservedToCtx is used to achieve metrics idempotency in case of double applied middleware
|
||||
func setSubscribeObservedToCtx(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, subscribeObserved, true)
|
||||
}
|
||||
|
||||
func subscribeAlreadyObserved(ctx context.Context) bool {
|
||||
return ctx.Value(subscribeObserved) != nil
|
||||
}
|
||||
92
components/metrics/handler.go
Normal file
92
components/metrics/handler.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
var (
|
||||
handlerLabelKeys = []string{
|
||||
labelKeyHandlerName,
|
||||
labelSuccess,
|
||||
}
|
||||
|
||||
// defaultHandlerExecutionTimeBuckets are one order of magnitude smaller than default buckets (5ms~10s),
|
||||
// because the handler execution times are typically shorter (µs~ms range).
|
||||
defaultHandlerExecutionTimeBuckets = []float64{
|
||||
0.0005,
|
||||
0.001,
|
||||
0.0025,
|
||||
0.005,
|
||||
0.01,
|
||||
0.025,
|
||||
0.05,
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
1,
|
||||
}
|
||||
)
|
||||
|
||||
// HandlerPrometheusMetricsMiddleware is a middleware that captures Prometheus metrics.
|
||||
type HandlerPrometheusMetricsMiddleware struct {
|
||||
handlerExecutionTimeSeconds *prometheus.HistogramVec
|
||||
additionalLabels []MetricLabel
|
||||
}
|
||||
|
||||
// Middleware returns the middleware ready to be used with watermill's Router.
|
||||
func (m HandlerPrometheusMetricsMiddleware) Middleware(h message.HandlerFunc) message.HandlerFunc {
|
||||
return func(msg *message.Message) (msgs []*message.Message, err error) {
|
||||
now := time.Now()
|
||||
ctx := msg.Context()
|
||||
labels := prometheus.Labels{
|
||||
labelKeyHandlerName: message.HandlerNameFromCtx(ctx),
|
||||
}
|
||||
for _, lb := range m.additionalLabels {
|
||||
labels[lb.Label] = lb.ComputeValueFn(ctx)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
labels[labelSuccess] = "false"
|
||||
} else {
|
||||
labels[labelSuccess] = "true"
|
||||
}
|
||||
m.handlerExecutionTimeSeconds.With(labels).Observe(time.Since(now).Seconds())
|
||||
}()
|
||||
|
||||
return h(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// NewRouterMiddleware returns new middleware.
|
||||
func (b PrometheusMetricsBuilder) NewRouterMiddleware() HandlerPrometheusMetricsMiddleware {
|
||||
var err error
|
||||
m := HandlerPrometheusMetricsMiddleware{
|
||||
additionalLabels: b.additionalLabels,
|
||||
}
|
||||
|
||||
if b.HandlerBuckets == nil {
|
||||
b.HandlerBuckets = defaultHandlerExecutionTimeBuckets
|
||||
}
|
||||
|
||||
m.handlerExecutionTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: b.Namespace,
|
||||
Subsystem: b.Subsystem,
|
||||
Name: "handler_execution_time_seconds",
|
||||
Help: "The total time elapsed while executing the handler function in seconds",
|
||||
Buckets: b.HandlerBuckets,
|
||||
},
|
||||
toLabelsSlice(handlerLabelKeys, b.additionalLabels),
|
||||
))
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not register handler execution time metric"))
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
41
components/metrics/http.go
Normal file
41
components/metrics/http.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// CreateRegistryAndServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address.
|
||||
// It returns a new prometheus registry (to register the metrics on) and a canceling function that ends the server.
|
||||
func CreateRegistryAndServeHTTP(addr string) (registry *prometheus.Registry, cancel func()) {
|
||||
registry = prometheus.NewRegistry()
|
||||
return registry, ServeHTTP(addr, registry)
|
||||
}
|
||||
|
||||
// ServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address.
|
||||
// It takes an existing Prometheus registry and returns a canceling function that ends the server.
|
||||
func ServeHTTP(addr string, registry *prometheus.Registry) (cancel func()) {
|
||||
router := chi.NewRouter()
|
||||
|
||||
handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
|
||||
router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
server := http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := server.ListenAndServe()
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { _ = server.Close() }
|
||||
}
|
||||
66
components/metrics/http_test.go
Normal file
66
components/metrics/http_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/metrics"
|
||||
)
|
||||
|
||||
func TestCreateRegistryAndServeHTTP_metrics_endpoint(t *testing.T) {
|
||||
reg, cancel := metrics.CreateRegistryAndServeHTTP(":8090")
|
||||
defer cancel()
|
||||
err := reg.Register(collectors.NewBuildInfoCollector())
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "registration of prometheus build info collector failed"))
|
||||
}
|
||||
waitServerReady(t, "http://localhost:8090")
|
||||
resp, err := http.DefaultClient.Get("http://localhost:8090/metrics")
|
||||
if resp != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "call to metrics endpoint failed"))
|
||||
}
|
||||
assert.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestCreateRegistryAndServeHTTP_unknown_endpoint(t *testing.T) {
|
||||
reg, cancel := metrics.CreateRegistryAndServeHTTP(":8091")
|
||||
defer cancel()
|
||||
err := reg.Register(collectors.NewBuildInfoCollector())
|
||||
if err != nil {
|
||||
t.Error(errors.Wrap(err, "registration of prometheus build info collector failed"))
|
||||
}
|
||||
waitServerReady(t, "http://localhost:8091")
|
||||
resp, err := http.DefaultClient.Get("http://localhost:8091/unknown")
|
||||
if resp != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "call to unknown endpoint failed"))
|
||||
}
|
||||
assert.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
// server might have small delay before being able to server traffic
|
||||
func waitServerReady(t *testing.T, addr string) {
|
||||
for i := 0; i < 50; i++ {
|
||||
_, err := http.DefaultClient.Get(addr)
|
||||
// assume server ready when no err anymore
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
}
|
||||
75
components/metrics/labels.go
Normal file
75
components/metrics/labels.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const (
|
||||
labelKeyHandlerName = "handler_name"
|
||||
labelKeyPublisherName = "publisher_name"
|
||||
labelKeySubscriberName = "subscriber_name"
|
||||
labelSuccess = "success"
|
||||
labelAcked = "acked"
|
||||
|
||||
labelValueNoHandler = "<no handler>"
|
||||
)
|
||||
|
||||
var (
|
||||
labelGetters = map[string]func(context.Context) string{
|
||||
labelKeyHandlerName: message.HandlerNameFromCtx,
|
||||
labelKeyPublisherName: message.PublisherNameFromCtx,
|
||||
labelKeySubscriberName: message.SubscriberNameFromCtx,
|
||||
}
|
||||
)
|
||||
|
||||
func labelsFromCtx(ctx context.Context, labels ...string) prometheus.Labels {
|
||||
ctxLabels := map[string]string{}
|
||||
|
||||
for _, l := range labels {
|
||||
k := l
|
||||
ctxLabels[l] = ""
|
||||
|
||||
getter, ok := labelGetters[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
v := getter(ctx)
|
||||
if v != "" {
|
||||
ctxLabels[l] = v
|
||||
}
|
||||
}
|
||||
|
||||
return ctxLabels
|
||||
}
|
||||
|
||||
type LabelComputeValueFn func(msgCtx context.Context) string
|
||||
|
||||
type MetricLabel struct {
|
||||
Label string
|
||||
ComputeValueFn LabelComputeValueFn
|
||||
}
|
||||
|
||||
func toLabelsSlice(baseLabels []string, customs []MetricLabel) []string {
|
||||
labels := make([]string, len(baseLabels), len(baseLabels)+len(customs))
|
||||
copy(labels, baseLabels)
|
||||
for _, label := range customs {
|
||||
//Check if the additional label is already in the base labels. We cannot have duplicate labels
|
||||
//If it's in the base, just skip it as the compute function is going to overwrite the default value
|
||||
contains := false
|
||||
for _, baseLabel := range baseLabels {
|
||||
if baseLabel == label.Label {
|
||||
contains = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !contains {
|
||||
labels = append(labels, label.Label)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
70
components/metrics/publisher.go
Normal file
70
components/metrics/publisher.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
publisherLabelKeys = []string{
|
||||
labelKeyHandlerName,
|
||||
labelKeyPublisherName,
|
||||
labelSuccess,
|
||||
}
|
||||
)
|
||||
|
||||
// PublisherPrometheusMetricsDecorator decorates a publisher to capture Prometheus metrics.
|
||||
type PublisherPrometheusMetricsDecorator struct {
|
||||
pub message.Publisher
|
||||
publisherName string
|
||||
publishTimeSeconds *prometheus.HistogramVec
|
||||
additionalLabels []MetricLabel
|
||||
}
|
||||
|
||||
// Publish updates the relevant publisher metrics and calls the wrapped publisher's Publish.
|
||||
func (m PublisherPrometheusMetricsDecorator) Publish(topic string, messages ...*message.Message) (err error) {
|
||||
if len(messages) == 0 {
|
||||
return m.pub.Publish(topic)
|
||||
}
|
||||
|
||||
// TODO: take ctx not only from first msg. Might require changing the signature of Publish, which is planned anyway.
|
||||
ctx := messages[0].Context()
|
||||
labels := labelsFromCtx(ctx, publisherLabelKeys...)
|
||||
if labels[labelKeyPublisherName] == "" {
|
||||
labels[labelKeyPublisherName] = m.publisherName
|
||||
}
|
||||
if labels[labelKeyHandlerName] == "" {
|
||||
labels[labelKeyHandlerName] = labelValueNoHandler
|
||||
}
|
||||
for _, lb := range m.additionalLabels {
|
||||
labels[lb.Label] = lb.ComputeValueFn(ctx)
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
if publishAlreadyObserved(ctx) {
|
||||
// decorator idempotency when applied decorator multiple times
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
labels[labelSuccess] = "false"
|
||||
} else {
|
||||
labels[labelSuccess] = "true"
|
||||
}
|
||||
m.publishTimeSeconds.With(labels).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
for _, msg := range messages {
|
||||
msg.SetContext(setPublishObservedToCtx(msg.Context()))
|
||||
}
|
||||
|
||||
return m.pub.Publish(topic, messages...)
|
||||
}
|
||||
|
||||
// Close decreases the total publisher count, closes the Prometheus HTTP server and calls wrapped Close.
|
||||
func (m PublisherPrometheusMetricsDecorator) Close() error {
|
||||
return m.pub.Close()
|
||||
}
|
||||
57
components/metrics/subscriber.go
Normal file
57
components/metrics/subscriber.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
subscriberLabelKeys = []string{
|
||||
labelKeyHandlerName,
|
||||
labelKeySubscriberName,
|
||||
}
|
||||
)
|
||||
|
||||
// SubscriberPrometheusMetricsDecorator decorates a subscriber to capture Prometheus metrics.
|
||||
type SubscriberPrometheusMetricsDecorator struct {
|
||||
message.Subscriber
|
||||
subscriberName string
|
||||
subscriberMessagesReceivedTotal *prometheus.CounterVec
|
||||
closing chan struct{}
|
||||
additionalLabels []MetricLabel
|
||||
}
|
||||
|
||||
func (s SubscriberPrometheusMetricsDecorator) recordMetrics(msg *message.Message) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := msg.Context()
|
||||
labels := labelsFromCtx(ctx, subscriberLabelKeys...)
|
||||
if labels[labelKeySubscriberName] == "" {
|
||||
labels[labelKeySubscriberName] = s.subscriberName
|
||||
}
|
||||
if labels[labelKeyHandlerName] == "" {
|
||||
labels[labelKeyHandlerName] = labelValueNoHandler
|
||||
}
|
||||
for _, lb := range s.additionalLabels {
|
||||
labels[lb.Label] = lb.ComputeValueFn(ctx)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if subscribeAlreadyObserved(ctx) {
|
||||
// decorator idempotency when applied decorator multiple times
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-msg.Acked():
|
||||
labels[labelAcked] = "acked"
|
||||
case <-msg.Nacked():
|
||||
labels[labelAcked] = "nacked"
|
||||
}
|
||||
s.subscriberMessagesReceivedTotal.With(labels).Inc()
|
||||
}()
|
||||
|
||||
msg.SetContext(setSubscribeObservedToCtx(msg.Context()))
|
||||
}
|
||||
298
components/requestreply/backend_pubsub.go
Normal file
298
components/requestreply/backend_pubsub.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package requestreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// PubSubBackend is a Backend that uses Pub/Sub to transport commands and replies.
|
||||
type PubSubBackend[Result any] struct {
|
||||
config PubSubBackendConfig
|
||||
marshaler BackendPubsubMarshaler[Result]
|
||||
}
|
||||
|
||||
// NewPubSubBackend creates a new PubSubBackend.
|
||||
//
|
||||
// If you want to use backend together with `NewCommandHandler` (without result), you should pass `NoResult` or `struct{}` as Result type.
|
||||
func NewPubSubBackend[Result any](
|
||||
config PubSubBackendConfig,
|
||||
marshaler BackendPubsubMarshaler[Result],
|
||||
) (*PubSubBackend[Result], error) {
|
||||
config.setDefaults()
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "invalid config")
|
||||
}
|
||||
if marshaler == nil {
|
||||
return nil, errors.New("marshaler cannot be nil")
|
||||
}
|
||||
|
||||
return &PubSubBackend[Result]{
|
||||
config: config,
|
||||
marshaler: marshaler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type PubSubBackendSubscribeParams struct {
|
||||
Command any
|
||||
|
||||
OperationID OperationID
|
||||
}
|
||||
|
||||
type PubSubBackendSubscriberConstructorFn func(PubSubBackendSubscribeParams) (message.Subscriber, error)
|
||||
|
||||
type PubSubBackendGenerateSubscribeTopicFn func(PubSubBackendSubscribeParams) (string, error)
|
||||
|
||||
type PubSubBackendPublishParams struct {
|
||||
Command any
|
||||
|
||||
CommandMessage *message.Message
|
||||
|
||||
OperationID OperationID
|
||||
}
|
||||
|
||||
type PubSubBackendGeneratePublishTopicFn func(PubSubBackendPublishParams) (string, error)
|
||||
|
||||
type PubSubBackendOnCommandProcessedParams struct {
|
||||
HandleErr error
|
||||
|
||||
PubSubBackendPublishParams
|
||||
}
|
||||
|
||||
type PubSubBackendModifyNotificationMessageFn func(msg *message.Message, params PubSubBackendOnCommandProcessedParams) error
|
||||
|
||||
type PubSubBackendOnListenForReplyFinishedFn func(ctx context.Context, params PubSubBackendSubscribeParams)
|
||||
|
||||
type ReplyPublishErrorHandler func(replyTopic string, notificationMsg *message.Message, err error) error
|
||||
|
||||
type PubSubBackendConfig struct {
|
||||
Publisher message.Publisher
|
||||
SubscriberConstructor PubSubBackendSubscriberConstructorFn
|
||||
|
||||
GeneratePublishTopic PubSubBackendGeneratePublishTopicFn
|
||||
GenerateSubscribeTopic PubSubBackendGenerateSubscribeTopicFn
|
||||
|
||||
Logger watermill.LoggerAdapter
|
||||
|
||||
ListenForReplyTimeout *time.Duration
|
||||
|
||||
ModifyNotificationMessage PubSubBackendModifyNotificationMessageFn
|
||||
|
||||
OnListenForReplyFinished PubSubBackendOnListenForReplyFinishedFn
|
||||
|
||||
// AckCommandErrors determines if the command should be acked or nacked when handler returns an error.
|
||||
// Command will be nacked by default when sending reply fails, you can control this behaviour with the
|
||||
// ReplyPublishErrorHandler config option.
|
||||
// You should use this option instead of cqrs.CommandProcessorConfig.AckCommandHandlingErrors, as it's aware
|
||||
// if error was returned by handler or sending reply failed.
|
||||
AckCommandErrors bool
|
||||
|
||||
// ReplyPublishErrorHandler if not nil will be invoked when sending the reply fails. If it returns an error
|
||||
// the command will be nacked.
|
||||
ReplyPublishErrorHandler ReplyPublishErrorHandler
|
||||
}
|
||||
|
||||
func (p *PubSubBackendConfig) setDefaults() {
|
||||
if p.Logger == nil {
|
||||
p.Logger = watermill.NopLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PubSubBackendConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if p.Publisher == nil {
|
||||
err = stdErrors.Join(err, errors.New("publisher cannot be nil"))
|
||||
}
|
||||
if p.SubscriberConstructor == nil {
|
||||
err = stdErrors.Join(err, errors.New("subscriber constructor cannot be nil"))
|
||||
}
|
||||
if p.GeneratePublishTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("GeneratePublishTopic cannot be nil"))
|
||||
}
|
||||
if p.GenerateSubscribeTopic == nil {
|
||||
err = stdErrors.Join(err, errors.New("GenerateSubscribeTopic cannot be nil"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (p PubSubBackend[Result]) ListenForNotifications(
|
||||
ctx context.Context,
|
||||
params BackendListenForNotificationsParams,
|
||||
) (<-chan Reply[Result], error) {
|
||||
start := time.Now()
|
||||
|
||||
replyContext := PubSubBackendSubscribeParams(params)
|
||||
|
||||
// this needs to be done before publishing the message to avoid race condition
|
||||
notificationsSubscriber, err := p.config.SubscriberConstructor(replyContext)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create request/reply notifications subscriber")
|
||||
}
|
||||
|
||||
replyNotificationTopic, err := p.config.GenerateSubscribeTopic(replyContext)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot generate request/reply notifications topic")
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if p.config.ListenForReplyTimeout != nil {
|
||||
ctx, cancel = context.WithTimeout(ctx, *p.config.ListenForReplyTimeout)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
notifyMsgs, err := notificationsSubscriber.Subscribe(ctx, replyNotificationTopic)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, errors.Wrap(err, "cannot subscribe to request/reply notifications topic")
|
||||
}
|
||||
|
||||
p.config.Logger.Debug(
|
||||
"Subscribed to request/reply notifications topic",
|
||||
watermill.LogFields{
|
||||
"request_reply_topic": replyNotificationTopic,
|
||||
},
|
||||
)
|
||||
|
||||
replyChan := make(chan Reply[Result], 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if p.config.OnListenForReplyFinished == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.config.OnListenForReplyFinished(ctx, replyContext)
|
||||
}()
|
||||
defer close(replyChan)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
replyChan <- Reply[Result]{
|
||||
Error: ReplyTimeoutError{time.Since(start), ctx.Err()},
|
||||
}
|
||||
return
|
||||
case notifyMsg, ok := <-notifyMsgs:
|
||||
if !ok {
|
||||
// subscriber is closed
|
||||
replyChan <- Reply[Result]{
|
||||
Error: ReplyTimeoutError{time.Since(start), fmt.Errorf("subscriber closed")},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
resp, ok, unmarshalErr := p.handleNotifyMsg(notifyMsg, string(params.OperationID), p.marshaler)
|
||||
if unmarshalErr != nil {
|
||||
replyChan <- Reply[Result]{
|
||||
Error: ReplyUnmarshalError{unmarshalErr},
|
||||
}
|
||||
} else if ok {
|
||||
replyChan <- Reply[Result]{
|
||||
HandlerResult: resp.HandlerResult,
|
||||
Error: resp.Error,
|
||||
NotificationMessage: notifyMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// we assume that more messages may arrive (in case of fan-out commands handling) - we don't exit yet
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return replyChan, nil
|
||||
}
|
||||
|
||||
const OperationIDMetadataKey = "_watermill_requestreply_op_id"
|
||||
|
||||
func (p PubSubBackend[Result]) OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error {
|
||||
p.config.Logger.Debug("Sending request reply", nil)
|
||||
|
||||
notificationMsg, err := p.marshaler.MarshalReply(params)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot marshal request reply notification")
|
||||
}
|
||||
notificationMsg.SetContext(ctx)
|
||||
|
||||
operationID, err := operationIDFromMetadata(params.CommandMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notificationMsg.Metadata.Set(OperationIDMetadataKey, string(operationID))
|
||||
|
||||
if p.config.ModifyNotificationMessage != nil {
|
||||
processedContext := PubSubBackendOnCommandProcessedParams{
|
||||
HandleErr: params.HandleErr,
|
||||
PubSubBackendPublishParams: PubSubBackendPublishParams{
|
||||
Command: params.Command,
|
||||
CommandMessage: params.CommandMessage,
|
||||
OperationID: operationID,
|
||||
},
|
||||
}
|
||||
if err := p.config.ModifyNotificationMessage(notificationMsg, processedContext); err != nil {
|
||||
return errors.Wrap(err, "cannot modify notification message")
|
||||
}
|
||||
}
|
||||
|
||||
replyTopic, err := p.config.GeneratePublishTopic(PubSubBackendPublishParams{
|
||||
Command: params.Command,
|
||||
CommandMessage: params.CommandMessage,
|
||||
OperationID: operationID,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot generate request/reply notify topic")
|
||||
}
|
||||
|
||||
err = p.config.Publisher.Publish(replyTopic, notificationMsg)
|
||||
if err != nil {
|
||||
if p.config.ReplyPublishErrorHandler != nil {
|
||||
err = p.config.ReplyPublishErrorHandler(replyTopic, notificationMsg, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot publish command executed message")
|
||||
}
|
||||
|
||||
if p.config.AckCommandErrors {
|
||||
// we are ignoring handler error - message will be acked
|
||||
return nil
|
||||
} else {
|
||||
// if handler returned error, it will nack the message
|
||||
// if params.HandleErr is nil, message will be acked
|
||||
return params.HandleErr
|
||||
}
|
||||
}
|
||||
|
||||
func operationIDFromMetadata(msg *message.Message) (OperationID, error) {
|
||||
operationID := msg.Metadata.Get(OperationIDMetadataKey)
|
||||
if operationID == "" {
|
||||
return "", errors.Errorf("cannot get notification ID from command message metadata, key: %s", OperationIDMetadataKey)
|
||||
}
|
||||
|
||||
return OperationID(operationID), nil
|
||||
}
|
||||
|
||||
func (p PubSubBackend[Result]) handleNotifyMsg(
|
||||
msg *message.Message,
|
||||
expectedCommandUuid string,
|
||||
marshaler BackendPubsubMarshaler[Result],
|
||||
) (Reply[Result], bool, error) {
|
||||
defer msg.Ack()
|
||||
|
||||
if msg.Metadata.Get(OperationIDMetadataKey) != expectedCommandUuid {
|
||||
p.config.Logger.Debug("Received notify message with different command UUID", nil)
|
||||
return Reply[Result]{}, false, nil
|
||||
}
|
||||
|
||||
res, unmarshalErr := marshaler.UnmarshalReply(msg)
|
||||
return res, true, unmarshalErr
|
||||
}
|
||||
58
components/requestreply/backend_pubsub_marshaler.go
Normal file
58
components/requestreply/backend_pubsub_marshaler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package requestreply
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type BackendPubsubMarshaler[Result any] interface {
|
||||
MarshalReply(params BackendOnCommandProcessedParams[Result]) (*message.Message, error)
|
||||
UnmarshalReply(msg *message.Message) (reply Reply[Result], err error)
|
||||
}
|
||||
|
||||
const (
|
||||
ErrorMetadataKey = "_watermill_requestreply_error"
|
||||
HasErrorMetadataKey = "_watermill_requestreply_has_error"
|
||||
)
|
||||
|
||||
type BackendPubsubJSONMarshaler[Result any] struct{}
|
||||
|
||||
func (m BackendPubsubJSONMarshaler[Result]) MarshalReply(
|
||||
params BackendOnCommandProcessedParams[Result],
|
||||
) (*message.Message, error) {
|
||||
msg := message.NewMessage(watermill.NewUUID(), nil)
|
||||
|
||||
if params.HandleErr != nil {
|
||||
msg.Metadata.Set(ErrorMetadataKey, params.HandleErr.Error())
|
||||
msg.Metadata.Set(HasErrorMetadataKey, "1")
|
||||
} else {
|
||||
msg.Metadata.Set(HasErrorMetadataKey, "0")
|
||||
}
|
||||
|
||||
b, err := json.Marshal(params.HandlerResult)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot marshal reply")
|
||||
}
|
||||
msg.Payload = b
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m BackendPubsubJSONMarshaler[Result]) UnmarshalReply(msg *message.Message) (Reply[Result], error) {
|
||||
reply := Reply[Result]{}
|
||||
|
||||
if msg.Metadata.Get(HasErrorMetadataKey) == "1" {
|
||||
reply.Error = errors.New(msg.Metadata.Get(ErrorMetadataKey))
|
||||
}
|
||||
|
||||
var result Result
|
||||
if err := json.Unmarshal(msg.Payload, &result); err != nil {
|
||||
return Reply[Result]{}, errors.Wrap(err, "cannot unmarshal result")
|
||||
}
|
||||
reply.HandlerResult = result
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
128
components/requestreply/command_bus.go
Normal file
128
components/requestreply/command_bus.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package requestreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type CommandBus interface {
|
||||
SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error
|
||||
}
|
||||
|
||||
// SendWithReply sends command to the command bus and receives a replies of the command handler.
|
||||
// It returns a channel with replies, cancel function and error.
|
||||
// If more than one replies are sent, only the first which is received is returned.
|
||||
//
|
||||
// If you expect multiple replies, please use SendWithReplies instead.
|
||||
//
|
||||
// SendWithReply is blocking until the first reply is received or the context is canceled.
|
||||
// SendWithReply can be cancelled by cancelling context or
|
||||
// by exceeding the timeout set in the backend (if set).
|
||||
//
|
||||
// SendWithReply can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler).
|
||||
// If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type:
|
||||
//
|
||||
// reply, err := requestreply.SendWithReply[requestreply.NoResult](
|
||||
// context.Background(),
|
||||
// ts.CommandBus,
|
||||
// ts.RequestReplyBackend,
|
||||
// &TestCommand{ID: "1"},
|
||||
// )
|
||||
//
|
||||
// If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type:
|
||||
//
|
||||
// reply, err := requestreply.SendWithReply[SomeTypeReturnedByHandler](
|
||||
// context.Background(),
|
||||
// ts.CommandBus,
|
||||
// ts.RequestReplyBackend,
|
||||
// &TestCommand{ID: "1"},
|
||||
// )
|
||||
func SendWithReply[Result any](
|
||||
ctx context.Context,
|
||||
c CommandBus,
|
||||
backend Backend[Result],
|
||||
cmd any,
|
||||
) (Reply[Result], error) {
|
||||
replyCh, cancel, err := SendWithReplies[Result](ctx, c, backend, cmd)
|
||||
if err != nil {
|
||||
return Reply[Result]{}, errors.Wrap(err, "SendWithReplies failed")
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Reply[Result]{}, errors.Wrap(ctx.Err(), "context closed")
|
||||
case reply := <-replyCh:
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendWithReplies sends command to the command bus and receives a replies of the command handler.
|
||||
// It returns a channel with replies, cancel function and error.
|
||||
//
|
||||
// SendWithReplies can be cancelled by calling cancel function or by cancelling context or
|
||||
// When SendWithReplies is canceled, the returned channel is closed as well.
|
||||
// by exceeding the timeout set in the backend (if set).
|
||||
// Warning: It's important to cancel the function, because it's listening for the replies in the background.
|
||||
// Lack of cancelling the function can lead to subscriber leak.
|
||||
//
|
||||
// SendWithReplies can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler).
|
||||
// If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type:
|
||||
//
|
||||
// replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
|
||||
// context.Background(),
|
||||
// ts.CommandBus,
|
||||
// ts.RequestReplyBackend,
|
||||
// &TestCommand{ID: "1"},
|
||||
// )
|
||||
//
|
||||
// If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type:
|
||||
//
|
||||
// replyCh, cancel, err := requestreply.SendWithReplies[SomeTypeReturnedByHandler](
|
||||
// context.Background(),
|
||||
// ts.CommandBus,
|
||||
// ts.RequestReplyBackend,
|
||||
// &TestCommand{ID: "1"},
|
||||
// )
|
||||
//
|
||||
// SendWithReplies will send the replies to the channel until the context is cancelled or the timeout is exceeded.
|
||||
// They are multiple cases when more than one reply can be sent:
|
||||
// - when the handler returns an error, and backend is configured to nack the message on error
|
||||
// (for the PubSubBackend, it depends on `PubSubBackendConfig.AckCommandErrors` option.),
|
||||
// - when you are using fan-out mechanism and commands are handled multiple times,
|
||||
func SendWithReplies[Result any](
|
||||
ctx context.Context,
|
||||
c CommandBus,
|
||||
backend Backend[Result],
|
||||
cmd any,
|
||||
) (replCh <-chan Reply[Result], cancel func(), err error) {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
operationID := watermill.NewUUID()
|
||||
|
||||
replyChan, err := backend.ListenForNotifications(ctx, BackendListenForNotificationsParams{
|
||||
Command: cmd,
|
||||
OperationID: OperationID(operationID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, cancel, errors.Wrap(err, "cannot listen for reply")
|
||||
}
|
||||
|
||||
if err := c.SendWithModifiedMessage(ctx, cmd, func(m *message.Message) error {
|
||||
m.Metadata.Set(OperationIDMetadataKey, operationID)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, cancel, errors.Wrap(err, "cannot send command")
|
||||
}
|
||||
|
||||
return replyChan, cancel, nil
|
||||
}
|
||||
81
components/requestreply/handler.go
Normal file
81
components/requestreply/handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package requestreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// NewCommandHandler creates a new CommandHandler which supports the request-reply pattern.
|
||||
// The result handler is handler compatible with cqrs.CommandHandler.
|
||||
//
|
||||
// The logic if a command should be acked or not is based on the logic of the Backend.
|
||||
// For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option.
|
||||
func NewCommandHandler[Command any](
|
||||
handlerName string,
|
||||
backend Backend[struct{}],
|
||||
handleFunc func(ctx context.Context, cmd *Command) error,
|
||||
) cqrs.CommandHandler {
|
||||
return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error {
|
||||
handlerErr := handleFunc(ctx, cmd)
|
||||
|
||||
originalMessage, err := originalCommandMsgFromCtx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[struct{}]{
|
||||
Command: cmd,
|
||||
CommandMessage: originalMessage,
|
||||
HandleErr: handlerErr,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// NewCommandHandlerWithResult creates a new CommandHandler which supports the request-reply pattern with a result.
|
||||
// The result handler is handler compatible with cqrs.CommandHandler.
|
||||
//
|
||||
// In addition to cqrs.CommandHandler, it also allows returning a result from the handler.
|
||||
// The result is passed to the Backend implementation and sent to the caller.
|
||||
//
|
||||
// The logic if a command should be acked or not is based on the logic of the Backend.
|
||||
// For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option.
|
||||
//
|
||||
// The reply is sent to the caller, even if the handler returns an error.
|
||||
func NewCommandHandlerWithResult[Command any, Result any](
|
||||
handlerName string,
|
||||
backend Backend[Result],
|
||||
handleFunc func(ctx context.Context, cmd *Command) (Result, error),
|
||||
) cqrs.CommandHandler {
|
||||
return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error {
|
||||
resp, handlerErr := handleFunc(ctx, cmd)
|
||||
|
||||
originalMessage, err := originalCommandMsgFromCtx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[Result]{
|
||||
Command: cmd,
|
||||
CommandMessage: originalMessage,
|
||||
HandlerResult: resp,
|
||||
HandleErr: handlerErr,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func originalCommandMsgFromCtx(ctx context.Context) (*message.Message, error) {
|
||||
originalMessage := cqrs.OriginalMessageFromCtx(ctx)
|
||||
if originalMessage == nil {
|
||||
// This should not happen, as long as cqrs.CommandProcessor is used - but it's not mandatory.
|
||||
// In this case, it's enough to use cqrs.CtxWithOriginalMessage
|
||||
return nil, errors.New(
|
||||
"original message not found in context, did you pass context correctly everywhere? " +
|
||||
"did you use cqrs.CommandProcessor? " +
|
||||
"if you are using custom implementation, please call cqrs.CtxWithOriginalMessage on the context passed to the handler",
|
||||
)
|
||||
}
|
||||
return originalMessage, nil
|
||||
}
|
||||
91
components/requestreply/requestreply.go
Normal file
91
components/requestreply/requestreply.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package requestreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
// NoResult is a result type for commands that don't have result.
|
||||
type NoResult = struct{}
|
||||
|
||||
type Reply[Result any] struct {
|
||||
// HandlerResult contains the handler result.
|
||||
// It's preset only when NewCommandHandlerWithResult is used. If NewCommandHandler is used, HandlerResult is empty.
|
||||
//
|
||||
// Result is sent even if the handler returns an error.
|
||||
HandlerResult Result
|
||||
|
||||
// Error contains the error returned by the command handler or the Backend when handling notification fails.
|
||||
// Handling the notification can fail, for example, when unmarshaling the message or if there's a timeout.
|
||||
// If listening for a reply times out or the context is canceled, the Error is ReplyTimeoutError.
|
||||
//
|
||||
// If an error from the handler is returned, CommandHandlerError is returned.
|
||||
// If processing was successful, Error is nil.
|
||||
Error error
|
||||
|
||||
// NotificationMessage contains the notification message sent after the command is handled.
|
||||
// It's present only if the request/reply backend uses a Pub/Sub for notifications (for example, PubSubBackend).
|
||||
//
|
||||
// Warning: NotificationMessage is nil if a timeout occurs.
|
||||
NotificationMessage *message.Message
|
||||
}
|
||||
|
||||
type Backend[Result any] interface {
|
||||
ListenForNotifications(ctx context.Context, params BackendListenForNotificationsParams) (<-chan Reply[Result], error)
|
||||
OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error
|
||||
}
|
||||
|
||||
type BackendListenForNotificationsParams struct {
|
||||
Command any
|
||||
OperationID OperationID
|
||||
}
|
||||
|
||||
type BackendOnCommandProcessedParams[Result any] struct {
|
||||
Command any
|
||||
CommandMessage *message.Message
|
||||
|
||||
HandlerResult Result
|
||||
HandleErr error
|
||||
}
|
||||
|
||||
// OperationID is a unique identifier of a command.
|
||||
// It correlates commands with replies between the bus and the handler.
|
||||
type OperationID string
|
||||
|
||||
// ReplyTimeoutError is returned when the reply timeout is exceeded.
|
||||
type ReplyTimeoutError struct {
|
||||
Duration time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e ReplyTimeoutError) Error() string {
|
||||
return fmt.Sprintf("reply timeout after %s: %s", e.Duration, e.Err)
|
||||
}
|
||||
|
||||
type ReplyUnmarshalError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (r ReplyUnmarshalError) Error() string {
|
||||
return fmt.Sprintf("cannot unmarshal reply: %s", r.Err)
|
||||
}
|
||||
|
||||
func (r ReplyUnmarshalError) Unwrap() error {
|
||||
return r.Err
|
||||
}
|
||||
|
||||
// CommandHandlerError is returned when the command handler returns an error.
|
||||
type CommandHandlerError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e CommandHandlerError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e CommandHandlerError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
772
components/requestreply/requestreply_test.go
Normal file
772
components/requestreply/requestreply_test.go
Normal file
@@ -0,0 +1,772 @@
|
||||
package requestreply_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
||||
"github.com/ThreeDotsLabs/watermill/components/requestreply"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestServices[Result any] struct {
|
||||
Logger watermill.LoggerAdapter
|
||||
Marshaler cqrs.CommandEventMarshaler
|
||||
PubSub *gochannel.GoChannel
|
||||
Router *message.Router
|
||||
|
||||
CommandBus *cqrs.CommandBus
|
||||
CommandProcessor *cqrs.CommandProcessor
|
||||
|
||||
RequestReplyBackend *requestreply.PubSubBackend[Result]
|
||||
BackendConfig requestreply.PubSubBackendConfig
|
||||
}
|
||||
|
||||
type TestServicesConfig struct {
|
||||
DoNotAckOnCommandErrors bool
|
||||
ListenForReplyTimeout *time.Duration
|
||||
|
||||
AssertNotificationMessage func(t *testing.T, msg *message.Message)
|
||||
|
||||
DoNotBlockPublishUntilSubscriberAck bool
|
||||
}
|
||||
|
||||
func NewTestServices[Result any](t *testing.T, c TestServicesConfig) TestServices[Result] {
|
||||
t.Helper()
|
||||
|
||||
logger := watermill.NewStdLogger(true, true)
|
||||
marshaler := cqrs.JSONMarshaler{}
|
||||
|
||||
pubSub := gochannel.NewGoChannel(
|
||||
gochannel.Config{BlockPublishUntilSubscriberAck: false},
|
||||
logger,
|
||||
)
|
||||
|
||||
backendConfig := requestreply.PubSubBackendConfig{
|
||||
Publisher: pubSub,
|
||||
SubscriberConstructor: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (message.Subscriber, error) {
|
||||
assert.NotEmpty(t, subscriberContext.OperationID)
|
||||
assert.NotEmpty(t, subscriberContext.Command)
|
||||
|
||||
return pubSub, nil
|
||||
},
|
||||
GenerateSubscribeTopic: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (string, error) {
|
||||
assert.NotEmpty(t, subscriberContext.OperationID)
|
||||
assert.NotEmpty(t, subscriberContext.Command)
|
||||
|
||||
return "reply", nil
|
||||
},
|
||||
GeneratePublishTopic: func(subscriberContext requestreply.PubSubBackendPublishParams) (string, error) {
|
||||
assert.NotEmpty(t, subscriberContext.OperationID)
|
||||
assert.NotEmpty(t, subscriberContext.Command)
|
||||
assert.NotEmpty(t, subscriberContext.CommandMessage)
|
||||
|
||||
return "reply", nil
|
||||
},
|
||||
Logger: logger,
|
||||
ModifyNotificationMessage: func(msg *message.Message, params requestreply.PubSubBackendOnCommandProcessedParams) error {
|
||||
// to make it deterministic
|
||||
msg.UUID = "1"
|
||||
|
||||
assert.NotEmpty(t, params.OperationID)
|
||||
assert.NotEmpty(t, params.Command)
|
||||
assert.NotEmpty(t, params.CommandMessage)
|
||||
|
||||
// to ensure backward compatibility
|
||||
if c.AssertNotificationMessage != nil {
|
||||
c.AssertNotificationMessage(t, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
AckCommandErrors: !c.DoNotAckOnCommandErrors,
|
||||
ListenForReplyTimeout: c.ListenForReplyTimeout,
|
||||
}
|
||||
backend, err := requestreply.NewPubSubBackend[Result](
|
||||
backendConfig,
|
||||
requestreply.BackendPubsubJSONMarshaler[Result]{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
commandBus, err := cqrs.NewCommandBusWithConfig(pubSub, cqrs.CommandBusConfig{
|
||||
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cqrs.CommandProcessorConfig{
|
||||
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
||||
return "commands", nil
|
||||
},
|
||||
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
||||
return pubSub, nil
|
||||
},
|
||||
Marshaler: marshaler,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return TestServices[Result]{
|
||||
Logger: logger,
|
||||
PubSub: gochannel.NewGoChannel(
|
||||
gochannel.Config{BlockPublishUntilSubscriberAck: !c.DoNotBlockPublishUntilSubscriberAck},
|
||||
logger,
|
||||
),
|
||||
Router: router,
|
||||
RequestReplyBackend: backend,
|
||||
CommandBus: commandBus,
|
||||
CommandProcessor: commandProcessor,
|
||||
Marshaler: marshaler,
|
||||
BackendConfig: backendConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func (ts TestServices[Result]) RunRouter() {
|
||||
go func() {
|
||||
err := ts.Router.Run(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ts.Router.Running()
|
||||
}
|
||||
|
||||
type TestCommand struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type TestCommand2 struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type TestCommandResult struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func TestRequestReply_without_result_no_error(t *testing.T) {
|
||||
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
|
||||
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
|
||||
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
|
||||
},
|
||||
})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandler(
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) error {
|
||||
return nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.Empty(t, reply.HandlerResult)
|
||||
assert.NoError(t, reply.Error)
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_without_result_with_error(t *testing.T) {
|
||||
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
|
||||
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
|
||||
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
|
||||
assert.NotEmpty(t, msg.Metadata.Get(requestreply.ErrorMetadataKey))
|
||||
},
|
||||
})
|
||||
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandler(
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) error {
|
||||
return expectedErr
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.Empty(t, reply.HandlerResult)
|
||||
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_with_result_no_error(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
|
||||
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
|
||||
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
|
||||
},
|
||||
})
|
||||
|
||||
expectedResult := TestCommandResult{ID: "123"}
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
return expectedResult, nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, expectedResult, reply.HandlerResult)
|
||||
assert.NoError(t, reply.Error)
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_with_result_with_error(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{})
|
||||
|
||||
expectedResult := TestCommandResult{ID: "123"}
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
return expectedResult, expectedErr
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult)
|
||||
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithReply(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{})
|
||||
|
||||
expectedResult := TestCommandResult{ID: "123"}
|
||||
expectedErr := errors.New("some error")
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
return expectedResult, expectedErr
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
reply, err := requestreply.SendWithReply[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult)
|
||||
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
}
|
||||
|
||||
func TestRequestReply_without_result_multiple_replies(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
|
||||
DoNotAckOnCommandErrors: true,
|
||||
})
|
||||
|
||||
type toSend struct {
|
||||
ID string
|
||||
Err error
|
||||
}
|
||||
|
||||
toSendCh := make(chan toSend, 1)
|
||||
toSendCh <- toSend{ID: "1", Err: fmt.Errorf("error 1")}
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
toSend := <-toSendCh
|
||||
|
||||
return TestCommandResult{ID: toSend.ID}, toSend.Err
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, TestCommandResult{ID: "1"}, reply.HandlerResult)
|
||||
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, "error 1", reply.Error.Error())
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
|
||||
toSendCh <- toSend{ID: "2", Err: fmt.Errorf("error 2")}
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, TestCommandResult{ID: "2"}, reply.HandlerResult)
|
||||
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, "error 2", reply.Error.Error())
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
|
||||
toSendCh <- toSend{ID: "3", Err: nil}
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, TestCommandResult{ID: "3"}, reply.HandlerResult)
|
||||
|
||||
require.NoError(t, reply.Error)
|
||||
|
||||
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_timeout(t *testing.T) {
|
||||
timeout := time.Millisecond * 10
|
||||
|
||||
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
|
||||
ListenForReplyTimeout: &timeout,
|
||||
})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandler[TestCommand](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) error {
|
||||
time.Sleep(time.Second)
|
||||
return nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.Empty(t, reply.HandlerResult)
|
||||
require.Error(t, reply.Error)
|
||||
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
|
||||
|
||||
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
|
||||
assert.Equal(t, context.DeadlineExceeded, replyTimeoutError.Err)
|
||||
assert.NotEmpty(t, replyTimeoutError.Duration)
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_context_cancellation(t *testing.T) {
|
||||
ts := NewTestServices[struct{}](t, TestServicesConfig{})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandler[TestCommand](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) error {
|
||||
time.Sleep(time.Second)
|
||||
return nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
replyCh, _, err := requestreply.SendWithReplies[struct{}](
|
||||
ctx,
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.Empty(t, reply.HandlerResult)
|
||||
require.Error(t, reply.Error)
|
||||
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
|
||||
|
||||
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
|
||||
assert.Contains(
|
||||
t,
|
||||
// it depends on which switch will be executed first
|
||||
[]string{"subscriber closed", context.Canceled.Error()},
|
||||
replyTimeoutError.Err.Error(),
|
||||
)
|
||||
assert.NotEmpty(t, replyTimeoutError.Duration)
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_fn_cancellation(t *testing.T) {
|
||||
ts := NewTestServices[struct{}](t, TestServicesConfig{})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandler[TestCommand](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) error {
|
||||
time.Sleep(time.Second)
|
||||
return nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&TestCommand{ID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.Empty(t, reply.HandlerResult)
|
||||
require.Error(t, reply.Error)
|
||||
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
|
||||
|
||||
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
|
||||
assert.Contains(
|
||||
t,
|
||||
// it depends on which switch will be executed first
|
||||
[]string{"subscriber closed", context.Canceled.Error()},
|
||||
replyTimeoutError.Err.Error(),
|
||||
)
|
||||
assert.NotEmpty(t, replyTimeoutError.Duration)
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReply_parallel_different_handlers(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
|
||||
DoNotAckOnCommandErrors: true,
|
||||
})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler_1",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
|
||||
return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 1 %s", cmd.ID)
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand2, TestCommandResult](
|
||||
"test_handler_2",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand2) (TestCommandResult, error) {
|
||||
|
||||
return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 2 %s", cmd.ID)
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
start := make(chan struct{})
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
<-start
|
||||
|
||||
cmd := TestCommand{ID: watermill.NewUUID()}
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&cmd,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
i := 0
|
||||
|
||||
for reply := range replyCh {
|
||||
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, fmt.Sprintf("error 1 %s", cmd.ID), reply.Error.Error())
|
||||
i++
|
||||
|
||||
if i > 100 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
<-start
|
||||
|
||||
cmd := TestCommand2{ID: watermill.NewUUID()}
|
||||
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&cmd,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
i := 0
|
||||
|
||||
for reply := range replyCh {
|
||||
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
|
||||
require.Error(t, reply.Error)
|
||||
assert.Equal(t, fmt.Sprintf("error 2 %s", cmd.ID), reply.Error.Error())
|
||||
i++
|
||||
|
||||
if i > 100 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// sync workers
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRequestReply_parallel_same_handler(t *testing.T) {
|
||||
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
|
||||
DoNotBlockPublishUntilSubscriberAck: true,
|
||||
})
|
||||
|
||||
err := ts.CommandProcessor.AddHandlers(
|
||||
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
|
||||
"test_handler",
|
||||
ts.RequestReplyBackend,
|
||||
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
|
||||
return TestCommandResult{ID: cmd.ID}, nil
|
||||
},
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.RunRouter()
|
||||
|
||||
count := 20
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(count)
|
||||
|
||||
start := make(chan struct{})
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
<-start
|
||||
|
||||
cmd := TestCommand{ID: uuid.NewString()}
|
||||
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
|
||||
context.Background(),
|
||||
ts.CommandBus,
|
||||
ts.RequestReplyBackend,
|
||||
&cmd,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replyCh)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case reply := <-replyCh:
|
||||
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
|
||||
assert.NoError(t, reply.Error)
|
||||
case <-time.After(time.Millisecond * 100):
|
||||
t.Error("timeout")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sync workers
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestNewPubSubBackend_missing_values(t *testing.T) {
|
||||
t.Run("invalid_config", func(t *testing.T) {
|
||||
invalidConfig := requestreply.PubSubBackendConfig{}
|
||||
require.Error(t, invalidConfig.Validate())
|
||||
|
||||
backend, err := requestreply.NewPubSubBackend[requestreply.NoResult](
|
||||
invalidConfig,
|
||||
requestreply.BackendPubsubJSONMarshaler[requestreply.NoResult]{},
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid config")
|
||||
assert.Nil(t, backend)
|
||||
})
|
||||
|
||||
t.Run("missing_marshaler", func(t *testing.T) {
|
||||
ts := NewTestServices[struct{}](t, TestServicesConfig{})
|
||||
require.NoError(t, ts.BackendConfig.Validate())
|
||||
|
||||
backend, err := requestreply.NewPubSubBackend[requestreply.NoResult](
|
||||
ts.BackendConfig,
|
||||
nil,
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "marshaler cannot be nil")
|
||||
assert.Nil(t, backend)
|
||||
})
|
||||
}
|
||||
158
components/requeuer/requeuer.go
Normal file
158
components/requeuer/requeuer.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package requeuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
const RetriesKey = "_watermill_requeuer_retries"
|
||||
|
||||
// Requeuer is a component that moves messages from one topic to another.
|
||||
// It can be used to requeue messages that failed to process.
|
||||
type Requeuer struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
// GeneratePublishTopicParams are the parameters passed to the GeneratePublishTopic function.
|
||||
type GeneratePublishTopicParams struct {
|
||||
Message *message.Message
|
||||
}
|
||||
|
||||
// Config is the configuration for the Requeuer.
|
||||
type Config struct {
|
||||
// Subscriber is the subscriber to consume messages from. Required.
|
||||
Subscriber message.Subscriber
|
||||
|
||||
// SubscribeTopic is the topic related to the Subscriber to consume messages from. Required.
|
||||
SubscribeTopic string
|
||||
|
||||
// Publisher is the publisher to publish requeued messages to. Required.
|
||||
Publisher message.Publisher
|
||||
|
||||
// GeneratePublishTopic is the topic related to the Publisher to publish the requeued message to.
|
||||
// For example, it could be a constant, or taken from the message's metadata.
|
||||
// Required.
|
||||
GeneratePublishTopic func(params GeneratePublishTopicParams) (string, error)
|
||||
|
||||
// Delay is the duration to wait before requeuing the message. Optional.
|
||||
// The default is no delay.
|
||||
//
|
||||
// This can be useful to avoid requeuing messages too quickly, for example, to avoid
|
||||
// requeuing a message that failed to process due to a temporary issue.
|
||||
//
|
||||
// Avoid setting this to a very high value, as it will block the message processing.
|
||||
Delay time.Duration
|
||||
|
||||
// Router is the custom router to run the requeue handler on. Optional.
|
||||
Router *message.Router
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults(logger watermill.LoggerAdapter) error {
|
||||
if c.Router == nil {
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create router: %w", err)
|
||||
}
|
||||
|
||||
c.Router = router
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Subscriber == nil {
|
||||
return errors.New("subscriber is required")
|
||||
}
|
||||
|
||||
if c.SubscribeTopic == "" {
|
||||
return errors.New("subscribe topic is required")
|
||||
}
|
||||
|
||||
if c.Publisher == nil {
|
||||
return errors.New("publisher is required")
|
||||
}
|
||||
|
||||
if c.GeneratePublishTopic == nil {
|
||||
return errors.New("generate publish topic is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRequeuer creates a new Requeuer with the provided Config.
|
||||
// It's not started automatically. You need to call Run on the returned Requeuer.
|
||||
func NewRequeuer(
|
||||
config Config,
|
||||
logger watermill.LoggerAdapter,
|
||||
) (*Requeuer, error) {
|
||||
if logger == nil {
|
||||
logger = watermill.NewStdLogger(false, false)
|
||||
}
|
||||
|
||||
err := config.setDefaults(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.validate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
r := &Requeuer{
|
||||
config: config,
|
||||
}
|
||||
|
||||
config.Router.AddConsumerHandler(
|
||||
"requeuer",
|
||||
config.SubscribeTopic,
|
||||
config.Subscriber,
|
||||
r.handler,
|
||||
)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Requeuer) handler(msg *message.Message) error {
|
||||
if r.config.Delay > 0 {
|
||||
select {
|
||||
case <-msg.Context().Done():
|
||||
return msg.Context().Err()
|
||||
case <-time.After(r.config.Delay):
|
||||
}
|
||||
}
|
||||
|
||||
topic, err := r.config.GeneratePublishTopic(GeneratePublishTopicParams{Message: msg})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
retriesStr := msg.Metadata.Get(RetriesKey)
|
||||
retries, err := strconv.Atoi(retriesStr)
|
||||
if err != nil {
|
||||
retries = 0
|
||||
}
|
||||
|
||||
retries++
|
||||
|
||||
msg.Metadata.Set(RetriesKey, strconv.Itoa(retries))
|
||||
|
||||
err = r.config.Publisher.Publish(topic, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run runs the Requeuer.
|
||||
func (r *Requeuer) Run(ctx context.Context) error {
|
||||
return r.config.Router.Run(ctx)
|
||||
}
|
||||
105
components/requeuer/requeuer_test.go
Normal file
105
components/requeuer/requeuer_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package requeuer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/components/requeuer"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
)
|
||||
|
||||
func TestRequeue(t *testing.T) {
|
||||
logger := watermill.NewStdLogger(false, false)
|
||||
|
||||
pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
|
||||
|
||||
requeue, err := requeuer.NewRequeuer(requeuer.Config{
|
||||
Subscriber: pubSub,
|
||||
SubscribeTopic: "requeue",
|
||||
Publisher: pubSub,
|
||||
GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) {
|
||||
return "test", nil
|
||||
},
|
||||
Delay: time.Millisecond * 200,
|
||||
}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
err := requeue.Run(context.Background())
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
pq, err := middleware.PoisonQueue(pubSub, "requeue")
|
||||
require.NoError(t, err)
|
||||
|
||||
router.AddMiddleware(pq)
|
||||
|
||||
receivedMessages := make(chan int, 10)
|
||||
|
||||
lock := sync.Mutex{}
|
||||
counter := 0
|
||||
|
||||
router.AddConsumerHandler(
|
||||
"test",
|
||||
"test",
|
||||
pubSub,
|
||||
func(msg *message.Message) error {
|
||||
i, err := strconv.Atoi(string(msg.Payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
counter++
|
||||
|
||||
if counter < 10 && i%2 == 0 {
|
||||
return errors.New("error")
|
||||
}
|
||||
|
||||
receivedMessages <- i
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
go func() {
|
||||
err := router.Run(context.Background())
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
msg := message.NewMessage(watermill.NewUUID(), fmt.Append(nil, i))
|
||||
err := pubSub.Publish("test", msg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
var received []int
|
||||
|
||||
timeout := false
|
||||
for !timeout {
|
||||
select {
|
||||
case i := <-receivedMessages:
|
||||
received = append(received, i)
|
||||
case <-time.After(5 * time.Second):
|
||||
timeout = true
|
||||
}
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, received)
|
||||
}
|
||||
Reference in New Issue
Block a user