优化结构
This commit is contained in:
@@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces.
|
||||
|
||||
- `cache`: Redis-backed cache with an in-memory fallback.
|
||||
- `email`: SMTP email repository.
|
||||
- `mq`: reloadable EMQX/MQTT client exposed through the shared `pkg/mq` interface.
|
||||
- `mq`: reloadable EMQX/MQTT and RabbitMQ/AMQP clients exposed through the shared `pkg/mq` interface.
|
||||
- `payment`: payment-channel SDKs and callback/signature handling.
|
||||
- `storage`: local and object-storage implementations of `biz.FileStorage`.
|
||||
- `websocket`: reloadable Melody endpoint exposed through the shared
|
||||
@@ -28,7 +28,7 @@ Business modules depend on `websocket.Hub` and `mq.Client` from the shared
|
||||
`pkg/websocket` and `pkg/mq` packages; they do not construct
|
||||
Melody or Paho clients and do not read system configuration directly. The
|
||||
system integration packages own runtime refresh and shutdown. Registered
|
||||
WebSocket handlers and MQTT subscriptions are retained when database-backed
|
||||
WebSocket handlers and broker subscriptions are retained when database-backed
|
||||
configuration replaces a live client.
|
||||
|
||||
```go
|
||||
@@ -45,7 +45,8 @@ Integration configuration is stored in `sys_integration_configs`:
|
||||
|
||||
- WebSocket: `kind=websocket`, `provider=melody`
|
||||
- EMQX: `kind=mq`, `provider=emqx`
|
||||
- RabbitMQ: `kind=mq`, `provider=rabbitmq`
|
||||
|
||||
The YAML values are only migration/bootstrap inputs. After the integration
|
||||
table exists, the database is authoritative and runtime updates are applied
|
||||
without restarting the process. The WebSocket public path defaults to `/ws`.
|
||||
These three integrations are stored only in `sys_integration_configs`; they do
|
||||
not come from `config.yaml`. Runtime updates are applied without restarting
|
||||
the process. The WebSocket public path defaults to `/ws`.
|
||||
|
||||
+230
-71
@@ -2,140 +2,294 @@ package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/mq"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
platformmq "kra/pkg/mq"
|
||||
)
|
||||
|
||||
// Reloadable follows the runtime snapshot and keeps a single shared EMQX
|
||||
// connection for all modules in this process.
|
||||
const (
|
||||
ProviderEMQX = "emqx"
|
||||
ProviderRabbitMQ = "rabbitmq"
|
||||
)
|
||||
|
||||
// Reloadable owns the process-wide message clients. Configuration comes only
|
||||
// from sys_integration_configs through runtimeconfig.Store.
|
||||
type Reloadable struct {
|
||||
mu sync.RWMutex
|
||||
opMu sync.Mutex
|
||||
current mq.Client
|
||||
subscriptions map[string]subscription
|
||||
stop func()
|
||||
clients map[string]platformmq.Client
|
||||
subscriptions map[string]map[string]subscription
|
||||
stop []func()
|
||||
logger *slog.Logger
|
||||
closed bool
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
qos byte
|
||||
handler mq.Handler
|
||||
handler platformmq.Handler
|
||||
}
|
||||
|
||||
func New(runtime *conf.Runtime, logger *slog.Logger) (*Reloadable, func(), error) {
|
||||
type namedClient struct {
|
||||
owner *Reloadable
|
||||
provider string
|
||||
}
|
||||
|
||||
func New(store *runtimeconfig.Store, logger *slog.Logger) (*Reloadable, func(), error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
r := &Reloadable{logger: logger, subscriptions: make(map[string]subscription)}
|
||||
if runtime != nil {
|
||||
var config *conf.AdminBackend_MQ
|
||||
if admin := runtime.Admin(); admin != nil {
|
||||
config = admin.GetMq()
|
||||
}
|
||||
r.replace(config)
|
||||
r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) {
|
||||
if admin != nil {
|
||||
r.replace(admin.GetMq())
|
||||
}
|
||||
})
|
||||
r := &Reloadable{
|
||||
clients: make(map[string]platformmq.Client),
|
||||
subscriptions: make(map[string]map[string]subscription),
|
||||
logger: logger,
|
||||
}
|
||||
if store != nil {
|
||||
r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX))
|
||||
r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ))
|
||||
r.stop = append(r.stop,
|
||||
store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }),
|
||||
store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }),
|
||||
)
|
||||
}
|
||||
cleanup := func() {
|
||||
if r.stop != nil {
|
||||
r.stop()
|
||||
for _, stop := range r.stop {
|
||||
stop()
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
return r, cleanup, nil
|
||||
}
|
||||
|
||||
func (r *Reloadable) replace(config *conf.AdminBackend_MQ) {
|
||||
func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Config {
|
||||
config, _ := store.Get("mq", provider)
|
||||
return config
|
||||
}
|
||||
|
||||
func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_MQ{}
|
||||
}
|
||||
cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession}
|
||||
if config.KeepAlive > 0 {
|
||||
cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second
|
||||
}
|
||||
if config.ConnectTimeout > 0 {
|
||||
cfg.ConnectTimeout = time.Duration(config.ConnectTimeout) * time.Second
|
||||
}
|
||||
client, err := mq.NewMQTT(cfg)
|
||||
if err != nil {
|
||||
r.logger.Warn("emqx unavailable", "mod", "mq", "error", err)
|
||||
if !config.Enabled {
|
||||
r.replaceClientLocked(provider, nil)
|
||||
return
|
||||
}
|
||||
if config.Enabled {
|
||||
if err = r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
_ = client.Close()
|
||||
r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err)
|
||||
return
|
||||
}
|
||||
client, err := newProviderClient(provider, config.Values)
|
||||
if err != nil {
|
||||
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||
return
|
||||
}
|
||||
if err = r.restoreSubscriptionsLocked(provider, client); err != nil {
|
||||
_ = client.Close()
|
||||
r.logger.Warn("restore message subscriptions failed", "mod", "mq", "provider", provider, "error", err)
|
||||
return
|
||||
}
|
||||
r.replaceClientLocked(provider, client)
|
||||
}
|
||||
|
||||
func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client, error) {
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, fmt.Errorf("decode %s configuration: %w", provider, err)
|
||||
}
|
||||
switch provider {
|
||||
case ProviderEMQX:
|
||||
return platformmq.NewMQTT(platformmq.Config{
|
||||
Enabled: true,
|
||||
Broker: configText(values, "broker"),
|
||||
ClientID: configText(values, "client_id"),
|
||||
Username: configText(values, "username"),
|
||||
Password: configText(values, "password"),
|
||||
KeepAlive: configSeconds(values, "keep_alive"),
|
||||
CleanSession: configBool(values, "clean_session"),
|
||||
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||
})
|
||||
case ProviderRabbitMQ:
|
||||
return platformmq.NewRabbitMQ(platformmq.RabbitMQConfig{
|
||||
Enabled: true,
|
||||
Host: configText(values, "host"),
|
||||
Port: configInt(values, "port"),
|
||||
Username: configText(values, "username"),
|
||||
Password: configText(values, "password"),
|
||||
VHost: configText(values, "vhost"),
|
||||
Exchange: configText(values, "exchange"),
|
||||
ExchangeType: configText(values, "exchange_type"),
|
||||
Queue: configText(values, "queue"),
|
||||
RoutingKey: configText(values, "routing_key"),
|
||||
Durable: configBool(values, "durable"),
|
||||
AutoDelete: configBool(values, "auto_delete"),
|
||||
PrefetchCount: configInt(values, "prefetch_count"),
|
||||
Heartbeat: configSeconds(values, "heartbeat"),
|
||||
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||
TLS: configBool(values, "tls"),
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported message provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func configText(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func configInt(values map[string]any, key string) int {
|
||||
switch value := values[key].(type) {
|
||||
case float64:
|
||||
return int(value)
|
||||
case int:
|
||||
return value
|
||||
case json.Number:
|
||||
parsed, _ := strconv.Atoi(string(value))
|
||||
return parsed
|
||||
default:
|
||||
parsed, _ := strconv.Atoi(configText(values, key))
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
func configSeconds(values map[string]any, key string) time.Duration {
|
||||
seconds := configInt(values, key)
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func configBool(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = client
|
||||
old := r.clients[provider]
|
||||
if next == nil {
|
||||
delete(r.clients, provider)
|
||||
} else {
|
||||
r.clients[provider] = next
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reloadable) restoreSubscriptions(ctx context.Context, client mq.Client) error {
|
||||
for topic, item := range r.subscriptions {
|
||||
if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil {
|
||||
func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platformmq.Client) error {
|
||||
for topic, item := range r.subscriptions[provider] {
|
||||
if err := client.Subscribe(context.Background(), topic, item.qos, item.handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current }
|
||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
}
|
||||
return c.Publish(ctx, topic, payload, qos, retain)
|
||||
func (r *Reloadable) client(provider string) platformmq.Client {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.clients[provider]
|
||||
}
|
||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error {
|
||||
|
||||
func (r *Reloadable) Client(provider string) platformmq.Client {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
|
||||
return nil
|
||||
}
|
||||
return &namedClient{owner: r, provider: provider}
|
||||
}
|
||||
|
||||
func (c *namedClient) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
return c.owner.PublishTo(ctx, c.provider, topic, payload, qos, retain)
|
||||
}
|
||||
func (c *namedClient) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||
return c.owner.SubscribeTo(ctx, c.provider, topic, qos, handler)
|
||||
}
|
||||
func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||
return c.owner.UnsubscribeFrom(ctx, c.provider, topics...)
|
||||
}
|
||||
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
|
||||
func (*namedClient) Close() error { return nil }
|
||||
|
||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
|
||||
}
|
||||
|
||||
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
client := r.clients[provider]
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
return client.Publish(ctx, topic, payload, qos, retain)
|
||||
}
|
||||
|
||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
|
||||
}
|
||||
|
||||
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
client := r.client(provider)
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
if err := c.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
r.subscriptions[topic] = subscription{qos: qos, handler: handler}
|
||||
if r.subscriptions[provider] == nil {
|
||||
r.subscriptions[provider] = make(map[string]subscription)
|
||||
}
|
||||
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
|
||||
}
|
||||
|
||||
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
client := r.client(provider)
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
if err := c.Unsubscribe(ctx, topics...); err != nil {
|
||||
if err := client.Unsubscribe(ctx, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, topic := range topics {
|
||||
delete(r.subscriptions, topic)
|
||||
delete(r.subscriptions[provider], topic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.Connected() }
|
||||
|
||||
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||
|
||||
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
client := r.clients[provider]
|
||||
return client != nil && client.Connected()
|
||||
}
|
||||
|
||||
func (r *Reloadable) Close() error {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
@@ -144,11 +298,16 @@ func (r *Reloadable) Close() error {
|
||||
}
|
||||
r.closed = true
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = nil
|
||||
clients := make([]platformmq.Client, 0, len(r.clients))
|
||||
for provider, client := range r.clients {
|
||||
clients = append(clients, client)
|
||||
delete(r.clients, provider)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
return old.Close()
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil }
|
||||
|
||||
func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{current: client, subscriptions: make(map[string]subscription)}
|
||||
r := &Reloadable{
|
||||
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
||||
subscriptions: make(map[string]map[string]subscription),
|
||||
}
|
||||
handler := func(context.Context, platformmq.Message) {}
|
||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; !ok {
|
||||
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; !ok {
|
||||
t.Fatal("subscription was not retained for configuration reload")
|
||||
}
|
||||
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; ok {
|
||||
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; ok {
|
||||
t.Fatal("unsubscribed topic remained in the reload registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{subscriptions: map[string]subscription{
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
r := &Reloadable{subscriptions: map[string]map[string]subscription{
|
||||
ProviderEMQX: {
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
},
|
||||
}}
|
||||
if err := r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||
|
||||
@@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet(
|
||||
storage.NewFileStorage,
|
||||
mqintegration.New,
|
||||
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
||||
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),
|
||||
websocketintegration.New,
|
||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package runtimeconfig keeps the active database-backed integration settings
|
||||
// and notifies long-lived provider clients when they change.
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Kind string
|
||||
Provider string
|
||||
Enabled bool
|
||||
Values json.RawMessage
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
kind string
|
||||
provider string
|
||||
callback func(Config)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]Config
|
||||
listeners map[uint64]listener
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{values: make(map[string]Config), listeners: make(map[uint64]listener)}
|
||||
}
|
||||
|
||||
func configKey(kind, provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(kind)) + "/" + strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
|
||||
func cloneConfig(config Config) Config {
|
||||
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Store) Get(kind, provider string) (Config, bool) {
|
||||
if s == nil {
|
||||
return Config{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
config, ok := s.values[configKey(kind, provider)]
|
||||
s.mu.RUnlock()
|
||||
return cloneConfig(config), ok
|
||||
}
|
||||
|
||||
func (s *Store) Set(config Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||
config = cloneConfig(config)
|
||||
key := configKey(config.Kind, config.Provider)
|
||||
s.mu.Lock()
|
||||
s.values[key] = config
|
||||
callbacks := s.matchingListenersLocked(config.Kind, config.Provider)
|
||||
s.mu.Unlock()
|
||||
for _, callback := range callbacks {
|
||||
callback(cloneConfig(config))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Delete(kind, provider string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
s.mu.Lock()
|
||||
delete(s.values, configKey(kind, provider))
|
||||
callbacks := s.matchingListenersLocked(kind, provider)
|
||||
s.mu.Unlock()
|
||||
config := Config{Kind: kind, Provider: provider}
|
||||
for _, callback := range callbacks {
|
||||
callback(config)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Replace(configs []Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
next := make(map[string]Config, len(configs))
|
||||
for _, config := range configs {
|
||||
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||
config = cloneConfig(config)
|
||||
next[configKey(config.Kind, config.Provider)] = config
|
||||
}
|
||||
s.mu.Lock()
|
||||
previous := s.values
|
||||
s.values = next
|
||||
listeners := make([]listener, 0, len(s.listeners))
|
||||
for _, item := range s.listeners {
|
||||
listeners = append(listeners, item)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
changed := make(map[string]Config, len(previous)+len(next))
|
||||
for key, config := range previous {
|
||||
changed[key] = Config{Kind: config.Kind, Provider: config.Provider}
|
||||
}
|
||||
for key, config := range next {
|
||||
changed[key] = config
|
||||
}
|
||||
for _, item := range listeners {
|
||||
if config, ok := changed[configKey(item.kind, item.provider)]; ok {
|
||||
item.callback(cloneConfig(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Subscribe(kind, provider string, callback func(Config)) func() {
|
||||
if s == nil || callback == nil {
|
||||
return func() {}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
s.listeners[id] = listener{kind: strings.ToLower(strings.TrimSpace(kind)), provider: strings.ToLower(strings.TrimSpace(provider)), callback: callback}
|
||||
s.mu.Unlock()
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
delete(s.listeners, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) matchingListenersLocked(kind, provider string) []func(Config) {
|
||||
callbacks := make([]func(Config), 0)
|
||||
for _, item := range s.listeners {
|
||||
if item.kind == kind && item.provider == provider {
|
||||
callbacks = append(callbacks, item.callback)
|
||||
}
|
||||
}
|
||||
return callbacks
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreSetDeleteAndSubscribe(t *testing.T) {
|
||||
store := NewStore()
|
||||
updates := make(chan Config, 2)
|
||||
stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config })
|
||||
defer stop()
|
||||
|
||||
store.Set(Config{Kind: "MQ", Provider: "RabbitMQ", Enabled: true, Values: json.RawMessage(`{"host":"localhost"}`)})
|
||||
loaded, ok := store.Get("mq", "rabbitmq")
|
||||
if !ok || !loaded.Enabled || string(loaded.Values) != `{"host":"localhost"}` {
|
||||
t.Fatalf("loaded config = %#v, ok=%v", loaded, ok)
|
||||
}
|
||||
if update := <-updates; !update.Enabled {
|
||||
t.Fatalf("set update = %#v", update)
|
||||
}
|
||||
|
||||
store.Delete("mq", "rabbitmq")
|
||||
if _, ok = store.Get("mq", "rabbitmq"); ok {
|
||||
t.Fatal("deleted config remained in store")
|
||||
}
|
||||
if update := <-updates; update.Enabled {
|
||||
t.Fatalf("delete update = %#v", update)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
melody "github.com/olahol/melody"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
// Server is the system-owned WebSocket endpoint. Business modules can attach
|
||||
// handlers and publish messages without depending on Gin or Melody directly.
|
||||
const ProviderMelody = "melody"
|
||||
|
||||
// Server owns the database-configured WebSocket endpoint.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
current *platformws.Server
|
||||
@@ -22,24 +26,25 @@ type Server struct {
|
||||
binaryHandlers []func(*melody.Session, []byte)
|
||||
connectHandlers []func(*melody.Session)
|
||||
disconnectHandlers []func(*melody.Session)
|
||||
stop func()
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(runtime *conf.Runtime) (*Server, func(), error) {
|
||||
func New(store *runtimeconfig.Store) (*Server, func(), error) {
|
||||
s := &Server{}
|
||||
s.Replace(runtime)
|
||||
var unsubscribe func()
|
||||
if runtime != nil {
|
||||
unsubscribe = runtime.Subscribe(func(_ *conf.Data, _ *conf.AdminBackend) { s.Replace(runtime) })
|
||||
if store != nil {
|
||||
s.apply(storeConfig(store))
|
||||
s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) })
|
||||
}
|
||||
return s, func() {
|
||||
if unsubscribe != nil {
|
||||
unsubscribe()
|
||||
if s.stop != nil {
|
||||
s.stop()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
current := s.current
|
||||
s.current = nil
|
||||
s.path = ""
|
||||
s.mu.Unlock()
|
||||
if current != nil {
|
||||
_ = current.Close()
|
||||
@@ -47,13 +52,20 @@ func New(runtime *conf.Runtime) (*Server, func(), error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Replace(runtime *conf.Runtime) {
|
||||
var config *conf.AdminBackend_WebSocket
|
||||
if runtime != nil && runtime.Admin() != nil {
|
||||
config = runtime.Admin().Websocket
|
||||
func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config {
|
||||
config, _ := store.Get("websocket", ProviderMelody)
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Server) apply(config runtimeconfig.Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_WebSocket{}
|
||||
values := map[string]any{}
|
||||
if len(config.Values) > 0 {
|
||||
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
@@ -70,14 +82,21 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
||||
}
|
||||
return
|
||||
}
|
||||
path := text(values, "path")
|
||||
if path == "" {
|
||||
path = "/ws"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
next := platformws.New(platformws.Config{
|
||||
WriteWait: duration(config.WriteWait, 10*time.Second),
|
||||
PongWait: duration(config.PongWait, 60*time.Second),
|
||||
PingPeriod: duration(config.PingPeriod, 54*time.Second),
|
||||
MaxMessageSize: config.MaxMessageSize,
|
||||
MessageBufferSize: int(config.MessageBufferSize),
|
||||
ConcurrentMessageHandling: config.ConcurrentMessageHandling,
|
||||
AllowOrigins: config.AllowOrigins,
|
||||
WriteWait: durationValue(values, "write_wait", 10*time.Second),
|
||||
PongWait: durationValue(values, "pong_wait", 60*time.Second),
|
||||
PingPeriod: durationValue(values, "ping_period", 54*time.Second),
|
||||
MaxMessageSize: int64Value(values, "max_message_size"),
|
||||
MessageBufferSize: int(intValue(values, "message_buffer_size")),
|
||||
ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"),
|
||||
AllowOrigins: stringList(values, "allow_origins"),
|
||||
})
|
||||
for _, handler := range s.messageHandlers {
|
||||
next.OnMessage(handler)
|
||||
@@ -91,27 +110,65 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
||||
for _, handler := range s.disconnectHandlers {
|
||||
next.OnDisconnect(handler)
|
||||
}
|
||||
s.path = strings.TrimSpace(config.Path)
|
||||
if s.path == "" {
|
||||
s.path = "/ws"
|
||||
} else if !strings.HasPrefix(s.path, "/") {
|
||||
s.path = "/" + s.path
|
||||
}
|
||||
s.current = next
|
||||
s.path = path
|
||||
s.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func duration(value interface{ AsDuration() time.Duration }, fallback time.Duration) time.Duration {
|
||||
if value == nil {
|
||||
return fallback
|
||||
func text(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if result := value.AsDuration(); result > 0 {
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func intValue(values map[string]any, key string) int64 {
|
||||
switch value := values[key].(type) {
|
||||
case float64:
|
||||
return int64(value)
|
||||
case int:
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
parsed, _ := strconv.ParseInt(string(value), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
parsed, _ := strconv.ParseInt(text(values, key), 10, 64)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
func int64Value(values map[string]any, key string) int64 { return intValue(values, key) }
|
||||
func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value }
|
||||
func stringList(values map[string]any, key string) []string {
|
||||
value, ok := values[key].([]any)
|
||||
if ok {
|
||||
result := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if item != nil && strings.TrimSpace(fmt.Sprint(item)) != "" {
|
||||
result = append(result, strings.TrimSpace(fmt.Sprint(item)))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return fallback
|
||||
if value, ok := values[key].([]string); ok {
|
||||
return append([]string(nil), value...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func durationValue(values map[string]any, key string, fallback time.Duration) time.Duration {
|
||||
value := text(values, key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (s *Server) Enabled() bool {
|
||||
@@ -131,14 +188,9 @@ func (s *Server) Path() string {
|
||||
return s.path
|
||||
}
|
||||
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.HandleRequest(w, r)
|
||||
}
|
||||
@@ -150,26 +202,16 @@ func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, k
|
||||
return current.HandleRequestWithKeys(w, r, keys)
|
||||
}
|
||||
func (s *Server) Broadcast(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.Broadcast(message)
|
||||
}
|
||||
func (s *Server) BroadcastBinary(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.BroadcastBinary(message)
|
||||
}
|
||||
@@ -231,8 +273,9 @@ func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.binaryHandlers = append(s.binaryHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnBinaryMessage(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnBinaryMessage(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -242,8 +285,9 @@ func (s *Server) OnConnect(handler func(*melody.Session)) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.connectHandlers = append(s.connectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnConnect(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnConnect(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -253,8 +297,9 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnDisconnect(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnDisconnect(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user