feat(core): implement cordis configuration extension and migrate all plugins

This commit is contained in:
ryan
2026-08-29 10:53:53 +08:00
parent b43c429544
commit ed8491addf
74 changed files with 1354 additions and 1145 deletions
@@ -7,7 +7,6 @@ package driver_asynq_cron
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/pkg/config"
"context"
"embed"
"encoding/json"
@@ -84,10 +83,45 @@ func (p *Plugin) Name() string {
return "driver_asynq_cron"
}
type redisCronConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
Username string `config:"username" env:"REDIS_USERNAME"`
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
DB int `config:"db" env:"REDIS_DB"`
}
// DeclareConfig declares configuration bindings for driver_asynq_cron.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "redis", Target: &redisCronConfig{}},
}
}
// ConfigEnabled gates plugin activation when Redis is enabled.
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
return view.Bool("redis.enabled", false)
}
// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context.
func (p *Plugin) Apply(ctx *core.Context) error {
var rCfg redisCronConfig
_ = ctx.Config().Bind("redis", &rCfg)
p.mu.Lock()
p.coreCtx = ctx
if p.redisOpt == nil {
addr := "127.0.0.1:6379"
if len(rCfg.Addrs) > 0 && rCfg.Addrs[0] != "" {
addr = rCfg.Addrs[0]
}
p.redisOpt = asynq.RedisClientOpt{
Addr: addr,
Username: rCfg.Username,
Password: rCfg.Password,
DB: rCfg.DB,
}
}
p.mu.Unlock()
// Bind DBService
@@ -263,15 +297,7 @@ func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt {
if RedisOpt != nil {
return RedisOpt
}
redisCfg := config.Config.Redis
addr := "127.0.0.1:6379"
if len(redisCfg.Addrs) > 0 && redisCfg.Addrs[0] != "" {
addr = redisCfg.Addrs[0]
}
return asynq.RedisClientOpt{
Addr: addr,
Username: redisCfg.Username,
Password: redisCfg.Password,
DB: redisCfg.DB,
Addr: "127.0.0.1:6379",
}
}
@@ -0,0 +1,28 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_asynq_worker
type queueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type workerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY" default:"10"`
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY" default:"false"`
Queues []queueConfig `config:"queues"`
}
type redisWorkerConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
Username string `config:"username" env:"REDIS_USERNAME"`
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
DB int `config:"db" env:"REDIS_DB"`
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
}
@@ -4,6 +4,7 @@
package driver_asynq_worker
import (
"Wavelet/pkg/idgen"
"Wavelet/pkg/testhelper"
"context"
"errors"
@@ -22,6 +23,10 @@ import (
"gorm.io/gorm"
)
func init() {
_ = idgen.Init(1)
}
type mockDBService struct {
db *gorm.DB
}
@@ -108,10 +108,44 @@ func (p *Plugin) Name() string {
return "driver_asynq_worker"
}
// DeclareConfig declares configuration bindings consumed by the Asynq worker driver.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "worker", Target: &workerConfig{}},
{Prefix: "redis", Target: &redisWorkerConfig{}},
}
}
// ConfigEnabled gates plugin activation when Redis is enabled.
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
return view.Bool("redis.enabled", false)
}
// Apply mounts the Asynq Worker driver into the micro-kernel Context.
func (p *Plugin) Apply(ctx *core.Context) error {
var wCfg workerConfig
_ = ctx.Config().Bind("worker", &wCfg)
var rCfg redisWorkerConfig
_ = ctx.Config().Bind("redis", &rCfg)
p.mu.Lock()
p.coreCtx = ctx
if p.concurrency == defaultConcurrency && wCfg.Concurrency > 0 {
p.concurrency = wCfg.Concurrency
}
p.strictPriority = wCfg.StrictPriority
if len(p.queues) == 1 && p.queues["default"] == 1 && len(wCfg.Queues) > 0 {
qMap := make(map[string]int, len(wCfg.Queues))
for _, q := range wCfg.Queues {
qMap[q.Name] = q.Priority
}
p.queues = qMap
}
if p.redisOpt == nil {
p.redisOpt = NewRedisConnOptWithConfig(rCfg)
}
RedisOpt = p.redisOpt
ResetAsynqClient()
p.mu.Unlock()
// 0. Bind DBService
@@ -4,7 +4,6 @@
package driver_asynq_worker
import (
"Wavelet/pkg/config"
"sync"
"github.com/hibiken/asynq"
@@ -72,8 +71,11 @@ func GetAsynqClient() *asynq.Client {
return AsynqClient
}
opt := NewRedisConnOpt()
RedisOpt = opt
opt := RedisOpt
if opt == nil {
opt = NewRedisConnOpt()
RedisOpt = opt
}
AsynqClient = asynq.NewClient(opt)
return AsynqClient
}
@@ -88,9 +90,33 @@ func ResetAsynqClient() {
}
}
var (
keyPrefixMu sync.RWMutex
keyPrefix string
)
// SetKeyPrefix sets the redis key prefix for queue names.
func SetKeyPrefix(prefix string) {
keyPrefixMu.Lock()
defer keyPrefixMu.Unlock()
keyPrefix = prefix
}
// GetKeyPrefix returns the redis key prefix for queue names.
func GetKeyPrefix() string {
keyPrefixMu.RLock()
defer keyPrefixMu.RUnlock()
return keyPrefix
}
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
func NewRedisConnOpt() asynq.RedisConnOpt {
cfg := config.Config.Redis
return NewRedisConnOptWithConfig(redisWorkerConfig{})
}
// NewRedisConnOptWithConfig returns the asynq RedisConnOpt based on the provided configuration.
func NewRedisConnOptWithConfig(cfg redisWorkerConfig) asynq.RedisConnOpt {
SetKeyPrefix(cfg.KeyPrefix)
addrs := cfg.Addrs
if cfg.ClusterMode {
@@ -135,7 +161,7 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
func PrefixedQueue(queue string) string {
prefix := config.Config.Redis.KeyPrefix
prefix := GetKeyPrefix()
if prefix == "" {
return queue
}
@@ -4,7 +4,6 @@
package driver_asynq_worker
import (
"Wavelet/pkg/config"
"testing"
"github.com/redis/go-redis/v9/maintnotifications"
@@ -42,17 +41,17 @@ func TestMaintNotificationsConfig(t *testing.T) {
}
func TestPrefixedQueue(t *testing.T) {
oldPrefix := config.Config.Redis.KeyPrefix
oldPrefix := GetKeyPrefix()
defer func() {
config.Config.Redis.KeyPrefix = oldPrefix
SetKeyPrefix(oldPrefix)
}()
config.Config.Redis.KeyPrefix = "test:"
SetKeyPrefix("test:")
if got := PrefixedQueue("default"); got != "test:default" {
t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default")
}
config.Config.Redis.KeyPrefix = ""
SetKeyPrefix("")
if got := PrefixedQueue("default"); got != "default" {
t.Fatalf("PrefixedQueue() = %q, want %q", got, "default")
}
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_http
type httpAppConfig struct {
Addr string `config:"addr" env:"APP_ADDR" default:":8000"`
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX" default:"/api/v1"`
Env string `config:"env" env:"APP_ENV" default:"development"`
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT" default:"30"`
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
}
type httpRedisConfig struct {
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
Username string `config:"username" env:"REDIS_USERNAME"`
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
DB int `config:"db" env:"REDIS_DB"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
}
+42 -72
View File
@@ -4,19 +4,9 @@
package driver_http
import (
"Wavelet/pkg/config"
"Wavelet/pkg/trace"
"Wavelet/pkg/util"
"context"
"errors"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
@@ -26,102 +16,82 @@ import (
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
func BuildEngine() (*gin.Engine, error) {
return BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{})
}
// BuildEngineWithConfig constructs the Gin engine with explicitly injected configuration.
func BuildEngineWithConfig(appCfg httpAppConfig, redisCfg httpRedisConfig) (*gin.Engine, error) {
// 运行模式
if config.Config.App.IsProduction() {
if appCfg.Env == "production" || appCfg.Env == "prod" {
gin.SetMode(gin.ReleaseMode)
}
setAPIPrefix(appCfg.APIPrefix)
// 初始化路由
r := gin.New()
r.Use(gin.Recovery())
r.Use(corsMiddleware())
cfg := config.Config.Redis
addrs := cfg.Addrs
addrs := redisCfg.Addrs
sessionAddr := "localhost:6379"
if len(addrs) > 0 {
sessionAddr = addrs[0]
}
sessionSecret := appCfg.SessionSecret
if sessionSecret == "" {
sessionSecret = "wavelet-default-session-secret"
}
sessionStore, err := redis.NewStoreWithDB(
cfg.MinIdleConn,
redisCfg.MinIdleConn,
"tcp",
sessionAddr,
cfg.Username,
cfg.Password,
strconv.Itoa(cfg.DB),
[]byte(config.Config.App.SessionSecret),
redisCfg.Username,
redisCfg.Password,
strconv.Itoa(redisCfg.DB),
[]byte(sessionSecret),
)
if err != nil {
return nil, err
}
// 设置 Session Redis Key 前缀
if cfg.KeyPrefix != "" {
if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil {
if redisCfg.KeyPrefix != "" {
if err := redis.SetKeyPrefix(sessionStore, redisCfg.KeyPrefix+"session:"); err != nil {
log.Printf("[API] set session key prefix failed: %v\n", err)
}
}
sessionCookieName := appCfg.SessionCookieName
if sessionCookieName == "" {
sessionCookieName = "wavelet_session"
}
sessionAge := appCfg.SessionAge
if sessionAge <= 0 {
sessionAge = 86400
}
sessionStore.Options(sessions.Options{
Path: "/",
Domain: config.Config.App.SessionDomain,
MaxAge: config.Config.App.SessionAge,
HttpOnly: config.Config.App.SessionHTTPOnly,
Secure: config.Config.App.SessionSecure,
Domain: appCfg.SessionDomain,
MaxAge: sessionAge,
HttpOnly: appCfg.SessionHTTPOnly,
Secure: appCfg.SessionSecure,
SameSite: http.SameSiteLaxMode,
})
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
r.Use(sessions.Sessions(sessionCookieName, sessionStore))
appName := appCfg.AppName
if appName == "" {
appName = "Wavelet"
}
// 补充中间件
r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware())
r.Use(otelgin.Middleware(appName), errorHandlerMiddleware(), loggerMiddleware())
return r, nil
}
// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。
func Serve(onStarted func()) {
r, err := BuildEngine()
if err != nil {
log.Fatalf("[API] init session store failed: %v\n", err)
}
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr)
if err != nil {
log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err)
}
if onStarted != nil {
onStarted()
}
util.Go(func() {
log.Printf("[API] server listening on %s\n", config.Config.App.Addr)
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", err)
}
})
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
trace.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[API] server forced to shutdown: %v\n", err)
cancel()
os.Exit(1)
}
cancel()
log.Println("[API] server exited")
}
@@ -5,13 +5,13 @@
package driver_http
import (
"Wavelet/pkg/config"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"context"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -21,6 +21,26 @@ import (
otel_trace "Wavelet/pkg/trace"
)
var (
apiPrefixMu sync.RWMutex
apiPrefix = "/api/v1"
)
func setAPIPrefix(prefix string) {
if prefix == "" {
return
}
apiPrefixMu.Lock()
defer apiPrefixMu.Unlock()
apiPrefix = prefix
}
func getAPIPrefix() string {
apiPrefixMu.RLock()
defer apiPrefixMu.RUnlock()
return apiPrefix
}
func loggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 初始化 Trace
@@ -46,7 +66,7 @@ func loggerMiddleware() gin.HandlerFunc {
// 打印日志
// 排除健康检查接口
healthPath := config.Config.App.APIPrefix + "/health"
healthPath := getAPIPrefix() + "/health"
if c.Request.URL.Path != healthPath {
logger.InfoF(
ctx,
+32 -1
View File
@@ -91,10 +91,31 @@ func (p *Plugin) Name() string {
return "driver_http"
}
// DeclareConfig declares configuration bindings for driver_http.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "app", Target: &httpAppConfig{}},
{Prefix: "redis", Target: &httpRedisConfig{}},
}
}
// Apply mounts the HTTP driver plugin into the micro-kernel Context.
func (p *Plugin) Apply(ctx *core.Context) error {
var appCfg httpAppConfig
if err := ctx.Config().Bind("app", &appCfg); err != nil {
return err
}
var redisCfg httpRedisConfig
_ = ctx.Config().Bind("redis", &redisCfg)
p.mu.Lock()
p.coreCtx = ctx
if p.addr == defaultAddr && appCfg.Addr != "" {
p.addr = appCfg.Addr
}
if appCfg.GracefulShutdownTimeout > 0 {
p.shutdownTimeout = time.Duration(appCfg.GracefulShutdownTimeout) * time.Second
}
p.mu.Unlock()
// Bind DBService from Context
@@ -147,7 +168,17 @@ func (p *Plugin) Start(ctx context.Context) error {
}
if p.engine == nil {
p.engine = gin.New()
var appCfg httpAppConfig
var redisCfg httpRedisConfig
if p.coreCtx != nil {
_ = p.coreCtx.Config().Bind("app", &appCfg)
_ = p.coreCtx.Config().Bind("redis", &redisCfg)
}
var err error
p.engine, err = BuildEngineWithConfig(appCfg, redisCfg)
if err != nil {
p.engine = gin.New()
}
}
// Mount routes collected in Context RouterExtension
@@ -38,6 +38,22 @@ func (p *Plugin) Manifest() core.Manifest {
}
}
type redisGateConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
}
// DeclareConfig declares configuration bindings for driver_inproc_cron.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "redis", Target: &redisGateConfig{}},
}
}
// ConfigEnabled gates plugin activation when Redis is disabled.
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
return !view.Bool("redis.enabled", false)
}
// Apply registers the scheduler driver into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
p.mu.Lock()
@@ -98,6 +98,22 @@ func (p *Plugin) Manifest() core.Manifest {
}
}
type redisGateConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
}
// DeclareConfig declares the configuration bindings consumed by this plugin.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "redis", Target: &redisGateConfig{}},
}
}
// ConfigEnabled gates plugin activation when Redis is disabled.
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
return !view.Bool("redis.enabled", false)
}
// Apply registers the worker driver and provides contracts.TaskService.
func (p *Plugin) Apply(ctx *core.Context) error {
p.coreCtx = ctx
@@ -6,6 +6,7 @@ package driver_inproc_worker_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"Wavelet/pkg/idgen"
"Wavelet/plugins/drivers/driver_inproc_worker"
"context"
"sync/atomic"
@@ -17,6 +18,7 @@ import (
)
func TestInprocWorkerPlugin(t *testing.T) {
require.NoError(t, idgen.Init(1))
ctx := core.NewContext(context.Background())
p := driver_inproc_worker.New(
driver_inproc_worker.WithConcurrency(2),
+4
View File
@@ -32,6 +32,8 @@ func init() {
func TestHTTPDriverLifecycle(t *testing.T) {
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(nil))
require.NoError(t, ctx.Config().Resolve())
var globalMiddlewareCalled atomic.Bool
var groupMiddlewareCalled atomic.Bool
@@ -264,6 +266,8 @@ func TestMultipleDriversInContext(t *testing.T) {
defer mr.Close()
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(nil))
require.NoError(t, ctx.Config().Resolve())
httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0"))
workerPlugin := driver_asynq_worker.New(driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}))