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:
351
_examples/basic/5-cqrs-protobuf/main.go
Normal file
351
_examples/basic/5-cqrs-protobuf/main.go
Normal file
@@ -0,0 +1,351 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user