352 lines
10 KiB
Go
352 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ThreeDotsLabs/watermill"
|
|
"github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp"
|
|
"github.com/ThreeDotsLabs/watermill/components/cqrs"
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
// BookRoomHandler is a command handler, which handles BookRoom command and emits RoomBooked.
|
|
//
|
|
// In CQRS, one command must be handled by only one handler.
|
|
// When another handler with this command is added to command processor, error will be returned.
|
|
type BookRoomHandler struct {
|
|
eventBus *cqrs.EventBus
|
|
}
|
|
|
|
func (b BookRoomHandler) Handle(ctx context.Context, cmd *BookRoom) error {
|
|
// some random price, in production you probably will calculate in wiser way
|
|
price := (rand.Int63n(40) + 1) * 10
|
|
|
|
slog.Info(
|
|
"Booked room",
|
|
"room_id", cmd.RoomId,
|
|
"guest_name", cmd.GuestName,
|
|
"start_date", time.Unix(cmd.StartDate.Seconds, int64(cmd.StartDate.Nanos)),
|
|
"end_date", time.Unix(cmd.EndDate.Seconds, int64(cmd.EndDate.Nanos)),
|
|
)
|
|
|
|
// RoomBooked will be handled by OrderBeerOnRoomBooked event handler,
|
|
// in future RoomBooked may be handled by multiple event handler
|
|
if err := b.eventBus.Publish(ctx, &RoomBooked{
|
|
ReservationId: watermill.NewUUID(),
|
|
RoomId: cmd.RoomId,
|
|
GuestName: cmd.GuestName,
|
|
Price: price,
|
|
StartDate: cmd.StartDate,
|
|
EndDate: cmd.EndDate,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OrderBeerOnRoomBooked is an event handler, which handles RoomBooked event and emits OrderBeer command.
|
|
type OrderBeerOnRoomBooked struct {
|
|
commandBus *cqrs.CommandBus
|
|
}
|
|
|
|
func (o OrderBeerOnRoomBooked) Handle(ctx context.Context, event *RoomBooked) error {
|
|
orderBeerCmd := &OrderBeer{
|
|
RoomId: event.RoomId,
|
|
Count: rand.Int63n(10) + 1,
|
|
}
|
|
|
|
return o.commandBus.Send(ctx, orderBeerCmd)
|
|
}
|
|
|
|
// OrderBeerHandler is a command handler, which handles OrderBeer command and emits BeerOrdered.
|
|
// BeerOrdered is not handled by any event handler, but we may use persistent Pub/Sub to handle it in the future.
|
|
type OrderBeerHandler struct {
|
|
eventBus *cqrs.EventBus
|
|
}
|
|
|
|
func (o OrderBeerHandler) Handle(ctx context.Context, cmd *OrderBeer) error {
|
|
if rand.Int63n(10) == 0 {
|
|
// sometimes there is no beer left, command will be retried
|
|
return fmt.Errorf("no beer left for room %s, please try later", cmd.RoomId)
|
|
}
|
|
|
|
if err := o.eventBus.Publish(ctx, &BeerOrdered{
|
|
RoomId: cmd.RoomId,
|
|
Count: cmd.Count,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
slog.Info(fmt.Sprintf("%d beers ordered to room %s", cmd.Count, cmd.RoomId))
|
|
return nil
|
|
}
|
|
|
|
// BookingsFinancialReport is a read model, which calculates how much money we may earn from bookings.
|
|
// Like OrderBeerOnRoomBooked, it listens for RoomBooked event.
|
|
//
|
|
// This implementation is just writing to the memory. In production, you will probably use some persistent storage.
|
|
type BookingsFinancialReport struct {
|
|
handledBookings map[string]struct{}
|
|
totalCharge int64
|
|
lock sync.Mutex
|
|
}
|
|
|
|
func NewBookingsFinancialReport() *BookingsFinancialReport {
|
|
return &BookingsFinancialReport{handledBookings: map[string]struct{}{}}
|
|
}
|
|
|
|
func (b *BookingsFinancialReport) Handle(ctx context.Context, event *RoomBooked) error {
|
|
// Handle may be called concurrently, so it needs to be thread safe.
|
|
b.lock.Lock()
|
|
defer b.lock.Unlock()
|
|
|
|
// When we are using Pub/Sub which doesn't provide exactly-once delivery semantics, we need to deduplicate messages.
|
|
// GoChannel Pub/Sub provides exactly-once delivery,
|
|
// but let's make this example ready for other Pub/Sub implementations.
|
|
if _, ok := b.handledBookings[event.ReservationId]; ok {
|
|
return nil
|
|
}
|
|
b.handledBookings[event.ReservationId] = struct{}{}
|
|
|
|
b.totalCharge += event.Price
|
|
|
|
slog.Info(fmt.Sprintf(">>> Already booked rooms for $%d\n", b.totalCharge))
|
|
return nil
|
|
}
|
|
|
|
var amqpAddress = "amqp://guest:guest@rabbitmq:5672/"
|
|
|
|
func main() {
|
|
logger := watermill.NewSlogLoggerWithLevelMapping(nil, map[slog.Level]slog.Level{
|
|
slog.LevelInfo: slog.LevelDebug,
|
|
})
|
|
|
|
cqrsMarshaler := cqrs.ProtoMarshaler{
|
|
// It will generate topic names based on the event/command type.
|
|
// So for example, for "RoomBooked" name will be "RoomBooked".
|
|
//
|
|
// This value is used to generate topic names with "generateEventsTopic" and "generateCommandsTopic" functions.
|
|
GenerateName: cqrs.StructName,
|
|
}
|
|
|
|
generateEventsTopic := func(eventName string) string {
|
|
return "events." + eventName
|
|
}
|
|
|
|
generateCommandsTopic := func(commandName string) string {
|
|
return "commands." + commandName
|
|
}
|
|
|
|
// You can use any Pub/Sub implementation from here: https://watermill.io/pubsubs/
|
|
// Detailed RabbitMQ implementation: https://watermill.io/pubsubs/amqp/
|
|
// Commands will be sent to queue, because they need to be consumed once.
|
|
commandsAMQPConfig := amqp.NewDurableQueueConfig(amqpAddress)
|
|
commandsPublisher, err := amqp.NewPublisher(commandsAMQPConfig, logger)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
commandsSubscriber, err := amqp.NewSubscriber(commandsAMQPConfig, logger)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Events will be published to PubSub configured Rabbit, because they may be consumed by multiple consumers.
|
|
// (in that case BookingsFinancialReport and OrderBeerOnRoomBooked).
|
|
eventsPublisher, err := amqp.NewPublisher(amqp.NewDurablePubSubConfig(amqpAddress, nil), logger)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// CQRS is built on messages router. Detailed documentation: https://watermill.io/docs/messages-router/
|
|
router, err := message.NewRouter(message.RouterConfig{}, logger)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Simple middleware which will recover panics from event or command handlers.
|
|
// More about router middlewares you can find in the documentation:
|
|
// https://watermill.io/docs/messages-router/#middleware
|
|
//
|
|
// List of available middlewares you can find in message/router/middleware.
|
|
router.AddMiddleware(middleware.Recoverer)
|
|
|
|
commandBus, err := cqrs.NewCommandBusWithConfig(commandsPublisher, cqrs.CommandBusConfig{
|
|
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
|
|
return generateCommandsTopic(params.CommandName), nil
|
|
},
|
|
OnSend: func(params cqrs.CommandBusOnSendParams) error {
|
|
logger.Info("Sending command", watermill.LogFields{
|
|
"command_name": params.CommandName,
|
|
})
|
|
|
|
params.Message.Metadata.Set("sent_at", time.Now().String())
|
|
|
|
return nil
|
|
},
|
|
Marshaler: cqrsMarshaler,
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
|
|
router,
|
|
cqrs.CommandProcessorConfig{
|
|
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
|
|
return generateCommandsTopic(params.CommandName), nil
|
|
},
|
|
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
|
// we can reuse subscriber, because all commands have separated topics
|
|
return commandsSubscriber, nil
|
|
},
|
|
OnHandle: func(params cqrs.CommandProcessorOnHandleParams) error {
|
|
start := time.Now()
|
|
|
|
err := params.Handler.Handle(params.Message.Context(), params.Command)
|
|
|
|
logger.Info("Command handled", watermill.LogFields{
|
|
"command_name": params.CommandName,
|
|
"duration": time.Since(start),
|
|
"err": err,
|
|
})
|
|
|
|
return err
|
|
},
|
|
Marshaler: cqrsMarshaler,
|
|
Logger: logger,
|
|
},
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
eventBus, err := cqrs.NewEventBusWithConfig(eventsPublisher, cqrs.EventBusConfig{
|
|
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
|
|
return generateEventsTopic(params.EventName), nil
|
|
},
|
|
|
|
OnPublish: func(params cqrs.OnEventSendParams) error {
|
|
logger.Info("Publishing event", watermill.LogFields{
|
|
"event_name": params.EventName,
|
|
})
|
|
|
|
params.Message.Metadata.Set("published_at", time.Now().String())
|
|
|
|
return nil
|
|
},
|
|
|
|
Marshaler: cqrsMarshaler,
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
|
|
router,
|
|
cqrs.EventProcessorConfig{
|
|
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
|
|
return generateEventsTopic(params.EventName), nil
|
|
},
|
|
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
|
|
config := amqp.NewDurablePubSubConfig(
|
|
amqpAddress,
|
|
amqp.GenerateQueueNameTopicNameWithSuffix(params.HandlerName),
|
|
)
|
|
|
|
return amqp.NewSubscriber(config, logger)
|
|
},
|
|
|
|
OnHandle: func(params cqrs.EventProcessorOnHandleParams) error {
|
|
start := time.Now()
|
|
|
|
err := params.Handler.Handle(params.Message.Context(), params.Event)
|
|
|
|
logger.Info("Event handled", watermill.LogFields{
|
|
"event_name": params.EventName,
|
|
"duration": time.Since(start),
|
|
"err": err,
|
|
})
|
|
|
|
return err
|
|
},
|
|
|
|
Marshaler: cqrsMarshaler,
|
|
Logger: logger,
|
|
},
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
err = commandProcessor.AddHandlers(
|
|
cqrs.NewCommandHandler("BookRoomHandler", BookRoomHandler{eventBus}.Handle),
|
|
cqrs.NewCommandHandler("OrderBeerHandler", OrderBeerHandler{eventBus}.Handle),
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
err = eventProcessor.AddHandlers(
|
|
cqrs.NewEventHandler(
|
|
"OrderBeerOnRoomBooked",
|
|
OrderBeerOnRoomBooked{commandBus}.Handle,
|
|
),
|
|
cqrs.NewEventHandler(
|
|
"LogBeerOrdered",
|
|
func(ctx context.Context, event *BeerOrdered) error {
|
|
logger.Info("Beer ordered", watermill.LogFields{
|
|
"room_id": event.RoomId,
|
|
})
|
|
return nil
|
|
},
|
|
),
|
|
cqrs.NewEventHandler(
|
|
"BookingsFinancialReport",
|
|
NewBookingsFinancialReport().Handle,
|
|
),
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// publish BookRoom commands every second to simulate incoming traffic
|
|
go publishCommands(commandBus)
|
|
|
|
// processors are based on router, so they will work when router will start
|
|
if err := router.Run(context.Background()); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func publishCommands(commandBus *cqrs.CommandBus) func() {
|
|
i := 0
|
|
for {
|
|
i++
|
|
|
|
startDate := timestamppb.New(time.Now())
|
|
endDate := timestamppb.New(time.Now().Add(time.Hour * 24 * 3))
|
|
|
|
bookRoomCmd := &BookRoom{
|
|
RoomId: fmt.Sprintf("%d", i),
|
|
GuestName: "John",
|
|
StartDate: startDate,
|
|
EndDate: endDate,
|
|
}
|
|
if err := commandBus.Send(context.Background(), bookRoomCmd); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
time.Sleep(time.Second)
|
|
}
|
|
}
|