feat(core): implement app profile lifecycle dispatcher and wire cli commands

This commit is contained in:
ryan
2026-08-28 00:02:30 +08:00
parent a4c3f70f0b
commit b6bfa120fc
16 changed files with 1471 additions and 98 deletions
+5 -50
View File
@@ -6,15 +6,9 @@
package cmd
import (
"context"
"log"
"sync"
gwrunner "github.com/Rain-kl/Wavelet/internal/apps/message_gateway/runner"
"github.com/Rain-kl/Wavelet/internal/infra/task/scheduler"
"github.com/Rain-kl/Wavelet/internal/infra/task/worker"
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/internal/router"
"github.com/Rain-kl/Wavelet/core"
"github.com/spf13/cobra"
)
@@ -22,48 +16,9 @@ var allCmd = &cobra.Command{
Use: "all",
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
Run: func(_ *cobra.Command, _ []string) {
log.Println("[All] 融合模式启动")
bootstrap.RegisterAll()
runBootstrap(bootstrap.Options{API: true})
var wg sync.WaitGroup
// 启动 API HTTP 服务
wg.Add(1)
go func() {
defer wg.Done()
log.Println("[All] 启动 API 服务")
router.Serve(func() {
printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true})
})
}()
go func() {
if err := gwrunner.Start(context.Background()); err != nil {
log.Printf("[All] message gateway stopped: %v", err)
}
}()
// 启动 Asynq Worker 任务处理服务
wg.Add(1)
go func() {
defer wg.Done()
log.Println("[All] 启动 Worker 服务")
if err := worker.StartWorker(); err != nil {
log.Printf("[All] Worker 启动失败: %v\n", err)
}
}()
// 启动 Asynq 定时任务调度器
wg.Add(1)
go func() {
defer wg.Done()
log.Println("[All] 启动 Scheduler 服务")
if err := scheduler.StartScheduler(); err != nil {
log.Printf("[All] Scheduler 启动失败: %v\n", err)
}
}()
wg.Wait()
app := newWaveletApp(core.ProfileAll)
if err := app.Run(); err != nil {
log.Fatalf("[All] run failed: %v\n", err)
}
},
}
+7 -7
View File
@@ -5,8 +5,9 @@
package cmd
import (
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/internal/router"
"log"
"github.com/Rain-kl/Wavelet/core"
"github.com/spf13/cobra"
)
@@ -14,10 +15,9 @@ var apiCmd = &cobra.Command{
Use: "api",
Short: "wavelet API",
Run: func(_ *cobra.Command, _ []string) {
bootstrap.RegisterAPI()
runBootstrap(bootstrap.Options{API: true})
router.Serve(func() {
printStartupBanner(startupState{mode: "API", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true})
})
app := newWaveletApp(core.ProfileAPI)
if err := app.Run(); err != nil {
log.Fatalf("[API] run failed: %v\n", err)
}
},
}
+308
View File
@@ -0,0 +1,308 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"sync"
"time"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/extpoints"
gwrunner "github.com/Rain-kl/Wavelet/internal/apps/message_gateway/runner"
"github.com/Rain-kl/Wavelet/internal/infra/config"
"github.com/Rain-kl/Wavelet/internal/infra/task/scheduler"
"github.com/Rain-kl/Wavelet/internal/infra/task/worker"
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/internal/router"
"github.com/Rain-kl/Wavelet/pkg/util"
"github.com/Rain-kl/Wavelet/plugins/infra/cache"
"github.com/Rain-kl/Wavelet/plugins/infra/database"
"github.com/Rain-kl/Wavelet/plugins/infra/logger"
"github.com/Rain-kl/Wavelet/plugins/infra/storage"
"github.com/hibiken/asynq"
)
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure and profile drivers.
//
//nolint:contextcheck
func newWaveletApp(profile core.Profile) *core.App {
app := core.NewApp(
core.WithProfile(profile),
core.WithShutdownTimeout(time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second),
)
// Register standard infrastructure plugins
app.Use(
database.New(),
cache.New(),
logger.New(),
storage.New(),
)
// Bind Goose migration runner
app.SetMigrationRunner(func(_ context.Context, _ []extpoints.MigrationEntry) error {
runMigrations()
return nil
})
// Mount drivers for each aspect
app.Use(
newWaveletHTTPDriver(profile),
newWaveletWorkerDriver(profile),
newWaveletSchedulerDriver(profile),
)
return app
}
type waveletHTTPDriver struct {
profile core.Profile
server *http.Server
mu sync.Mutex
running bool
}
func newWaveletHTTPDriver(profile core.Profile) *waveletHTTPDriver {
return &waveletHTTPDriver{profile: profile}
}
func (d *waveletHTTPDriver) Name() string {
return "driver_wavelet_http"
}
func (d *waveletHTTPDriver) Apply(ctx *core.Context) error {
return ctx.RegisterDriver(d)
}
func (d *waveletHTTPDriver) Type() core.DriverType {
return core.DriverTypeHTTP
}
//nolint:contextcheck
func (d *waveletHTTPDriver) Start(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.running {
return nil
}
bootstrap.RegisterAPI()
runBootstrap(bootstrap.Options{API: true})
engine, err := router.BuildEngine()
if err != nil {
return fmt.Errorf("[API] build router engine failed: %w", err)
}
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: engine,
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", config.Config.App.Addr)
if err != nil {
return fmt.Errorf("[API] listen on %s failed: %w", config.Config.App.Addr, err)
}
mode := "API"
if d.profile == core.ProfileAll {
mode = "API + Worker + Scheduler"
}
printStartupBanner(startupState{
mode: mode,
relationalDB: latestMigrationState.relationalDB,
clickHouseDB: latestMigrationState.clickHouseDB,
listensForHTTP: true,
})
d.server = srv
d.running = true
util.Go(func() {
log.Printf("[API] server listening on %s\n", config.Config.App.Addr)
if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", serveErr)
}
})
return nil
}
func (d *waveletHTTPDriver) Stop(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return nil
}
d.running = false
var err error
if d.server != nil {
err = d.server.Shutdown(ctx)
d.server = nil
}
bootstrap.Stop(ctx)
log.Println("[API] server exited")
return err
}
type waveletWorkerDriver struct {
profile core.Profile
server *asynq.Server
mu sync.Mutex
running bool
}
func newWaveletWorkerDriver(profile core.Profile) *waveletWorkerDriver {
return &waveletWorkerDriver{profile: profile}
}
func (d *waveletWorkerDriver) Name() string {
return "driver_wavelet_worker"
}
func (d *waveletWorkerDriver) Apply(ctx *core.Context) error {
return ctx.RegisterDriver(d)
}
func (d *waveletWorkerDriver) Type() core.DriverType {
return core.DriverTypeWorker
}
//nolint:contextcheck
func (d *waveletWorkerDriver) Start(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.running {
return nil
}
if d.profile == core.ProfileAll {
bootstrap.RegisterAll()
} else {
bootstrap.RegisterWorker()
}
runBootstrap(bootstrap.Options{})
if d.profile == core.ProfileWorker {
printStartupBanner(startupState{
mode: "Worker",
relationalDB: latestMigrationState.relationalDB,
clickHouseDB: latestMigrationState.clickHouseDB,
})
}
util.Go(func() {
if err := gwrunner.Start(context.Background()); err != nil {
log.Printf("[Worker] message gateway stopped: %v", err)
}
})
log.Println("[Worker] 启动任务处理服务")
srv, err := worker.StartWorkerServer()
if err != nil {
return fmt.Errorf("[Worker] 启动失败: %w", err)
}
d.server = srv
d.running = true
return nil
}
func (d *waveletWorkerDriver) Stop(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return nil
}
d.running = false
if d.server != nil {
d.server.Stop()
d.server.Shutdown()
d.server = nil
}
log.Println("[Worker] 任务处理服务已退出")
return nil
}
type waveletSchedulerDriver struct {
profile core.Profile
mu sync.Mutex
running bool
}
func newWaveletSchedulerDriver(profile core.Profile) *waveletSchedulerDriver {
return &waveletSchedulerDriver{profile: profile}
}
func (d *waveletSchedulerDriver) Name() string {
return "driver_wavelet_scheduler"
}
func (d *waveletSchedulerDriver) Apply(ctx *core.Context) error {
return ctx.RegisterDriver(d)
}
func (d *waveletSchedulerDriver) Type() core.DriverType {
return core.DriverTypeScheduler
}
//nolint:contextcheck
func (d *waveletSchedulerDriver) Start(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.running {
return nil
}
if d.profile == core.ProfileAll {
bootstrap.RegisterAll()
} else {
bootstrap.RegisterScheduler()
}
runBootstrap(bootstrap.Options{})
if d.profile == core.ProfileSchedule {
printStartupBanner(startupState{
mode: "Scheduler",
relationalDB: latestMigrationState.relationalDB,
clickHouseDB: latestMigrationState.clickHouseDB,
})
}
log.Println("[Scheduler] 启动定时任务调度服务")
if err := scheduler.ReloadScheduler(); err != nil {
return fmt.Errorf("[Scheduler] 启动失败: %w", err)
}
d.running = true
return nil
}
func (d *waveletSchedulerDriver) Stop(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return nil
}
d.running = false
scheduler.StopScheduler()
log.Println("[Scheduler] 定时任务调度服务已退出")
return nil
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Rain-kl/Wavelet/core"
)
func TestNewWaveletAppProfiles(t *testing.T) {
profiles := []core.Profile{
core.ProfileAPI,
core.ProfileWorker,
core.ProfileSchedule,
core.ProfileAll,
}
for _, prof := range profiles {
t.Run(string(prof), func(t *testing.T) {
app := newWaveletApp(prof)
require.NotNil(t, app)
assert.Equal(t, prof, app.Profile())
// Verify 4 infra plugins + 3 driver plugins registered
plugins := app.Plugins()
assert.Len(t, plugins, 7)
// Verify each standard infra plugin is registered
_, ok := app.Plugin("database")
assert.True(t, ok, "database plugin missing")
_, ok = app.Plugin("cache")
assert.True(t, ok, "cache plugin missing")
_, ok = app.Plugin("logger")
assert.True(t, ok, "logger plugin missing")
_, ok = app.Plugin("storage")
assert.True(t, ok, "storage plugin missing")
// Verify driver plugins
_, ok = app.Plugin("driver_wavelet_http")
assert.True(t, ok, "http driver missing")
_, ok = app.Plugin("driver_wavelet_worker")
assert.True(t, ok, "worker driver missing")
_, ok = app.Plugin("driver_wavelet_scheduler")
assert.True(t, ok, "scheduler driver missing")
})
}
}
+4 -8
View File
@@ -7,9 +7,7 @@ package cmd
import (
"log"
"github.com/Rain-kl/Wavelet/internal/infra/task/scheduler"
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/core"
"github.com/spf13/cobra"
)
@@ -17,11 +15,9 @@ var schedulerCmd = &cobra.Command{
Use: "scheduler",
Short: "wavelet Scheduler",
Run: func(_ *cobra.Command, _ []string) {
runBootstrap(bootstrap.Options{})
printStartupBanner(startupState{mode: "Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB})
log.Println("[Scheduler] 启动定时任务调度服务")
if err := scheduler.StartScheduler(); err != nil {
log.Fatalf("[调度器] 启动失败: %v", err)
app := newWaveletApp(core.ProfileSchedule)
if err := app.Run(); err != nil {
log.Fatalf("[Scheduler] run failed: %v\n", err)
}
},
}
+4 -15
View File
@@ -5,13 +5,9 @@
package cmd
import (
"context"
"log"
gwrunner "github.com/Rain-kl/Wavelet/internal/apps/message_gateway/runner"
"github.com/Rain-kl/Wavelet/internal/infra/task/worker"
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/core"
"github.com/spf13/cobra"
)
@@ -19,16 +15,9 @@ var workerCmd = &cobra.Command{
Use: "worker",
Short: "wavelet Worker",
Run: func(_ *cobra.Command, _ []string) {
runBootstrap(bootstrap.Options{})
printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB})
go func() {
if err := gwrunner.Start(context.Background()); err != nil {
log.Printf("[Worker] message gateway stopped: %v", err)
}
}()
log.Println("[Worker] 启动任务处理服务")
if err := worker.StartWorker(); err != nil {
log.Fatalf("[工作器] 启动失败: %v", err)
app := newWaveletApp(core.ProfileWorker)
if err := app.Run(); err != nil {
log.Fatalf("[Worker] run failed: %v\n", err)
}
},
}
+29 -3
View File
@@ -24,7 +24,7 @@ func StartWorker() error {
asynq.Config{
Concurrency: config.Config.Worker.Concurrency,
ShutdownTimeout: workerShutdownTimeout,
Queues: buildQueuesFromConfig(),
Queues: BuildQueuesFromConfig(),
StrictPriority: config.Config.Worker.StrictPriority,
},
)
@@ -44,8 +44,34 @@ func StartWorker() error {
return asynqServer.Run(mux)
}
// buildQueuesFromConfig 从配置构建队列映射
func buildQueuesFromConfig() map[string]int {
// StartWorkerServer 异步启动 Asynq 工作器服务并返回 Server 实例以支持平滑停机
func StartWorkerServer() (*asynq.Server, error) {
bootstrap.RegisterWorker()
asynqServer := asynq.NewServer(
task.RedisOpt,
asynq.Config{
Concurrency: config.Config.Worker.Concurrency,
ShutdownTimeout: workerShutdownTimeout,
Queues: BuildQueuesFromConfig(),
StrictPriority: config.Config.Worker.StrictPriority,
},
)
mux := asynq.NewServeMux()
mux.Use(taskLoggingMiddleware)
for _, taskName := range task.GetRegisteredAsynqTasks() {
mux.HandleFunc(taskName, task.ProcessTask)
}
if err := asynqServer.Start(mux); err != nil {
return nil, err
}
return asynqServer, nil
}
// BuildQueuesFromConfig 从配置构建队列映射
func BuildQueuesFromConfig() map[string]int {
queues := make(map[string]int)
// 从配置读取队列
+12 -3
View File
@@ -31,8 +31,8 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。
func Serve(onStarted func()) {
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
func BuildEngine() (*gin.Engine, error) {
// 运行模式
if config.Config.App.IsProduction() {
gin.SetMode(gin.ReleaseMode)
@@ -60,7 +60,7 @@ func Serve(onStarted func()) {
[]byte(config.Config.App.SessionSecret),
)
if err != nil {
log.Fatalf("[API] init session store failed: %v\n", err)
return nil, err
}
// 设置 Session Redis Key 前缀
@@ -78,6 +78,15 @@ func Serve(onStarted func()) {
r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware(), risk_control.RiskControlMiddleware())
registerRoutes(r)
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,