优化结构

This commit is contained in:
Yvan
2026-08-21 08:54:29 +08:00
parent c968f9f24e
commit 77e192b6c0
167 changed files with 3657 additions and 1528 deletions
+58
View File
@@ -0,0 +1,58 @@
// Package mq exposes the broker-agnostic messaging seam used by services.
package mq
import (
"context"
"encoding/json"
"errors"
"time"
)
var ErrUnavailable = errors.New("message broker unavailable")
const (
AtMostOnce byte = 0
AtLeastOnce byte = 1
ExactlyOnce byte = 2
)
type Message struct {
Topic string
Payload []byte
QoS byte
Retain bool
}
func (m Message) DecodeJSON(target any) error { return json.Unmarshal(m.Payload, target) }
type Handler func(context.Context, Message)
type Client interface {
Publish(context.Context, string, []byte, byte, bool) error
Subscribe(context.Context, string, byte, Handler) error
Unsubscribe(context.Context, ...string) error
Connected() bool
Close() error
}
type Config struct {
Enabled bool
Broker string
ClientID string
Username string
Password string
KeepAlive time.Duration
CleanSession bool
ConnectTimeout time.Duration
}
func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error {
if client == nil {
return ErrUnavailable
}
payload, err := json.Marshal(value)
if err != nil {
return err
}
return client.Publish(ctx, topic, payload, qos, retain)
}
+126
View File
@@ -0,0 +1,126 @@
package mq
import (
"context"
"fmt"
"sync"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
type MQTT struct {
client paho.Client
mu sync.RWMutex
closed bool
}
func NewMQTT(cfg Config) (*MQTT, error) {
if !cfg.Enabled {
return &MQTT{}, nil
}
if cfg.Broker == "" {
return nil, fmt.Errorf("mqtt broker is empty")
}
if cfg.KeepAlive <= 0 {
cfg.KeepAlive = 30 * time.Second
}
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 10 * time.Second
}
opts := paho.NewClientOptions().AddBroker(cfg.Broker).SetClientID(cfg.ClientID).SetUsername(cfg.Username).SetPassword(cfg.Password)
opts.SetKeepAlive(cfg.KeepAlive).SetCleanSession(cfg.CleanSession).SetConnectTimeout(cfg.ConnectTimeout).SetAutoReconnect(true)
opts.SetResumeSubs(true).SetOrderMatters(false)
c := &MQTT{}
token := paho.NewClient(opts)
connect := token.Connect()
if !connect.WaitTimeout(cfg.ConnectTimeout) {
return nil, fmt.Errorf("connect mqtt: %w", ErrUnavailable)
}
if err := connect.Error(); err != nil {
return nil, err
}
c.client = token
return c, nil
}
func (c *MQTT) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
if topic == "" {
return fmt.Errorf("mqtt topic is empty")
}
if qos > ExactlyOnce {
return fmt.Errorf("invalid mqtt qos %d", qos)
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return waitToken(ctx, c.client.Publish(topic, qos, retain, payload))
}
func (c *MQTT) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
if topic == "" {
return fmt.Errorf("mqtt topic is empty")
}
if qos > ExactlyOnce {
return fmt.Errorf("invalid mqtt qos %d", qos)
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if handler == nil {
return fmt.Errorf("mqtt handler is nil")
}
return waitToken(ctx, c.client.Subscribe(topic, qos, func(_ paho.Client, msg paho.Message) {
handler(context.Background(), Message{Topic: msg.Topic(), Payload: append([]byte(nil), msg.Payload()...), QoS: msg.Qos(), Retain: msg.Retained()})
}))
}
func (c *MQTT) Unsubscribe(ctx context.Context, topics ...string) error {
if len(topics) == 0 {
return fmt.Errorf("mqtt topics are empty")
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return waitToken(ctx, c.client.Unsubscribe(topics...))
}
func waitToken(ctx context.Context, token paho.Token) error {
if token == nil {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
case <-token.Done():
return token.Error()
}
}
func (c *MQTT) Connected() bool { return c != nil && c.client != nil && c.client.IsConnected() }
func (c *MQTT) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(250)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package mq
import (
"context"
"errors"
"testing"
)
type captureClient struct{ payload []byte }
func (c *captureClient) Publish(_ context.Context, _ string, payload []byte, _ byte, _ bool) error {
c.payload = append([]byte(nil), payload...)
return nil
}
func (*captureClient) Subscribe(context.Context, string, byte, Handler) error { return nil }
func (*captureClient) Unsubscribe(context.Context, ...string) error { return nil }
func (*captureClient) Connected() bool { return true }
func (*captureClient) Close() error { return nil }
func TestDisabledMQTTIsSafeAndUnavailable(t *testing.T) {
client, err := NewMQTT(Config{})
if err != nil {
t.Fatal(err)
}
if client.Connected() {
t.Fatal("disabled client reported connected")
}
if err = client.Publish(context.Background(), "events/test", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
t.Fatalf("publish error = %v", err)
}
if err = client.Close(); err != nil {
t.Fatal(err)
}
if err = client.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
}
func TestEnabledMQTTRequiresBroker(t *testing.T) {
if _, err := NewMQTT(Config{Enabled: true}); err == nil {
t.Fatal("enabled MQTT without a broker should fail")
}
}
func TestPublishAndDecodeJSON(t *testing.T) {
client := &captureClient{}
if err := PublishJSON(context.Background(), client, "orders/paid", map[string]int{"id": 7}, AtLeastOnce, false); err != nil {
t.Fatal(err)
}
var value map[string]int
if err := (Message{Payload: client.payload}).DecodeJSON(&value); err != nil {
t.Fatal(err)
}
if value["id"] != 7 {
t.Fatalf("decoded value = %#v", value)
}
}