[hw-6] add notifier service, kafka

This commit is contained in:
Никита Шубин
2025-07-17 19:20:27 +00:00
parent 424d6905da
commit 6e1ad86128
33 changed files with 1412 additions and 92 deletions

View File

@@ -0,0 +1,83 @@
package app
import (
"context"
"fmt"
"os"
"route256/notifier/internal/app/controller"
"route256/notifier/internal/domain/service"
"route256/notifier/internal/infra/config"
"route256/notifier/internal/infra/messaging/kafka"
"github.com/IBM/sarama"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type App struct {
config *config.Config
controller *controller.Controller
}
func NewApp(configPath string) (*App, error) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("unable to load config: %w", err)
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if cfg.Service.LogLevel != "" {
level, logErr := zerolog.ParseLevel(cfg.Service.LogLevel)
if logErr != nil {
return nil, fmt.Errorf("unknown log level `%s` provided: %w", cfg.Service.LogLevel, logErr)
}
zerolog.SetGlobalLevel(level)
}
log.WithLevel(zerolog.GlobalLevel()).Msgf("using logging level=`%s`", zerolog.GlobalLevel().String())
consumer, err := setupSaramaConsumerGroup([]string{cfg.Kafka.Brokers}, cfg.Kafka.ConsumerGroupID)
if err != nil {
return nil, err
}
kafkaConsumer, err := kafka.NewStatusConsumer(cfg.Kafka.OrderTopic, consumer)
if err != nil {
return nil, fmt.Errorf("NewKafkaStatusConsumer: %w", err)
}
notifierService := service.NewNotifierService(kafkaConsumer)
controller := controller.NewController(notifierService)
return &App{
config: cfg,
controller: controller,
}, err
}
func (a *App) Run(ctx context.Context) {
a.controller.Run(ctx)
}
func (a *App) Shutdown(ctx context.Context) error {
return a.controller.Stop(ctx)
}
func setupSaramaConsumerGroup(brokers []string, groupID string) (sarama.ConsumerGroup, error) {
cfg := sarama.NewConfig()
cfg.Consumer.Group.Rebalance.Strategy = sarama.NewBalanceStrategyRange()
cfg.Consumer.Offsets.Initial = sarama.OffsetNewest
cfg.Metadata.AllowAutoTopicCreation = false
group, err := sarama.NewConsumerGroup(brokers, groupID, cfg)
if err != nil {
return nil, fmt.Errorf("sarama.NewConsumerGroup: %w", err)
}
return group, nil
}

View File

@@ -0,0 +1,74 @@
package controller
import (
"context"
"errors"
"fmt"
"sync"
)
type NotifierService interface {
RunFetchEvents(ctx context.Context) error
}
type Controller struct {
notifier NotifierService
wg sync.WaitGroup
errCh chan error
cancel context.CancelFunc
}
func NewController(notifierService NotifierService) *Controller {
return &Controller{
notifier: notifierService,
wg: sync.WaitGroup{},
errCh: make(chan error, 1),
}
}
// Run service asynchroniously.
func (c *Controller) Run(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.wg.Add(1)
go func() {
defer c.wg.Done()
if err := c.notifier.RunFetchEvents(ctx); err != nil && !errors.Is(err, context.Canceled) {
select {
case c.errCh <- fmt.Errorf("RunFetchEvents: %w", err):
default:
}
}
}()
}
// Gracefully stop service.
func (c *Controller) Stop(ctx context.Context) error {
if c.cancel != nil {
c.cancel()
}
done := make(chan struct{})
go func() {
c.wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
return ctx.Err()
}
close(c.errCh)
select {
case err := <-c.errCh:
return err
default:
return nil
}
}

View File

@@ -0,0 +1,42 @@
package service
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
type StatusConsumer interface {
FetchEvents(ctx context.Context) error
}
type NotifierService struct {
consumer StatusConsumer
}
func NewNotifierService(consumer StatusConsumer) *NotifierService {
return &NotifierService{
consumer: consumer,
}
}
func (s *NotifierService) RunFetchEvents(ctx context.Context) error {
backoff := 1 * time.Second
for {
if err := s.consumer.FetchEvents(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
log.Error().Err(err).Msgf("consume error (retrying in %d second(s))", backoff)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
}
}

View File

@@ -0,0 +1,51 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Service struct {
LogLevel string `yaml:"log_level"`
} `yaml:"service"`
Kafka struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
OrderTopic string `yaml:"order_topic"`
ConsumerGroupID string `yaml:"consumer_group_id"`
Brokers string `yaml:"brokers"`
} `yaml:"kafka"`
}
func LoadConfig(filename string) (*Config, error) {
workDir, err := os.Getwd()
if err != nil {
return nil, err
}
cfgRoot := filepath.Join(workDir, "configs")
absCfgRoot, _ := filepath.Abs(cfgRoot)
filePath := filepath.Join(absCfgRoot, filepath.Clean(filename))
if !strings.HasPrefix(filePath, absCfgRoot) {
return nil, fmt.Errorf("invalid path")
}
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
config := &Config{}
if err := yaml.NewDecoder(f).Decode(config); err != nil {
return nil, err
}
return config, nil
}

View File

@@ -0,0 +1,48 @@
package kafka
import (
"context"
"github.com/IBM/sarama"
"github.com/rs/zerolog/log"
)
type StatusConsumer struct {
group sarama.ConsumerGroup
topic string
}
func NewStatusConsumer(topic string, consumerGroup sarama.ConsumerGroup) (*StatusConsumer, error) {
return &StatusConsumer{
group: consumerGroup,
topic: topic,
}, nil
}
func (c *StatusConsumer) FetchEvents(ctx context.Context) error {
h := &statusHandler{}
return c.group.Consume(ctx, []string{c.topic}, h)
}
type statusHandler struct{}
func (h *statusHandler) Setup(sess sarama.ConsumerGroupSession) error {
log.Info().Msgf("[notifier] assigned %v", sess.Claims())
return nil
}
func (h *statusHandler) Cleanup(_ sarama.ConsumerGroupSession) error {
return nil
}
func (h *statusHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
for msg := range claim.Messages() {
log.Info().Msgf("[order=%s] p=%d off=%d %s\n", string(msg.Key), msg.Partition, msg.Offset, string(msg.Value))
sess.MarkMessage(msg, "")
}
return nil
}