DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+656
View File
@@ -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()
}
+58
View File
@@ -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
}
+127
View File
@@ -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))
}
}
+428
View File
@@ -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
}
+257
View File
@@ -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
}
+832
View File
@@ -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 ""
}
+122
View File
@@ -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)
}
}
+16
View File
@@ -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)
}