5eebddc680
Motivation: 消息在分发处理前缺少接收环节的观测信息,排查消息丢失、重复消费或报文异常等问题时难以还原现场,需要在消息入口处记录完整的原始信息以支撑问题定位与链路追踪。 Changes: * 在消息处理入口记录原始报文的接收日志 * 日志包含订阅者名称、队列、来源及投递标签等上下文信息 * 完整输出消息体内容,便于还原消息接收现场
215 lines
5.3 KiB
Go
215 lines
5.3 KiB
Go
package subscriber
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"time"
|
|
|
|
"aiaa-notification-service/internal/config"
|
|
"aiaa-notification-service/internal/subscriber/cryptostrategy"
|
|
"aiaa-notification-service/internal/subscriber/tradesignal"
|
|
|
|
amqp "github.com/rabbitmq/amqp091-go"
|
|
)
|
|
|
|
type Subscriber struct {
|
|
cfg config.SubscriptionConfig
|
|
conv MessageConverter
|
|
lookup SourceLookup
|
|
process ProcessFunc
|
|
deduper Deduper
|
|
}
|
|
|
|
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper) (*Subscriber, error) {
|
|
var conv MessageConverter
|
|
switch cfg.Formatter {
|
|
case "trade_signal":
|
|
conv = tradesignal.NewConverter(cfg.StrategyOverrides)
|
|
case "crypto_strategy":
|
|
conv = cryptostrategy.NewConverter()
|
|
default:
|
|
return nil, fmt.Errorf("unknown formatter %q", cfg.Formatter)
|
|
}
|
|
return &Subscriber{
|
|
cfg: cfg,
|
|
conv: conv,
|
|
lookup: lookup,
|
|
process: process,
|
|
deduper: deduper,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Subscriber) Run(ctx context.Context) error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if err := s.consumeOnce(ctx); err != nil {
|
|
slog.Error("subscriber error, reconnecting",
|
|
"name", s.cfg.Name,
|
|
"host", brokerHost(s.cfg.URL),
|
|
"error", err.Error())
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Subscriber) consumeOnce(ctx context.Context) error {
|
|
conn, err := amqp.Dial(s.cfg.URL)
|
|
if err != nil {
|
|
return fmt.Errorf("dial rabbitmq: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
ch, err := conn.Channel()
|
|
if err != nil {
|
|
return fmt.Errorf("open channel: %w", err)
|
|
}
|
|
defer ch.Close()
|
|
|
|
if err := ch.Qos(1, 0, false); err != nil {
|
|
return fmt.Errorf("set qos: %w", err)
|
|
}
|
|
if err := s.ensureQueue(ch); err != nil {
|
|
return err
|
|
}
|
|
|
|
deliveries, err := ch.Consume(s.cfg.Queue, s.cfg.Name, false, false, false, false, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("consume queue %s: %w", s.cfg.Queue, err)
|
|
}
|
|
slog.Info("listening on queue", "name", s.cfg.Name, "queue", s.cfg.Queue)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case d, ok := <-deliveries:
|
|
if !ok {
|
|
return fmt.Errorf("delivery channel closed")
|
|
}
|
|
s.handleDelivery(ch, d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
|
|
if s.cfg.Exchange != "" {
|
|
if err := ch.ExchangeDeclare(s.cfg.Exchange, s.cfg.ExchangeType, true, false, false, false, nil); err != nil {
|
|
return fmt.Errorf("declare exchange %q: %w", s.cfg.Exchange, err)
|
|
}
|
|
}
|
|
|
|
if _, err := ch.QueueDeclare(s.cfg.Queue, true, false, false, false, nil); err != nil {
|
|
return fmt.Errorf("declare queue %q: %w", s.cfg.Queue, err)
|
|
}
|
|
|
|
if s.cfg.Exchange != "" {
|
|
if err := ch.QueueBind(s.cfg.Queue, s.cfg.RoutingKey, s.cfg.Exchange, false, nil); err != nil {
|
|
return fmt.Errorf("bind queue %q to exchange %q: %w", s.cfg.Queue, s.cfg.Exchange, err)
|
|
}
|
|
slog.Info("queue bound", "queue", s.cfg.Queue, "exchange", s.cfg.Exchange, "type", s.cfg.ExchangeType, "routing_key", s.cfg.RoutingKey)
|
|
}
|
|
|
|
if s.cfg.DeadLetterQueue != "" {
|
|
if _, err := ch.QueueDeclare(s.cfg.DeadLetterQueue, true, false, false, false, nil); err != nil {
|
|
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err.Error())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Subscriber) handleDelivery(ch *amqp.Channel, d amqp.Delivery) {
|
|
slog.Info("raw message received",
|
|
"name", s.cfg.Name,
|
|
"queue", s.cfg.Queue,
|
|
"source", s.cfg.Source,
|
|
"delivery_tag", d.DeliveryTag,
|
|
"body", string(d.Body),
|
|
)
|
|
|
|
disp := HandleMessage(context.Background(), HandleInput{
|
|
Body: d.Body,
|
|
Headers: map[string]any(d.Headers),
|
|
Name: s.cfg.Name,
|
|
Queue: s.cfg.Queue,
|
|
SourceName: s.cfg.Source,
|
|
MaxRetry: s.cfg.MaxRetry,
|
|
Deduper: s.deduper,
|
|
}, s.conv, s.lookup, s.process)
|
|
|
|
switch disp {
|
|
case DispositionRetry:
|
|
s.republish(ch, d, s.cfg.Queue)
|
|
case DispositionDLQ:
|
|
if s.cfg.DeadLetterQueue == "" {
|
|
slog.Warn("max retry reached, discarded", "name", s.cfg.Name)
|
|
_ = d.Ack(false)
|
|
return
|
|
}
|
|
s.republish(ch, d, s.cfg.DeadLetterQueue)
|
|
default:
|
|
_ = d.Ack(false)
|
|
}
|
|
}
|
|
|
|
func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string) {
|
|
headers := copyAMQPHeaders(d.Headers)
|
|
headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1
|
|
if err := publishToQueue(ch, queue, d.Body, headers); err != nil {
|
|
slog.Error("requeue failed", "queue", queue, "error", err.Error())
|
|
_ = d.Nack(false, true)
|
|
return
|
|
}
|
|
_ = d.Ack(false)
|
|
if queue == s.cfg.DeadLetterQueue {
|
|
slog.Warn("message moved to dlq", "name", s.cfg.Name, "retries", s.cfg.MaxRetry)
|
|
return
|
|
}
|
|
slog.Info("message requeued", "name", s.cfg.Name, "retry", headers[retryHeader], "max", s.cfg.MaxRetry)
|
|
}
|
|
|
|
func brokerHost(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Hostname() == "" {
|
|
return ""
|
|
}
|
|
if u.Port() != "" {
|
|
return u.Hostname() + ":" + u.Port()
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func copyAMQPHeaders(headers amqp.Table) amqp.Table {
|
|
out := amqp.Table{}
|
|
for k, v := range headers {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func publishToQueue(ch *amqp.Channel, queue string, body []byte, headers amqp.Table) error {
|
|
return ch.Publish(
|
|
"",
|
|
queue,
|
|
false,
|
|
false,
|
|
amqp.Publishing{
|
|
ContentType: "application/json",
|
|
DeliveryMode: amqp.Persistent,
|
|
Headers: headers,
|
|
Body: body,
|
|
Timestamp: time.Now(),
|
|
},
|
|
)
|
|
}
|