DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,656 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/tracing"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
// ErrClosed 表示 bus 或订阅已经关闭,调用方应停止继续投递或订阅。
|
||||
var ErrClosed = errors.New("bus is closed")
|
||||
|
||||
// ErrCloseTimeout 表示关闭阶段仍有 handler 未在预算内收敛。
|
||||
var ErrCloseTimeout = errors.New("bus close timed out waiting for handlers")
|
||||
|
||||
// ErrHandlerRequired 表示订阅入口缺少有效 handler,避免消息到达后才 panic。
|
||||
var ErrHandlerRequired = errors.New("bus handler is required")
|
||||
|
||||
// ErrUnsupported 表示当前适配器不支持该能力,例如 Redis Pub/Sub 不支持 queue group。
|
||||
var ErrUnsupported = errors.New("bus operation is not supported by this adapter")
|
||||
|
||||
type Envelope struct {
|
||||
Topic string `json:"topic"`
|
||||
Payload any `json:"-"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Trace map[string]string `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
type Handler func(context.Context, Envelope) error
|
||||
|
||||
type Subscription interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Bus 是实时发布订阅抽象。内置 memory、Redis Pub/Sub 和 NATS core 适配器都不是持久化消息。
|
||||
// 关键状态变更必须走 EventLog/Outbox,或使用带显式确认语义的 stream 适配器。
|
||||
type Bus interface {
|
||||
Publish(context.Context, string, any) error
|
||||
Request(context.Context, string, any, time.Duration) ([]byte, error)
|
||||
Subscribe(string, Handler) (Subscription, error)
|
||||
QueueSubscribe(string, string, Handler) (Subscription, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
const defHandlerTimeout = 5 * time.Second
|
||||
const defaultCloseTimeout = 5 * time.Second
|
||||
|
||||
type MemoryOptions struct {
|
||||
HandlerTimeout time.Duration
|
||||
CloseTimeout time.Duration
|
||||
Metrics *Metrics
|
||||
}
|
||||
|
||||
type Memory struct {
|
||||
logger *slog.Logger
|
||||
options MemoryOptions
|
||||
metrics *Metrics
|
||||
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
nextID int
|
||||
subs map[string]map[int]Handler
|
||||
queues map[string]map[string]map[int]Handler
|
||||
next map[string]map[string]int
|
||||
|
||||
deliveries sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewMemory(logger *slog.Logger) *Memory {
|
||||
return NewMemoryWithOptions(logger, MemoryOptions{HandlerTimeout: defHandlerTimeout})
|
||||
}
|
||||
|
||||
func NewMemoryWithOptions(logger *slog.Logger, options MemoryOptions) *Memory {
|
||||
if options.HandlerTimeout < 0 {
|
||||
options.HandlerTimeout = 0
|
||||
}
|
||||
if options.CloseTimeout == 0 {
|
||||
options.CloseTimeout = defaultCloseTimeout
|
||||
}
|
||||
if options.CloseTimeout < 0 {
|
||||
options.CloseTimeout = 0
|
||||
}
|
||||
return &Memory{
|
||||
logger: logger,
|
||||
options: options,
|
||||
metrics: options.Metrics,
|
||||
subs: make(map[string]map[int]Handler),
|
||||
queues: make(map[string]map[string]map[int]Handler),
|
||||
next: make(map[string]map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// SetMetrics 在构造后替换指标发射器。平台装配先创建 bus、后创建 metrics 时会用到。
|
||||
// 该方法可与 Publish/Subscribe 并发调用,指针替换受 bus 锁保护。
|
||||
func (b *Memory) SetMetrics(m *Metrics) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.metrics = m
|
||||
b.options.Metrics = m
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Memory) Publish(ctx context.Context, topic string, payload any) error {
|
||||
return b.publishEnvelope(ctx, Envelope{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (b *Memory) publishEnvelope(ctx context.Context, env Envelope) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
if b != nil {
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(classifyHandlerError(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
topic := env.Topic
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
rawHandlers := b.subs[topic]
|
||||
handlers := make([]Handler, 0, len(rawHandlers))
|
||||
for _, handler := range rawHandlers {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
handlers = append(handlers, b.queueHandlersLocked(topic)...)
|
||||
if len(handlers) > 0 {
|
||||
// Add 在释放锁前完成,Close 等待投递收敛时不会漏掉已经选中的 handler。
|
||||
b.deliveries.Add(len(handlers))
|
||||
}
|
||||
metricsRef := b.metrics
|
||||
logger := b.logger
|
||||
handlerTimeout := b.options.HandlerTimeout
|
||||
b.mu.Unlock()
|
||||
|
||||
if env.Topic == "" {
|
||||
env.Topic = topic
|
||||
}
|
||||
if env.PublishedAt.IsZero() {
|
||||
env.PublishedAt = time.Now().UTC()
|
||||
}
|
||||
env = envelopeWithTrace(ctx, env)
|
||||
env.Payload = cloneBusPayload(env.Payload)
|
||||
deliveryParent := context.Background()
|
||||
var deliveryDeadline time.Time
|
||||
var hasDeliveryDeadline bool
|
||||
if ctx != nil {
|
||||
// 发布取消不直接杀掉订阅者;deadline 仍保留,避免慢 handler 脱离调用预算。
|
||||
deliveryParent = context.WithoutCancel(ctx)
|
||||
deliveryDeadline, hasDeliveryDeadline = ctx.Deadline()
|
||||
}
|
||||
deliveryParent = contextWithTrace(deliveryParent, env)
|
||||
|
||||
for _, handler := range handlers {
|
||||
go func(h Handler) {
|
||||
defer b.deliveries.Done()
|
||||
handlerEnv := cloneEnvelope(env)
|
||||
parent := deliveryParent
|
||||
cancel := func() {}
|
||||
if hasDeliveryDeadline {
|
||||
parent, cancel = context.WithDeadline(parent, deliveryDeadline)
|
||||
}
|
||||
defer cancel()
|
||||
deliverWithDeadline(parent, logger, topic, handlerTimeout, handlerEnv, h, metricsRef)
|
||||
}(handler)
|
||||
}
|
||||
metricsRef.recordPublish(ResultOK)
|
||||
metricsRef.recordPublishBytes(payloadSize(env.Payload))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Memory) queueHandlersLocked(topic string) []Handler {
|
||||
topicQueues := b.queues[topic]
|
||||
if len(topicQueues) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Handler, 0, len(topicQueues))
|
||||
for queue, queueHandlers := range topicQueues {
|
||||
if len(queueHandlers) == 0 {
|
||||
continue
|
||||
}
|
||||
ids := make([]int, 0, len(queueHandlers))
|
||||
for id := range queueHandlers {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
if _, ok := b.next[topic]; !ok {
|
||||
b.next[topic] = make(map[string]int)
|
||||
}
|
||||
// 同一 queue 内轮询选择一个订阅者,模拟工作队列语义,而不是广播给所有实例。
|
||||
idx := b.next[topic][queue] % len(ids)
|
||||
b.next[topic][queue]++
|
||||
out = append(out, queueHandlers[ids[idx]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneEnvelope(env Envelope) Envelope {
|
||||
env.Payload = cloneBusPayload(env.Payload)
|
||||
if len(env.Trace) > 0 {
|
||||
traceCopy := make(map[string]string, len(env.Trace))
|
||||
for key, value := range env.Trace {
|
||||
traceCopy[key] = value
|
||||
}
|
||||
env.Trace = traceCopy
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func cloneBusPayload(payload any) any {
|
||||
switch value := payload.(type) {
|
||||
case []byte:
|
||||
return cloneBytes(value)
|
||||
case json.RawMessage:
|
||||
return json.RawMessage(cloneBytes(value))
|
||||
case []string:
|
||||
return cloneStrings(value)
|
||||
case []any:
|
||||
return cloneAnySlice(value)
|
||||
case map[string]string:
|
||||
return cloneStringMap(value)
|
||||
case map[string]any:
|
||||
return cloneAnyMap(value)
|
||||
case contracts.GatewayClientPacket:
|
||||
value.Body = cloneBytes(value.Body)
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
return value
|
||||
case *contracts.GatewayClientPacket:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.GatewayClientPacket)
|
||||
return &cloned
|
||||
case contracts.LogicPlayerResponse:
|
||||
value.Body = cloneBytes(value.Body)
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
return value
|
||||
case *contracts.LogicPlayerResponse:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.LogicPlayerResponse)
|
||||
return &cloned
|
||||
case contracts.GatewayPush:
|
||||
value.Body = cloneBytes(value.Body)
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
return value
|
||||
case *contracts.GatewayPush:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.GatewayPush)
|
||||
return &cloned
|
||||
case contracts.ClusterServiceEvent:
|
||||
value.Meta = cloneStringMap(value.Meta)
|
||||
return value
|
||||
case *contracts.ClusterServiceEvent:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.ClusterServiceEvent)
|
||||
return &cloned
|
||||
case contracts.ChatBroadcast:
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
return value
|
||||
case *contracts.ChatBroadcast:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.ChatBroadcast)
|
||||
return &cloned
|
||||
case contracts.NoticePublished:
|
||||
value.Recipients = cloneStrings(value.Recipients)
|
||||
value.DeliveryIDs = cloneStrings(value.DeliveryIDs)
|
||||
value.Meta = cloneStringMap(value.Meta)
|
||||
return value
|
||||
case *contracts.NoticePublished:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.NoticePublished)
|
||||
return &cloned
|
||||
case contracts.RPCRequest:
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
value.Payload = cloneBytes(value.Payload)
|
||||
return value
|
||||
case *contracts.RPCRequest:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.RPCRequest)
|
||||
return &cloned
|
||||
case contracts.RPCResponse:
|
||||
value.Metadata = cloneStringMap(value.Metadata)
|
||||
value.Payload = cloneBytes(value.Payload)
|
||||
return value
|
||||
case *contracts.RPCResponse:
|
||||
if value == nil {
|
||||
return value
|
||||
}
|
||||
cloned := cloneBusPayload(*value).(contracts.RPCResponse)
|
||||
return &cloned
|
||||
default:
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
func cloneBytes(in []byte) []byte {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), in...)
|
||||
}
|
||||
|
||||
func cloneStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), in...)
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAnySlice(in []any) []any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]any, len(in))
|
||||
for idx, value := range in {
|
||||
out[idx] = cloneBusPayload(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAnyMap(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = cloneBusPayload(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Memory) Request(ctx context.Context, topic string, payload any, timeout time.Duration) ([]byte, error) {
|
||||
return requestWithPubSub(ctx, topic, payload, timeout, b.Subscribe, b.publishEnvelope)
|
||||
}
|
||||
|
||||
// ensureReadyLocked 补齐手工构造 Memory 时缺失的内部索引。
|
||||
// 调用方必须已经持有 b.mu 写锁,因为 queue 轮询状态会在发布快照阶段推进。
|
||||
func (b *Memory) ensureReadyLocked() {
|
||||
if b.subs == nil {
|
||||
b.subs = make(map[string]map[int]Handler)
|
||||
}
|
||||
if b.queues == nil {
|
||||
b.queues = make(map[string]map[string]map[int]Handler)
|
||||
}
|
||||
if b.next == nil {
|
||||
b.next = make(map[string]map[string]int)
|
||||
}
|
||||
if b.options.HandlerTimeout < 0 {
|
||||
b.options.HandlerTimeout = 0
|
||||
}
|
||||
if b.options.CloseTimeout < 0 {
|
||||
b.options.CloseTimeout = 0
|
||||
}
|
||||
if b.metrics == nil && b.options.Metrics != nil {
|
||||
b.metrics = b.options.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return parent, func() {}
|
||||
}
|
||||
return context.WithTimeout(parent, timeout)
|
||||
}
|
||||
|
||||
func deliverWithDeadline(parent context.Context, logger *slog.Logger, topic string, timeout time.Duration, env Envelope, handler Handler, m *Metrics) {
|
||||
handlerCtx, cancel := deliveryContext(parent, timeout)
|
||||
defer cancel()
|
||||
if timeout <= 0 {
|
||||
err, panicked := callHandler(handlerCtx, handler, env)
|
||||
logHandlerError(logger, topic, err)
|
||||
m.recordHandler(handlerOutcome(err, panicked))
|
||||
return
|
||||
}
|
||||
type resultWithPanic struct {
|
||||
err error
|
||||
panicked bool
|
||||
}
|
||||
done := make(chan resultWithPanic, 1)
|
||||
go func() {
|
||||
err, panicked := callHandler(handlerCtx, handler, env)
|
||||
done <- resultWithPanic{err: err, panicked: panicked}
|
||||
}()
|
||||
// 超时只释放 bus 投递流程,不能强杀业务 goroutine。
|
||||
// 因此订阅 handler 必须主动检查 ctx,避免慢任务在后台堆积。
|
||||
select {
|
||||
case result := <-done:
|
||||
logHandlerError(logger, topic, result.err)
|
||||
m.recordHandler(handlerOutcome(result.err, result.panicked))
|
||||
case <-handlerCtx.Done():
|
||||
logHandlerError(logger, topic, handlerCtx.Err())
|
||||
m.recordHandler(handlerOutcome(handlerCtx.Err(), false))
|
||||
}
|
||||
}
|
||||
|
||||
func callHandler(ctx context.Context, handler Handler, env Envelope) (err error, panicked bool) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
err = tracing.PanicError(ctx, "bus.handler", rec, map[string]string{"topic": env.Topic})
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
return handler(ctx, env), false
|
||||
}
|
||||
|
||||
func handlerOutcome(err error, panicked bool) string {
|
||||
if panicked {
|
||||
return ResultPanic
|
||||
}
|
||||
return classifyHandlerError(err)
|
||||
}
|
||||
|
||||
func logHandlerError(logger *slog.Logger, topic string, err error) {
|
||||
if err == nil || logger == nil || errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
logger.Error("bus handler failed", "topic", topic, "error", err)
|
||||
}
|
||||
|
||||
func (b *Memory) Check(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if b.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Memory) Subscribe(topic string, handler Handler) (Subscription, error) {
|
||||
if handler == nil {
|
||||
return nil, ErrHandlerRequired
|
||||
}
|
||||
if b == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
b.nextID++
|
||||
id := b.nextID
|
||||
if _, ok := b.subs[topic]; !ok {
|
||||
b.subs[topic] = make(map[int]Handler)
|
||||
}
|
||||
b.subs[topic][id] = handler
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
|
||||
metricsRef.recordSubscribe(ResultOK)
|
||||
metricsRef.trackSubscription(1)
|
||||
|
||||
return &memorySubscription{
|
||||
closeFn: func() error {
|
||||
b.mu.Lock()
|
||||
if topicSubs, ok := b.subs[topic]; ok {
|
||||
if _, hasSub := topicSubs[id]; hasSub {
|
||||
delete(topicSubs, id)
|
||||
if len(topicSubs) == 0 {
|
||||
delete(b.subs, topic)
|
||||
}
|
||||
trackRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
trackRef.trackSubscription(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Memory) QueueSubscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
||||
queue = normalizeQueueName(queue)
|
||||
if queue == "" {
|
||||
return b.Subscribe(topic, handler)
|
||||
}
|
||||
if handler == nil {
|
||||
return nil, ErrHandlerRequired
|
||||
}
|
||||
if b == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
b.nextID++
|
||||
id := b.nextID
|
||||
if _, ok := b.queues[topic]; !ok {
|
||||
b.queues[topic] = make(map[string]map[int]Handler)
|
||||
}
|
||||
if _, ok := b.queues[topic][queue]; !ok {
|
||||
b.queues[topic][queue] = make(map[int]Handler)
|
||||
}
|
||||
b.queues[topic][queue][id] = handler
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
|
||||
metricsRef.recordSubscribe(ResultOK)
|
||||
metricsRef.trackSubscription(1)
|
||||
|
||||
return &memorySubscription{
|
||||
closeFn: func() error {
|
||||
b.mu.Lock()
|
||||
if topicQueues, ok := b.queues[topic]; ok {
|
||||
if queueSubs, ok := topicQueues[queue]; ok {
|
||||
if _, hasSub := queueSubs[id]; hasSub {
|
||||
delete(queueSubs, id)
|
||||
if len(queueSubs) == 0 {
|
||||
delete(topicQueues, queue)
|
||||
if b.next[topic] != nil {
|
||||
delete(b.next[topic], queue)
|
||||
}
|
||||
}
|
||||
if len(topicQueues) == 0 {
|
||||
delete(b.queues, topic)
|
||||
delete(b.next, topic)
|
||||
}
|
||||
trackRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
trackRef.trackSubscription(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Memory) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.ensureReadyLocked()
|
||||
b.closed = true
|
||||
b.subs = make(map[string]map[int]Handler)
|
||||
b.queues = make(map[string]map[string]map[int]Handler)
|
||||
b.next = make(map[string]map[string]int)
|
||||
closeTimeout := b.options.CloseTimeout
|
||||
b.mu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.deliveries.Wait()
|
||||
close(done)
|
||||
}()
|
||||
// Close 不再接受新发布,并等待已开始的投递收敛;超时由上层关停策略处理。
|
||||
if closeTimeout <= 0 {
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(closeTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return ErrCloseTimeout
|
||||
}
|
||||
}
|
||||
|
||||
type memorySubscription struct {
|
||||
closeFn func() error
|
||||
}
|
||||
|
||||
func (s *memorySubscription) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.closeFn == nil {
|
||||
return nil
|
||||
}
|
||||
return s.closeFn()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStreamRequired = errors.New("bus stream is required")
|
||||
ErrConsumerRequired = errors.New("bus consumer is required")
|
||||
)
|
||||
|
||||
type StreamMessage struct {
|
||||
Stream string `json:"stream"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
type PublishOptions struct {
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
type ConsumeOptions struct {
|
||||
Consumer string `json:"consumer"`
|
||||
From string `json:"from,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type DurablePublisher interface {
|
||||
PublishDurable(context.Context, StreamMessage, PublishOptions) error
|
||||
}
|
||||
|
||||
type DurableConsumer interface {
|
||||
ConsumeDurable(context.Context, ConsumeOptions, func(context.Context, StreamMessage) error) error
|
||||
}
|
||||
|
||||
type DurableBus interface {
|
||||
DurablePublisher
|
||||
DurableConsumer
|
||||
}
|
||||
|
||||
func ValidateStreamMessage(message StreamMessage) error {
|
||||
if strings.TrimSpace(message.Stream) == "" {
|
||||
return ErrStreamRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateConsumeOptions(options ConsumeOptions) error {
|
||||
if strings.TrimSpace(options.Consumer) == "" {
|
||||
return ErrConsumerRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/metrics"
|
||||
)
|
||||
|
||||
func isContextCanceled(err error) bool { return errors.Is(err, context.Canceled) }
|
||||
func isContextDeadline(err error) bool { return errors.Is(err, context.DeadlineExceeded) }
|
||||
|
||||
const (
|
||||
MetricPublishTotal = "bus_publish_total"
|
||||
MetricPublishBytes = "bus_publish_bytes_total"
|
||||
MetricSubscribeTotal = "bus_subscribe_total"
|
||||
MetricHandlerTotal = "bus_handler_total"
|
||||
MetricSubscriptions = "bus_subscriptions"
|
||||
|
||||
ResultOK = "ok"
|
||||
ResultError = "error"
|
||||
ResultClosed = "closed"
|
||||
ResultPanic = "panic"
|
||||
ResultTimeout = "timeout"
|
||||
ResultCancel = "canceled"
|
||||
)
|
||||
|
||||
// Metrics 是各个 bus adapter 在热路径上调用的轻量指标层。
|
||||
// nil receiver 是 no-op,因此即使指标 wiring 关闭,调用方也可以保留
|
||||
// `m.recordX(...)`,无需到处写 nil 检查。
|
||||
type Metrics struct {
|
||||
reg *metrics.Registry
|
||||
kind string
|
||||
}
|
||||
|
||||
// NewMetrics 返回绑定 registry 和 adapter kind 标签("memory" / "redis" / "nats")的指标发射器。
|
||||
// 任一参数为空时返回 nil,让禁用指标的 wiring 保持真正的 no-op。
|
||||
func NewMetrics(reg *metrics.Registry, kind string) *Metrics {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if reg == nil || kind == "" {
|
||||
return nil
|
||||
}
|
||||
return &Metrics{reg: reg, kind: kind}
|
||||
}
|
||||
|
||||
func (m *Metrics) recordPublish(result string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.reg.Inc(MetricPublishTotal, m.labels(result))
|
||||
}
|
||||
|
||||
func (m *Metrics) recordPublishBytes(size int64) {
|
||||
if m == nil || size <= 0 {
|
||||
return
|
||||
}
|
||||
m.reg.Add(MetricPublishBytes, m.kindOnlyLabels(), size)
|
||||
}
|
||||
|
||||
func (m *Metrics) recordSubscribe(result string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.reg.Inc(MetricSubscribeTotal, m.labels(result))
|
||||
}
|
||||
|
||||
func (m *Metrics) recordHandler(result string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.reg.Inc(MetricHandlerTotal, m.labels(result))
|
||||
}
|
||||
|
||||
func (m *Metrics) trackSubscription(delta int64) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.reg.AddGauge(MetricSubscriptions, m.kindOnlyLabels(), delta)
|
||||
}
|
||||
|
||||
func (m *Metrics) labels(result string) map[string]string {
|
||||
return map[string]string{
|
||||
"kind": m.kind,
|
||||
"result": result,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) kindOnlyLabels() map[string]string {
|
||||
return map[string]string{"kind": m.kind}
|
||||
}
|
||||
|
||||
// classifyHandlerError 返回单次 handler 投递结果对应的 bus_handler_total result 标签。
|
||||
// context.Canceled 归类为 "canceled" 而不是 "error",避免调用方取消拉高错误率。
|
||||
func classifyHandlerError(err error) string {
|
||||
if err == nil {
|
||||
return ResultOK
|
||||
}
|
||||
switch {
|
||||
case isContextCanceled(err):
|
||||
return ResultCancel
|
||||
case isContextDeadline(err):
|
||||
return ResultTimeout
|
||||
default:
|
||||
return ResultError
|
||||
}
|
||||
}
|
||||
|
||||
func payloadSize(payload any) int64 {
|
||||
switch value := payload.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case []byte:
|
||||
return int64(len(value))
|
||||
case json.RawMessage:
|
||||
return int64(len(value))
|
||||
case string:
|
||||
return int64(len(value))
|
||||
default:
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(len(raw))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type NATS struct {
|
||||
logger *slog.Logger
|
||||
prefix string
|
||||
wireEncoding string
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
conn *nats.Conn
|
||||
metrics *Metrics
|
||||
}
|
||||
|
||||
type NATSOptions struct {
|
||||
Name string
|
||||
Timeout time.Duration
|
||||
PingInterval time.Duration
|
||||
MaxPingsOutstanding int
|
||||
MaxReconnects int
|
||||
ReconnectWait time.Duration
|
||||
RetryOnFailedConnect bool
|
||||
WireEncoding string
|
||||
Token string
|
||||
Username string
|
||||
Password string
|
||||
Credentials string
|
||||
TLSEnabled bool
|
||||
TLSServerName string
|
||||
CAFile string
|
||||
CertFile string
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
func DefaultNATSOptions() NATSOptions {
|
||||
return NATSOptions{
|
||||
Name: "longheng-bus",
|
||||
Timeout: 5 * time.Second,
|
||||
PingInterval: time.Second,
|
||||
MaxPingsOutstanding: 10,
|
||||
MaxReconnects: -1,
|
||||
ReconnectWait: time.Second,
|
||||
RetryOnFailedConnect: true,
|
||||
WireEncoding: NATSWireBinary,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNATSOptions(options NATSOptions) NATSOptions {
|
||||
defaults := DefaultNATSOptions()
|
||||
if strings.TrimSpace(options.Name) == "" {
|
||||
options.Name = defaults.Name
|
||||
}
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = defaults.Timeout
|
||||
}
|
||||
if options.PingInterval <= 0 {
|
||||
options.PingInterval = defaults.PingInterval
|
||||
}
|
||||
if options.MaxPingsOutstanding <= 0 {
|
||||
options.MaxPingsOutstanding = defaults.MaxPingsOutstanding
|
||||
}
|
||||
if options.MaxReconnects == 0 {
|
||||
options.MaxReconnects = defaults.MaxReconnects
|
||||
}
|
||||
if options.ReconnectWait <= 0 {
|
||||
options.ReconnectWait = defaults.ReconnectWait
|
||||
}
|
||||
if !options.RetryOnFailedConnect {
|
||||
options.RetryOnFailedConnect = defaults.RetryOnFailedConnect
|
||||
}
|
||||
if !isNATSWireEncoding(options.WireEncoding) {
|
||||
options.WireEncoding = defaults.WireEncoding
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// SetMetrics 在构造后挂接 Metrics 发射器,可与 Publish/Subscribe 并发调用。
|
||||
func (b *NATS) SetMetrics(m *Metrics) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.metrics = m
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewNATS(endpoints []string, namespace string, logger *slog.Logger) (*NATS, error) {
|
||||
return NewNATSWithOptions(endpoints, namespace, logger, DefaultNATSOptions())
|
||||
}
|
||||
|
||||
func NewNATSWithOptions(endpoints []string, namespace string, logger *slog.Logger, options NATSOptions) (*NATS, error) {
|
||||
endpoints = normNATSEndpoints(endpoints)
|
||||
if len(endpoints) == 0 {
|
||||
return nil, fmt.Errorf("nats bus requires at least one endpoint")
|
||||
}
|
||||
options = normalizeNATSOptions(options)
|
||||
|
||||
connectOptions := []nats.Option{
|
||||
nats.Name(options.Name),
|
||||
nats.Timeout(options.Timeout),
|
||||
nats.PingInterval(options.PingInterval),
|
||||
nats.MaxPingsOutstanding(options.MaxPingsOutstanding),
|
||||
nats.RetryOnFailedConnect(options.RetryOnFailedConnect),
|
||||
nats.MaxReconnects(options.MaxReconnects),
|
||||
nats.ReconnectWait(options.ReconnectWait),
|
||||
}
|
||||
connectOptions = appendNATSSecOpts(connectOptions, options)
|
||||
conn, err := nats.Connect(
|
||||
strings.Join(endpoints, ","),
|
||||
connectOptions...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NATS{
|
||||
logger: logger,
|
||||
prefix: strings.Trim(namespace, "."),
|
||||
wireEncoding: options.WireEncoding,
|
||||
conn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normNATSEndpoints(endpoints []string) []string {
|
||||
normalized := make([]string, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint != "" {
|
||||
normalized = append(normalized, endpoint)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func appendNATSSecOpts(connectOptions []nats.Option, options NATSOptions) []nats.Option {
|
||||
if options.Token != "" {
|
||||
connectOptions = append(connectOptions, nats.Token(options.Token))
|
||||
}
|
||||
if options.Username != "" || options.Password != "" {
|
||||
connectOptions = append(connectOptions, nats.UserInfo(options.Username, options.Password))
|
||||
}
|
||||
if options.Credentials != "" {
|
||||
connectOptions = append(connectOptions, nats.UserCredentials(options.Credentials))
|
||||
}
|
||||
if options.TLSEnabled {
|
||||
connectOptions = append(connectOptions, nats.Secure(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: options.TLSServerName,
|
||||
}))
|
||||
}
|
||||
if options.CAFile != "" {
|
||||
connectOptions = append(connectOptions, nats.RootCAs(options.CAFile))
|
||||
}
|
||||
if options.CertFile != "" || options.KeyFile != "" {
|
||||
connectOptions = append(connectOptions, nats.ClientCert(options.CertFile, options.KeyFile))
|
||||
}
|
||||
return connectOptions
|
||||
}
|
||||
|
||||
func (b *NATS) Publish(ctx context.Context, topic string, payload any) error {
|
||||
return b.publishEnvelope(ctx, Envelope{Topic: topic, Payload: payload, PublishedAt: time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (b *NATS) publishEnvelope(ctx context.Context, env Envelope) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if b != nil {
|
||||
b.mu.Lock()
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(classifyHandlerError(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
// NATS core publish 只提供在线投递,不保存消息、不等待消费者确认;关键状态变更必须走 EventLog/Outbox。
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
conn := b.conn
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
if conn == nil {
|
||||
metricsRef.recordPublish(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
body, err := contracts.MarshalTopicPayload(env.Payload)
|
||||
if err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
if env.PublishedAt.IsZero() {
|
||||
env.PublishedAt = time.Now().UTC()
|
||||
}
|
||||
env = envelopeWithTrace(ctx, env)
|
||||
|
||||
wire, err := encodeNATSWire(natsWireMessage{
|
||||
Topic: env.Topic,
|
||||
Payload: body,
|
||||
PublishedAt: env.PublishedAt,
|
||||
Reply: env.Reply,
|
||||
RequestID: env.RequestID,
|
||||
Trace: env.Trace,
|
||||
}, b.wireEncoding)
|
||||
if err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.Publish(b.subject(env.Topic), wire); err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
metricsRef.recordPublish(ResultOK)
|
||||
metricsRef.recordPublishBytes(int64(len(body)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NATS) Request(ctx context.Context, topic string, payload any, timeout time.Duration) ([]byte, error) {
|
||||
return requestWithPubSub(ctx, topic, payload, timeout, b.Subscribe, b.publishEnvelope)
|
||||
}
|
||||
|
||||
func (b *NATS) Subscribe(topic string, handler Handler) (Subscription, error) {
|
||||
return b.subscribe(topic, "", handler)
|
||||
}
|
||||
|
||||
func (b *NATS) QueueSubscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
||||
queue = normalizeQueueName(queue)
|
||||
if queue == "" {
|
||||
return b.Subscribe(topic, handler)
|
||||
}
|
||||
return b.subscribe(topic, queue, handler)
|
||||
}
|
||||
|
||||
func (b *NATS) subscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
||||
if handler == nil {
|
||||
return nil, ErrHandlerRequired
|
||||
}
|
||||
if b == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
conn := b.conn
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
if conn == nil {
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
// 回调里只做解包和 handler deadline 保护;超时只能记录失败,不能中断已经进入业务 handler 的 goroutine。
|
||||
callback := func(msg *nats.Msg) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
if b.logger != nil {
|
||||
b.logger.Error("nats handler panic recovered", "topic", topic, "panic", rec)
|
||||
}
|
||||
metricsRef.recordHandler(ResultPanic)
|
||||
}
|
||||
}()
|
||||
|
||||
wire, err := decodeNATSWire(msg.Data)
|
||||
if err != nil {
|
||||
if b.logger != nil {
|
||||
b.logger.Error("nats unmarshal failed", "topic", topic, "error", err)
|
||||
}
|
||||
metricsRef.recordHandler(ResultError)
|
||||
return
|
||||
}
|
||||
|
||||
var payload any = json.RawMessage(wire.Payload)
|
||||
if !isRequestReplyTopic(topic) {
|
||||
decoded, err := contracts.UnmarshalTopicPayload(topic, wire.Payload)
|
||||
if err != nil {
|
||||
if b.logger != nil {
|
||||
b.logger.Error("nats payload decode failed", "topic", topic, "error", err)
|
||||
}
|
||||
metricsRef.recordHandler(ResultError)
|
||||
return
|
||||
}
|
||||
payload = decoded
|
||||
}
|
||||
|
||||
publishedAt := wire.PublishedAt
|
||||
if publishedAt.IsZero() {
|
||||
publishedAt = time.Now().UTC()
|
||||
}
|
||||
reply := wire.Reply
|
||||
if reply == "" {
|
||||
reply = msg.Reply
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: publishedAt,
|
||||
Reply: reply,
|
||||
RequestID: wire.RequestID,
|
||||
Trace: wire.Trace,
|
||||
}
|
||||
deliverWithDeadline(contextWithTrace(context.Background(), env), b.logger, topic, defHandlerTimeout, env, handler, metricsRef)
|
||||
}
|
||||
var sub *nats.Subscription
|
||||
var err error
|
||||
if queue == "" {
|
||||
sub, err = conn.Subscribe(b.subject(topic), callback)
|
||||
} else {
|
||||
sub, err = conn.QueueSubscribe(b.subject(topic), queue, callback)
|
||||
}
|
||||
if err != nil {
|
||||
metricsRef.recordSubscribe(ResultError)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricsRef.recordSubscribe(ResultOK)
|
||||
metricsRef.trackSubscription(1)
|
||||
return &natsSubscription{sub: sub, metrics: metricsRef}, nil
|
||||
}
|
||||
|
||||
func (b *NATS) Check(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return ErrClosed
|
||||
}
|
||||
conn := b.conn
|
||||
b.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
return conn.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (b *NATS) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
b.closed = true
|
||||
conn := b.conn
|
||||
b.conn = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
// Drain 给正在发送的消息一个收敛窗口,但 NATS core 仍不是持久队列;关闭期间丢失的 live 消息不做补偿。
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
if err := conn.Drain(); err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NATS) subject(topic string) string {
|
||||
if b.prefix == "" {
|
||||
return topic
|
||||
}
|
||||
return b.prefix + "." + topic
|
||||
}
|
||||
|
||||
type natsSubscription struct {
|
||||
sub *nats.Subscription
|
||||
metrics *Metrics
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *natsSubscription) Close() error {
|
||||
if s == nil || s.sub == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
err := s.sub.Unsubscribe()
|
||||
s.metrics.trackSubscription(-1)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NATSWireBinary = "binary_v1"
|
||||
NATSWireJSON = "json"
|
||||
)
|
||||
|
||||
var (
|
||||
natsWireMagic = []byte{'L', 'H', 'B', '1'}
|
||||
natsWirePool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
|
||||
)
|
||||
|
||||
type natsWireMessage struct {
|
||||
Topic string
|
||||
Payload []byte
|
||||
PublishedAt time.Time
|
||||
Reply string
|
||||
RequestID string
|
||||
Trace map[string]string
|
||||
}
|
||||
|
||||
type natsJSONWireMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Trace map[string]string `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
func isNATSWireEncoding(encoding string) bool {
|
||||
switch encoding {
|
||||
case NATSWireBinary, NATSWireJSON:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func encodeNATSWire(msg natsWireMessage, encoding string) ([]byte, error) {
|
||||
if encoding == NATSWireJSON {
|
||||
return encodeNATSJSON(msg)
|
||||
}
|
||||
return encodeNATSBinary(msg)
|
||||
}
|
||||
|
||||
func encodeNATSJSON(msg natsWireMessage) ([]byte, error) {
|
||||
return json.Marshal(natsJSONWireMessage{
|
||||
Topic: msg.Topic,
|
||||
Payload: json.RawMessage(msg.Payload),
|
||||
PublishedAt: msg.PublishedAt,
|
||||
Reply: msg.Reply,
|
||||
RequestID: msg.RequestID,
|
||||
Trace: msg.Trace,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeNATSWire(data []byte) (natsWireMessage, error) {
|
||||
if bytes.HasPrefix(data, natsWireMagic) {
|
||||
return decodeNATSBinary(data)
|
||||
}
|
||||
var wire natsJSONWireMessage
|
||||
if err := json.Unmarshal(data, &wire); err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
return natsWireMessage{
|
||||
Topic: wire.Topic,
|
||||
Payload: append([]byte(nil), wire.Payload...),
|
||||
PublishedAt: wire.PublishedAt,
|
||||
Reply: wire.Reply,
|
||||
RequestID: wire.RequestID,
|
||||
Trace: wire.Trace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeNATSBinary(msg natsWireMessage) ([]byte, error) {
|
||||
buf := natsWirePool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
natsWirePool.Put(buf)
|
||||
}()
|
||||
|
||||
buf.Write(natsWireMagic)
|
||||
writeVarint(buf, msg.PublishedAt.UnixNano())
|
||||
writeString(buf, msg.Topic)
|
||||
writeString(buf, msg.Reply)
|
||||
writeString(buf, msg.RequestID)
|
||||
writeTrace(buf, msg.Trace)
|
||||
writeBytes(buf, msg.Payload)
|
||||
return append([]byte(nil), buf.Bytes()...), nil
|
||||
}
|
||||
|
||||
func decodeNATSBinary(data []byte) (natsWireMessage, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
magic := make([]byte, len(natsWireMagic))
|
||||
if _, err := io.ReadFull(reader, magic); err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
if !bytes.Equal(magic, natsWireMagic) {
|
||||
return natsWireMessage{}, fmt.Errorf("invalid nats wire magic")
|
||||
}
|
||||
unixNano, err := readVarint(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
topic, err := readString(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
reply, err := readString(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
requestID, err := readString(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
trace, err := readTrace(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
payload, err := readBytes(reader)
|
||||
if err != nil {
|
||||
return natsWireMessage{}, err
|
||||
}
|
||||
if reader.Len() != 0 {
|
||||
return natsWireMessage{}, fmt.Errorf("nats wire has %d trailing bytes", reader.Len())
|
||||
}
|
||||
var publishedAt time.Time
|
||||
if unixNano != 0 {
|
||||
publishedAt = time.Unix(0, unixNano).UTC()
|
||||
}
|
||||
return natsWireMessage{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: publishedAt,
|
||||
Reply: reply,
|
||||
RequestID: requestID,
|
||||
Trace: trace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeVarint(buf *bytes.Buffer, value int64) {
|
||||
var scratch [binary.MaxVarintLen64]byte
|
||||
n := binary.PutVarint(scratch[:], value)
|
||||
buf.Write(scratch[:n])
|
||||
}
|
||||
|
||||
func readVarint(reader *bytes.Reader) (int64, error) {
|
||||
value, err := binary.ReadVarint(reader)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read varint: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writeUvarint(buf *bytes.Buffer, value uint64) {
|
||||
var scratch [binary.MaxVarintLen64]byte
|
||||
n := binary.PutUvarint(scratch[:], value)
|
||||
buf.Write(scratch[:n])
|
||||
}
|
||||
|
||||
func readUvarint(reader *bytes.Reader) (uint64, error) {
|
||||
value, err := binary.ReadUvarint(reader)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read uvarint: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writeString(buf *bytes.Buffer, value string) {
|
||||
writeBytes(buf, []byte(value))
|
||||
}
|
||||
|
||||
func readString(reader *bytes.Reader) (string, error) {
|
||||
value, err := readBytes(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
|
||||
func writeBytes(buf *bytes.Buffer, value []byte) {
|
||||
writeUvarint(buf, uint64(len(value)))
|
||||
buf.Write(value)
|
||||
}
|
||||
|
||||
func readBytes(reader *bytes.Reader) ([]byte, error) {
|
||||
length, err := readUvarint(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length > uint64(reader.Len()) { //nolint:gosec // G115:bytes.Reader.Len 永远非负,这里只用于剩余长度上界比较。
|
||||
return nil, fmt.Errorf("nats wire length %d exceeds remaining %d", length, reader.Len())
|
||||
}
|
||||
value := make([]byte, int(length)) //nolint:gosec // G115:前面已确认 length 不超过 reader.Len。
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writeTrace(buf *bytes.Buffer, trace map[string]string) {
|
||||
if len(trace) == 0 {
|
||||
writeUvarint(buf, 0)
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(trace))
|
||||
for key := range trace {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
writeUvarint(buf, uint64(len(keys)))
|
||||
for _, key := range keys {
|
||||
writeString(buf, key)
|
||||
writeString(buf, trace[key])
|
||||
}
|
||||
}
|
||||
|
||||
func readTrace(reader *bytes.Reader) (map[string]string, error) {
|
||||
count, err := readUvarint(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
maxEntries := uint64(reader.Len() / 2) //nolint:gosec // G115:bytes.Reader.Len 永远非负,这里估算剩余 trace 最多条目。
|
||||
if count > maxEntries {
|
||||
return nil, fmt.Errorf("nats wire trace count %d exceeds remaining %d", count, reader.Len())
|
||||
}
|
||||
trace := make(map[string]string, int(count)) //nolint:gosec // G115:前面已按剩余字节数约束 count。
|
||||
for i := uint64(0); i < count; i++ {
|
||||
key, err := readString(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := readString(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trace[key] = value
|
||||
}
|
||||
return trace, nil
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type Redis struct {
|
||||
logger *slog.Logger
|
||||
endpoint string
|
||||
password string
|
||||
database int
|
||||
options RedisOptions
|
||||
prefix string
|
||||
pool *redis.Pool
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
metrics *Metrics
|
||||
|
||||
replyMu sync.Mutex
|
||||
replySub *redisSubscription
|
||||
replyWaiters map[string]chan requestResponse
|
||||
}
|
||||
|
||||
type RedisOptions struct {
|
||||
Username string
|
||||
Password string
|
||||
Database int
|
||||
TLSEnabled bool
|
||||
TLSServerName string
|
||||
}
|
||||
|
||||
// SetMetrics 在构造后挂接 Metrics 发射器。
|
||||
func (b *Redis) SetMetrics(m *Metrics) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.metrics = m
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewRedis(endpoints []string, namespace string, password string, database int, logger *slog.Logger) (*Redis, error) {
|
||||
return NewRedisWithOptions(endpoints, namespace, logger, RedisOptions{Password: password, Database: database})
|
||||
}
|
||||
|
||||
func NewRedisWithOptions(endpoints []string, namespace string, logger *slog.Logger, options RedisOptions) (*Redis, error) {
|
||||
endpoint := firstEndpoint(endpoints)
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("redis bus requires at least one endpoint")
|
||||
}
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: 8,
|
||||
MaxActive: 32,
|
||||
IdleTimeout: time.Minute,
|
||||
Wait: true,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
dialOptions := []redis.DialOption{
|
||||
redis.DialConnectTimeout(5 * time.Second),
|
||||
redis.DialReadTimeout(5 * time.Second),
|
||||
redis.DialWriteTimeout(5 * time.Second),
|
||||
}
|
||||
dialOptions = appendRedisDialOpts(dialOptions, options)
|
||||
if options.Database > 0 {
|
||||
dialOptions = append(dialOptions, redis.DialDatabase(options.Database))
|
||||
}
|
||||
return redis.Dial(
|
||||
"tcp",
|
||||
endpoint,
|
||||
dialOptions...,
|
||||
)
|
||||
},
|
||||
TestOnBorrow: func(conn redis.Conn, lastUsed time.Time) error {
|
||||
if time.Since(lastUsed) < time.Minute {
|
||||
return nil
|
||||
}
|
||||
_, err := conn.Do("PING")
|
||||
return err
|
||||
},
|
||||
}
|
||||
return &Redis{
|
||||
logger: logger,
|
||||
endpoint: endpoint,
|
||||
password: options.Password,
|
||||
database: options.Database,
|
||||
options: options,
|
||||
prefix: strings.Trim(namespace, "."),
|
||||
pool: pool,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendRedisDialOpts(dialOptions []redis.DialOption, options RedisOptions) []redis.DialOption {
|
||||
if options.Username != "" {
|
||||
dialOptions = append(dialOptions, redis.DialUsername(options.Username))
|
||||
}
|
||||
if options.Password != "" {
|
||||
dialOptions = append(dialOptions, redis.DialPassword(options.Password))
|
||||
}
|
||||
if options.TLSEnabled {
|
||||
dialOptions = append(dialOptions, redis.DialUseTLS(true))
|
||||
if options.TLSServerName != "" {
|
||||
dialOptions = append(dialOptions, redis.DialTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: options.TLSServerName,
|
||||
InsecureSkipVerify: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return dialOptions
|
||||
}
|
||||
|
||||
func (b *Redis) Publish(ctx context.Context, topic string, payload any) error {
|
||||
return b.publishEnvelope(ctx, Envelope{Topic: topic, Payload: payload, PublishedAt: time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (b *Redis) publishEnvelope(ctx context.Context, env Envelope) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if b != nil {
|
||||
b.mu.Lock()
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(classifyHandlerError(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordPublish(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
pool := b.pool
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
if pool == nil {
|
||||
metricsRef.recordPublish(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
body, err := contracts.MarshalTopicPayload(env.Payload)
|
||||
if err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
if env.PublishedAt.IsZero() {
|
||||
env.PublishedAt = time.Now().UTC()
|
||||
}
|
||||
env = envelopeWithTrace(ctx, env)
|
||||
wire, err := json.Marshal(natsWireMessage{
|
||||
Topic: env.Topic,
|
||||
Payload: body,
|
||||
PublishedAt: env.PublishedAt,
|
||||
Reply: env.Reply,
|
||||
RequestID: env.RequestID,
|
||||
Trace: env.Trace,
|
||||
})
|
||||
if err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := pool.GetContext(ctx)
|
||||
if err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if _, err := conn.Do("PUBLISH", b.subject(env.Topic), wire); err != nil {
|
||||
metricsRef.recordPublish(ResultError)
|
||||
return err
|
||||
}
|
||||
metricsRef.recordPublish(ResultOK)
|
||||
metricsRef.recordPublishBytes(int64(len(body)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Redis) Request(ctx context.Context, topic string, payload any, timeout time.Duration) ([]byte, error) {
|
||||
if b == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
ctx, cancel := requestContext(ctx, timeout)
|
||||
defer cancel()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestID := newRequestID(topic)
|
||||
replyTopic := requestReplyTopic(topic, requestID)
|
||||
responses := make(chan requestResponse, 1)
|
||||
if err := b.registerReplyWaiter(replyTopic, responses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer b.unregReplyWaiter(replyTopic)
|
||||
|
||||
if err := b.ensureReplySub(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.publishEnvelope(ctx, Envelope{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Reply: replyTopic,
|
||||
RequestID: requestID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-responses:
|
||||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return result.data, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Redis) Subscribe(topic string, handler Handler) (Subscription, error) {
|
||||
if handler == nil {
|
||||
return nil, ErrHandlerRequired
|
||||
}
|
||||
if b == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
endpoint := b.endpoint
|
||||
password := b.password
|
||||
database := b.database
|
||||
options := b.options
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
if endpoint == "" {
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
// Redis Pub/Sub 没有历史消息,订阅建立前和重连期间的消息都会丢;这里只适合在线协调和推送。
|
||||
subject := b.subject(topic)
|
||||
sub := &redisSubscription{
|
||||
logger: b.logger,
|
||||
endpoint: endpoint,
|
||||
password: password,
|
||||
database: database,
|
||||
options: options,
|
||||
topic: topic,
|
||||
subject: subject,
|
||||
done: make(chan struct{}),
|
||||
metrics: metricsRef,
|
||||
}
|
||||
if err := sub.connect(); err != nil {
|
||||
metricsRef.recordSubscribe(ResultError)
|
||||
return nil, err
|
||||
}
|
||||
metricsRef.recordSubscribe(ResultOK)
|
||||
metricsRef.trackSubscription(1)
|
||||
go sub.receive(handler)
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (b *Redis) QueueSubscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
||||
queue = normalizeQueueName(queue)
|
||||
if queue == "" {
|
||||
return b.Subscribe(topic, handler)
|
||||
}
|
||||
if handler == nil {
|
||||
return nil, ErrHandlerRequired
|
||||
}
|
||||
return nil, fmt.Errorf("%w: redis pubsub does not support queue groups", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (b *Redis) Check(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return ErrClosed
|
||||
}
|
||||
pool := b.pool
|
||||
b.mu.Unlock()
|
||||
if pool == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
conn, err := pool.GetContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
_, err = redis.String(conn.Do("PING"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Redis) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
b.closed = true
|
||||
pool := b.pool
|
||||
b.pool = nil
|
||||
b.mu.Unlock()
|
||||
b.replyMu.Lock()
|
||||
replySub := b.replySub
|
||||
b.replySub = nil
|
||||
for topic, waiter := range b.replyWaiters {
|
||||
delete(b.replyWaiters, topic)
|
||||
select {
|
||||
case waiter <- requestResponse{err: ErrClosed}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.replyMu.Unlock()
|
||||
|
||||
var err error
|
||||
if replySub != nil {
|
||||
err = errors.Join(err, replySub.Close())
|
||||
}
|
||||
if pool == nil {
|
||||
return err
|
||||
}
|
||||
return errors.Join(err, pool.Close())
|
||||
}
|
||||
|
||||
func (b *Redis) subject(topic string) string {
|
||||
if b.prefix == "" {
|
||||
return topic
|
||||
}
|
||||
return b.prefix + "." + topic
|
||||
}
|
||||
|
||||
type redisSubscription struct {
|
||||
logger *slog.Logger
|
||||
endpoint string
|
||||
password string
|
||||
database int
|
||||
options RedisOptions
|
||||
topic string
|
||||
subject string
|
||||
pattern bool
|
||||
done chan struct{}
|
||||
metrics *Metrics
|
||||
|
||||
mu sync.Mutex
|
||||
psc *redis.PubSubConn
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *redisSubscription) receive(handler Handler) {
|
||||
// receive 常驻单 goroutine,连接断开后进入重连退避;关闭信号优先于重连,避免 Stop 卡住。
|
||||
for {
|
||||
if s.isDone() {
|
||||
return
|
||||
}
|
||||
|
||||
psc := s.current()
|
||||
if psc == nil {
|
||||
if !s.reconnect() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
reply := psc.Receive()
|
||||
switch msg := reply.(type) {
|
||||
case redis.Message:
|
||||
s.handleMessage(handler, msg.Channel, msg.Data)
|
||||
case redis.Subscription:
|
||||
if msg.Count == 0 {
|
||||
if s.isDone() {
|
||||
return
|
||||
}
|
||||
s.clearCurrent(psc)
|
||||
if !s.reconnect() {
|
||||
return
|
||||
}
|
||||
}
|
||||
case error:
|
||||
if s.isDone() {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("redis bus subscription lost", "topic", s.topic, "error", msg)
|
||||
}
|
||||
s.clearCurrent(psc)
|
||||
if !s.reconnect() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisSubscription) handleMessage(handler Handler, channel string, data []byte) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("redis bus handler panic recovered", "topic", s.topic, "panic", rec)
|
||||
}
|
||||
s.metrics.recordHandler(ResultPanic)
|
||||
}
|
||||
}()
|
||||
|
||||
var wire natsWireMessage
|
||||
if err := json.Unmarshal(data, &wire); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("redis bus unmarshal failed", "topic", s.topic, "error", err)
|
||||
}
|
||||
s.metrics.recordHandler(ResultError)
|
||||
return
|
||||
}
|
||||
var payload any = json.RawMessage(wire.Payload)
|
||||
topic := strings.TrimSpace(wire.Topic)
|
||||
if topic == "" {
|
||||
topic = s.topic
|
||||
if s.pattern {
|
||||
topic = s.topicFromPattern(channel)
|
||||
}
|
||||
}
|
||||
if !isRequestReplyTopic(topic) {
|
||||
decoded, err := contracts.UnmarshalTopicPayload(topic, wire.Payload)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("redis bus payload decode failed", "topic", topic, "error", err)
|
||||
}
|
||||
s.metrics.recordHandler(ResultError)
|
||||
return
|
||||
}
|
||||
payload = decoded
|
||||
}
|
||||
publishedAt := wire.PublishedAt
|
||||
if publishedAt.IsZero() {
|
||||
publishedAt = time.Now().UTC()
|
||||
}
|
||||
env := Envelope{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: publishedAt,
|
||||
Reply: wire.Reply,
|
||||
RequestID: wire.RequestID,
|
||||
Trace: wire.Trace,
|
||||
}
|
||||
deliverWithDeadline(contextWithTrace(context.Background(), env), s.logger, s.topic, defHandlerTimeout, env, handler, s.metrics)
|
||||
}
|
||||
|
||||
func (s *redisSubscription) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.once.Do(func() {
|
||||
// Close 先关闭 done 再 Unsubscribe,唤醒 receive/reconnect 两条路径;未处理完的 Pub/Sub 消息不会重放。
|
||||
if s.done != nil {
|
||||
close(s.done)
|
||||
}
|
||||
s.mu.Lock()
|
||||
psc := s.psc
|
||||
s.psc = nil
|
||||
s.mu.Unlock()
|
||||
if psc != nil {
|
||||
if s.pattern {
|
||||
_ = psc.PUnsubscribe(s.subject)
|
||||
} else {
|
||||
_ = psc.Unsubscribe(s.subject)
|
||||
}
|
||||
s.err = psc.Close()
|
||||
}
|
||||
s.metrics.trackSubscription(-1)
|
||||
})
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *redisSubscription) connect() error {
|
||||
psc, err := s.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.setCurrent(psc) {
|
||||
_ = psc.Close()
|
||||
return ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redisSubscription) reconnect() bool {
|
||||
backoff := 250 * time.Millisecond
|
||||
// 重连使用指数退避,上限 5 秒;这段时间的 Redis Pub/Sub 消息天然不可恢复。
|
||||
for {
|
||||
if s.isDone() {
|
||||
return false
|
||||
}
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-s.done:
|
||||
timer.Stop()
|
||||
return false
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
psc, err := s.open()
|
||||
if err == nil {
|
||||
if !s.setCurrent(psc) {
|
||||
_ = psc.Close()
|
||||
return false
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Info("redis bus subscription reconnected", "topic", s.topic)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("redis bus subscription reconnect failed", "topic", s.topic, "error", err)
|
||||
}
|
||||
if backoff < 5*time.Second {
|
||||
backoff *= 2
|
||||
if backoff > 5*time.Second {
|
||||
backoff = 5 * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisSubscription) open() (*redis.PubSubConn, error) {
|
||||
dialOptions := []redis.DialOption{
|
||||
redis.DialConnectTimeout(5 * time.Second),
|
||||
redis.DialWriteTimeout(5 * time.Second),
|
||||
}
|
||||
options := s.options
|
||||
if options.Password == "" {
|
||||
options.Password = s.password
|
||||
}
|
||||
if options.Database == 0 {
|
||||
options.Database = s.database
|
||||
}
|
||||
dialOptions = appendRedisDialOpts(dialOptions, options)
|
||||
if options.Database > 0 {
|
||||
dialOptions = append(dialOptions, redis.DialDatabase(options.Database))
|
||||
}
|
||||
conn, err := redis.Dial(
|
||||
"tcp",
|
||||
s.endpoint,
|
||||
dialOptions...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
psc := &redis.PubSubConn{Conn: conn}
|
||||
if s.pattern {
|
||||
err = psc.PSubscribe(s.subject)
|
||||
} else {
|
||||
err = psc.Subscribe(s.subject)
|
||||
}
|
||||
if err != nil {
|
||||
_ = psc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := waitRedisSubReady(psc, s.pattern); err != nil {
|
||||
_ = psc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return psc, nil
|
||||
}
|
||||
|
||||
func (s *redisSubscription) topicFromPattern(channel string) string {
|
||||
channel = strings.TrimSpace(channel)
|
||||
if channel == "" {
|
||||
return s.topic
|
||||
}
|
||||
subjectPrefix, subjectSuffix, subjectOK := splitRedisPattern(s.subject)
|
||||
topicPrefix, topicSuffix, topicOK := splitRedisPattern(s.topic)
|
||||
if !subjectOK || !topicOK {
|
||||
return channel
|
||||
}
|
||||
if !strings.HasPrefix(channel, subjectPrefix) || !strings.HasSuffix(channel, subjectSuffix) {
|
||||
return channel
|
||||
}
|
||||
middleEnd := len(channel) - len(subjectSuffix)
|
||||
if middleEnd < len(subjectPrefix) {
|
||||
return channel
|
||||
}
|
||||
middle := channel[len(subjectPrefix):middleEnd]
|
||||
return topicPrefix + middle + topicSuffix
|
||||
}
|
||||
|
||||
func splitRedisPattern(pattern string) (prefix string, suffix string, ok bool) {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
idx := strings.Index(pattern, "*")
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return pattern[:idx], pattern[idx+1:], true
|
||||
}
|
||||
|
||||
func waitRedisSubReady(psc *redis.PubSubConn, pattern bool) error {
|
||||
if psc == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
const ackTimeout = 2 * time.Second
|
||||
deadline := time.Now().Add(ackTimeout)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return fmt.Errorf("redis subscription ack timeout")
|
||||
}
|
||||
reply := psc.ReceiveWithTimeout(remaining)
|
||||
switch msg := reply.(type) {
|
||||
case redis.Subscription:
|
||||
if msg.Count > 0 {
|
||||
if pattern && strings.EqualFold(msg.Kind, "psubscribe") {
|
||||
return nil
|
||||
}
|
||||
if !pattern && strings.EqualFold(msg.Kind, "subscribe") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case error:
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Redis) registerReplyWaiter(replyTopic string, responses chan requestResponse) error {
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
replyTopic = strings.TrimSpace(replyTopic)
|
||||
if replyTopic == "" {
|
||||
return fmt.Errorf("%w: request reply topic missing", ErrUnsupported)
|
||||
}
|
||||
b.replyMu.Lock()
|
||||
defer b.replyMu.Unlock()
|
||||
if b.replyWaiters == nil {
|
||||
b.replyWaiters = make(map[string]chan requestResponse)
|
||||
}
|
||||
if _, exists := b.replyWaiters[replyTopic]; exists {
|
||||
return fmt.Errorf("duplicate redis reply waiter %q", replyTopic)
|
||||
}
|
||||
b.replyWaiters[replyTopic] = responses
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Redis) unregReplyWaiter(replyTopic string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.replyMu.Lock()
|
||||
delete(b.replyWaiters, strings.TrimSpace(replyTopic))
|
||||
b.replyMu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Redis) ensureReplySub(ctx context.Context) error {
|
||||
if b == nil {
|
||||
return ErrClosed
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
b.replyMu.Lock()
|
||||
if b.replySub != nil && !b.replySub.isDone() {
|
||||
b.replyMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
b.replyMu.Unlock()
|
||||
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
metricsRef := b.metrics
|
||||
b.mu.Unlock()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
endpoint := b.endpoint
|
||||
password := b.password
|
||||
database := b.database
|
||||
options := b.options
|
||||
logger := b.logger
|
||||
metricsRef := b.metrics
|
||||
subject := b.subject(requestReplyPrefix + "*")
|
||||
b.mu.Unlock()
|
||||
if endpoint == "" {
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
sub := &redisSubscription{
|
||||
logger: logger,
|
||||
endpoint: endpoint,
|
||||
password: password,
|
||||
database: database,
|
||||
options: options,
|
||||
topic: requestReplyPrefix + "*",
|
||||
subject: subject,
|
||||
pattern: true,
|
||||
done: make(chan struct{}),
|
||||
metrics: metricsRef,
|
||||
}
|
||||
if err := sub.connect(); err != nil {
|
||||
metricsRef.recordSubscribe(ResultError)
|
||||
return err
|
||||
}
|
||||
metricsRef.recordSubscribe(ResultOK)
|
||||
metricsRef.trackSubscription(1)
|
||||
|
||||
b.mu.Lock()
|
||||
b.replyMu.Lock()
|
||||
if b.closed {
|
||||
b.replyMu.Unlock()
|
||||
b.mu.Unlock()
|
||||
_ = sub.Close()
|
||||
metricsRef.recordSubscribe(ResultClosed)
|
||||
return ErrClosed
|
||||
}
|
||||
if b.replySub != nil && !b.replySub.isDone() {
|
||||
b.replyMu.Unlock()
|
||||
b.mu.Unlock()
|
||||
_ = sub.Close()
|
||||
return nil
|
||||
}
|
||||
b.replySub = sub
|
||||
b.replyMu.Unlock()
|
||||
b.mu.Unlock()
|
||||
|
||||
go sub.receive(func(_ context.Context, env Envelope) error {
|
||||
b.dispatchReply(env)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Redis) dispatchReply(env Envelope) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
topic := strings.TrimSpace(env.Topic)
|
||||
if topic == "" {
|
||||
return
|
||||
}
|
||||
raw, err := marshalBusPayload(env.Payload)
|
||||
result := requestResponse{data: raw, err: err}
|
||||
b.replyMu.Lock()
|
||||
waiter := b.replyWaiters[topic]
|
||||
b.replyMu.Unlock()
|
||||
if waiter == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case waiter <- result:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisSubscription) current() *redis.PubSubConn {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.psc
|
||||
}
|
||||
|
||||
func (s *redisSubscription) setCurrent(psc *redis.PubSubConn) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.isDone() {
|
||||
return false
|
||||
}
|
||||
s.psc = psc
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *redisSubscription) clearCurrent(psc *redis.PubSubConn) {
|
||||
s.mu.Lock()
|
||||
if s.psc == psc {
|
||||
s.psc = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = psc.Close()
|
||||
}
|
||||
|
||||
func (s *redisSubscription) isDone() bool {
|
||||
select {
|
||||
case <-s.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstEndpoint(endpoints []string) string {
|
||||
for _, endpoint := range endpoints {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
const requestReplyPrefix = "_reply."
|
||||
|
||||
var requestSeq uint64
|
||||
|
||||
type envelopePublisher func(context.Context, Envelope) error
|
||||
type envelopeSubscriber func(string, Handler) (Subscription, error)
|
||||
|
||||
type requestResponse struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func requestWithPubSub(ctx context.Context, topic string, payload any, timeout time.Duration, subscribe envelopeSubscriber, publish envelopePublisher) ([]byte, error) {
|
||||
if subscribe == nil || publish == nil {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
ctx, cancel := requestContext(ctx, timeout)
|
||||
defer cancel()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestID := newRequestID(topic)
|
||||
replyTopic := requestReplyTopic(topic, requestID)
|
||||
responses := make(chan requestResponse, 1)
|
||||
// request-reply 必须先订阅临时 reply topic 再发布请求,否则快速响应可能在订阅建立前丢失。
|
||||
// responses 只保留第一条响应;超时后的迟到响应会被丢弃,调用方不能把它当可靠 RPC。
|
||||
sub, err := subscribe(replyTopic, func(_ context.Context, env Envelope) error {
|
||||
raw, err := marshalBusPayload(env.Payload)
|
||||
result := requestResponse{data: raw, err: err}
|
||||
select {
|
||||
case responses <- result:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sub == nil {
|
||||
return nil, fmt.Errorf("%w: request reply subscription missing", ErrUnsupported)
|
||||
}
|
||||
defer func() { _ = sub.Close() }()
|
||||
|
||||
if err := publish(ctx, Envelope{
|
||||
Topic: topic,
|
||||
Payload: payload,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Reply: replyTopic,
|
||||
RequestID: requestID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-responses:
|
||||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return result.data, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func requestContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func newRequestID(topic string) string {
|
||||
seq := atomic.AddUint64(&requestSeq, 1)
|
||||
return fmt.Sprintf("%d-%d", time.Now().UTC().UnixNano(), seq)
|
||||
}
|
||||
|
||||
func requestReplyTopic(topic, requestID string) string {
|
||||
topic = strings.TrimSpace(topic)
|
||||
if topic == "" {
|
||||
topic = "unknown"
|
||||
}
|
||||
topic = strings.NewReplacer(" ", "_", "\t", "_", "\n", "_", "\r", "_").Replace(topic)
|
||||
return requestReplyPrefix + topic + "." + requestID
|
||||
}
|
||||
|
||||
func isRequestReplyTopic(topic string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(topic), requestReplyPrefix)
|
||||
}
|
||||
|
||||
func normalizeQueueName(queue string) string {
|
||||
return strings.TrimSpace(queue)
|
||||
}
|
||||
|
||||
func marshalBusPayload(payload any) ([]byte, error) {
|
||||
switch value := payload.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []byte:
|
||||
return append([]byte(nil), value...), nil
|
||||
case json.RawMessage:
|
||||
return append([]byte(nil), value...), nil
|
||||
default:
|
||||
return contracts.MarshalTopicPayload(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"longheng.io/server/internal/platform/tracing"
|
||||
)
|
||||
|
||||
func envelopeWithTrace(ctx context.Context, env Envelope) Envelope {
|
||||
env.Trace = tracing.InjectCarrier(ctx, env.Trace)
|
||||
return env
|
||||
}
|
||||
|
||||
func contextWithTrace(ctx context.Context, env Envelope) context.Context {
|
||||
return tracing.ExtractCarrier(ctx, env.Trace)
|
||||
}
|
||||
Reference in new issue
Block a user