优化结构
This commit is contained in:
@@ -53,6 +53,12 @@ func skipStackFile(filename string) bool {
|
||||
"/pkg/logging/",
|
||||
"/internal/transport/middleware/",
|
||||
"/internal/transport/router/",
|
||||
"/internal/server/handler/",
|
||||
"/internal/server/middleware/",
|
||||
"/internal/server/router/",
|
||||
"/internal/server/httpx/",
|
||||
"/internal/server/middleware_",
|
||||
"/internal/server/route_",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
|
||||
@@ -95,6 +95,30 @@ func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipStackFileRecognizesServerTransportPackages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{name: "handler", path: `D:\workspace\app\system\internal\server\handler\user.go`, want: true},
|
||||
{name: "middleware", path: `D:\workspace\app\system\internal\server\middleware\auth.go`, want: true},
|
||||
{name: "router", path: `/workspace/app/system/internal/server/router/user.go`, want: true},
|
||||
{name: "http helper", path: `/workspace/app/system/internal/server/httpx/response.go`, want: true},
|
||||
{name: "legacy flattened middleware", path: `D:\workspace\app\system\internal\server\middleware_auth.go`, want: true},
|
||||
{name: "legacy flattened route", path: `/workspace/app/system/internal/server/route_user.go`, want: true},
|
||||
{name: "handler remains application boundary", path: `/workspace/app/system/internal/server/handler_user.go`, want: false},
|
||||
{name: "legacy middleware", path: `/workspace/app/system/internal/transport/middleware/auth.go`, want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := skipStackFile(test.path); got != test.want {
|
||||
t.Fatalf("skipStackFile(%q) = %v, want %v", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var entries []ErrorEntry
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package websocket provides a small, framework-neutral wrapper around Melody.
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
melody "github.com/olahol/melody"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WriteWait time.Duration
|
||||
PongWait time.Duration
|
||||
PingPeriod time.Duration
|
||||
MaxMessageSize int64
|
||||
MessageBufferSize int
|
||||
ConcurrentMessageHandling bool
|
||||
AllowOrigins []string
|
||||
}
|
||||
|
||||
type Session = melody.Session
|
||||
|
||||
// Hub is the shared WebSocket boundary consumed by business modules. The
|
||||
// application composition root supplies the runtime-configured implementation.
|
||||
type Hub interface {
|
||||
Enabled() bool
|
||||
Broadcast([]byte) error
|
||||
BroadcastBinary([]byte) error
|
||||
Sessions() ([]*Session, error)
|
||||
Len() int
|
||||
Send(*Session, []byte) error
|
||||
SendBinary(*Session, []byte) error
|
||||
OnMessage(func(*Session, []byte))
|
||||
OnBinaryMessage(func(*Session, []byte))
|
||||
OnConnect(func(*Session))
|
||||
OnDisconnect(func(*Session))
|
||||
}
|
||||
|
||||
func (c Config) defaults() Config {
|
||||
if c.WriteWait <= 0 {
|
||||
c.WriteWait = 10 * time.Second
|
||||
}
|
||||
if c.PongWait <= 0 {
|
||||
c.PongWait = 60 * time.Second
|
||||
}
|
||||
if c.PingPeriod <= 0 {
|
||||
c.PingPeriod = (c.PongWait * 9) / 10
|
||||
}
|
||||
if c.MaxMessageSize <= 0 {
|
||||
c.MaxMessageSize = 1 << 20
|
||||
}
|
||||
if c.MessageBufferSize <= 0 {
|
||||
c.MessageBufferSize = 256
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
m *melody.Melody
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
messageHandlers []func(*melody.Session, []byte)
|
||||
binaryHandlers []func(*melody.Session, []byte)
|
||||
connectHandlers []func(*melody.Session)
|
||||
disconnectHandlers []func(*melody.Session)
|
||||
}
|
||||
|
||||
func New(config Config) *Server {
|
||||
c := config.defaults()
|
||||
m := melody.New()
|
||||
m.Config.WriteWait = c.WriteWait
|
||||
m.Config.PongWait = c.PongWait
|
||||
m.Config.PingPeriod = c.PingPeriod
|
||||
m.Config.MaxMessageSize = c.MaxMessageSize
|
||||
m.Config.MessageBufferSize = c.MessageBufferSize
|
||||
m.Config.ConcurrentMessageHandling = c.ConcurrentMessageHandling
|
||||
allowed := append([]string(nil), c.AllowOrigins...)
|
||||
m.Upgrader.CheckOrigin = func(r *http.Request) bool {
|
||||
if len(allowed) == 0 {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(origin)
|
||||
return err == nil && strings.EqualFold(parsed.Host, r.Host)
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
for _, value := range allowed {
|
||||
if value == "*" || strings.EqualFold(strings.TrimSpace(value), origin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
s := &Server{m: m}
|
||||
m.HandleMessage(func(session *melody.Session, message []byte) {
|
||||
for _, handler := range s.messageHandlerSnapshot() {
|
||||
handler(session, message)
|
||||
}
|
||||
})
|
||||
m.HandleMessageBinary(func(session *melody.Session, message []byte) {
|
||||
for _, handler := range s.binaryHandlerSnapshot() {
|
||||
handler(session, message)
|
||||
}
|
||||
})
|
||||
m.HandleConnect(func(session *melody.Session) {
|
||||
for _, handler := range s.connectHandlerSnapshot() {
|
||||
handler(session)
|
||||
}
|
||||
})
|
||||
m.HandleDisconnect(func(session *melody.Session) {
|
||||
for _, handler := range s.disconnectHandlerSnapshot() {
|
||||
handler(session)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Melody() *melody.Melody {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.m
|
||||
}
|
||||
func (s *Server) Enabled() bool {
|
||||
if s == nil || s.m == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return !s.closed
|
||||
}
|
||||
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.m.HandleRequest(w, r)
|
||||
}
|
||||
func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, keys map[string]any) error {
|
||||
return s.m.HandleRequestWithKeys(w, r, keys)
|
||||
}
|
||||
func (s *Server) Broadcast(message []byte) error { return s.m.Broadcast(message) }
|
||||
func (s *Server) BroadcastBinary(message []byte) error { return s.m.BroadcastBinary(message) }
|
||||
func (s *Server) Sessions() ([]*melody.Session, error) { return s.m.Sessions() }
|
||||
func (s *Server) Len() int { return s.m.Len() }
|
||||
func (s *Server) OnMessage(handler func(*melody.Session, []byte)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messageHandlers = append(s.messageHandlers, handler)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.binaryHandlers = append(s.binaryHandlers, handler)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnConnect(handler func(*melody.Session)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.connectHandlers = append(s.connectHandlers, handler)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) messageHandlerSnapshot() []func(*melody.Session, []byte) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
handlers := make([]func(*melody.Session, []byte), len(s.messageHandlers))
|
||||
copy(handlers, s.messageHandlers)
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (s *Server) binaryHandlerSnapshot() []func(*melody.Session, []byte) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
handlers := make([]func(*melody.Session, []byte), len(s.binaryHandlers))
|
||||
copy(handlers, s.binaryHandlers)
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (s *Server) connectHandlerSnapshot() []func(*melody.Session) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
handlers := make([]func(*melody.Session), len(s.connectHandlers))
|
||||
copy(handlers, s.connectHandlers)
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (s *Server) disconnectHandlerSnapshot() []func(*melody.Session) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
handlers := make([]func(*melody.Session), len(s.disconnectHandlers))
|
||||
copy(handlers, s.disconnectHandlers)
|
||||
return handlers
|
||||
}
|
||||
func (s *Server) Send(session *melody.Session, message []byte) error {
|
||||
if session == nil {
|
||||
return melody.ErrClosed
|
||||
}
|
||||
return session.Write(message)
|
||||
}
|
||||
|
||||
func (s *Server) SendBinary(session *melody.Session, message []byte) error {
|
||||
if session == nil {
|
||||
return melody.ErrClosed
|
||||
}
|
||||
return session.WriteBinary(message)
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
if s == nil || s.m == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
return s.m.Close()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
melody "github.com/olahol/melody"
|
||||
)
|
||||
|
||||
func TestServerReceivesAndSendsTextMessages(t *testing.T) {
|
||||
server := New(Config{})
|
||||
var firstHandlerCalled atomic.Bool
|
||||
server.OnMessage(func(_ *melody.Session, _ []byte) {
|
||||
firstHandlerCalled.Store(true)
|
||||
})
|
||||
server.OnMessage(func(session *melody.Session, message []byte) {
|
||||
_ = server.Send(session, append([]byte("echo:"), message...))
|
||||
})
|
||||
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = server.HandleRequest(w, r)
|
||||
}))
|
||||
defer httpServer.Close()
|
||||
defer server.Close()
|
||||
|
||||
url := "ws" + strings.TrimPrefix(httpServer.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err = conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(message) != "echo:hello" {
|
||||
t.Fatalf("message = %q", message)
|
||||
}
|
||||
if !firstHandlerCalled.Load() {
|
||||
t.Fatal("first message handler was overwritten")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user