From 08fac67f2a937ff279fca6fab8aff186e0394e25 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 13 Jul 2026 15:56:30 +0800 Subject: [PATCH] feat(startup): print service banner after listener ready --- internal/cmd/all.go | 4 +- internal/cmd/api.go | 4 +- internal/cmd/banner.go | 67 ++++++++++++++++++++++++++++++ internal/cmd/banner_test.go | 57 +++++++++++++++++++++++++ internal/cmd/root.go | 16 +++++-- internal/cmd/scheduler.go | 1 + internal/cmd/worker.go | 1 + internal/db/migrator/clickhouse.go | 20 ++++++++- internal/db/migrator/migrator.go | 24 ++++++++++- internal/router/router.go | 17 ++++++-- 10 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/banner.go create mode 100644 internal/cmd/banner_test.go diff --git a/internal/cmd/all.go b/internal/cmd/all.go index b29f7a93..06f35f76 100644 --- a/internal/cmd/all.go +++ b/internal/cmd/all.go @@ -31,7 +31,9 @@ var allCmd = &cobra.Command{ go func() { defer wg.Done() log.Println("[All] 启动 API 服务") - router.Serve() + router.Serve(func() { + printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) + }) }() // 启动 Asynq Worker 任务处理服务 diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 373721af..86604d08 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -16,6 +16,8 @@ var apiCmd = &cobra.Command{ Run: func(_ *cobra.Command, _ []string) { bootstrap.RegisterAPI() runBootstrap(bootstrap.Options{API: true}) - router.Serve() + router.Serve(func() { + printStartupBanner(startupState{mode: "API", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) + }) }, } diff --git a/internal/cmd/banner.go b/internal/cmd/banner.go new file mode 100644 index 00000000..dd9e45ad --- /dev/null +++ b/internal/cmd/banner.go @@ -0,0 +1,67 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "log" + "runtime" + "strings" + + "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db/migrator" +) + +type startupState struct { + mode string + relationalDB migrator.Report + clickHouseDB migrator.Report + listensForHTTP bool +} + +func printStartupBanner(state startupState) { + log.Print(formatStartupBanner(state)) +} + +func formatStartupBanner(state startupState) string { + lines := []string{ + "", + "__ __ _ _ ", + "\\ \\ / /_ ___ _____ | | ___| |_ ", + " \\ \\ /\\ / / _` \\ \\ / / _ \\ | |/ _ \\ __|", + " \\ V V / (_| |\\ V / __/ | | __/ |_ ", + " \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|", + fmt.Sprintf(" Wavelet %s", buildinfo.Version), + "", + fmt.Sprintf(" Environment: %s", config.Config.App.Env), + fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), + fmt.Sprintf(" Build time: %s", buildTime()), + fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)), + fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)), + } + if state.listensForHTTP { + lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr)) + } + lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "") + return strings.Join(lines, "\n") +} + +func buildTime() string { + if buildinfo.BuildTime == "" { + return "development build" + } + return buildinfo.BuildTime +} + +func formatMigration(report migrator.Report) string { + if !report.Enabled { + return "disabled" + } + state := "up to date" + if report.Applied { + state = "upgraded" + } + return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state) +} diff --git a/internal/cmd/banner_test.go b/internal/cmd/banner_test.go new file mode 100644 index 00000000..8703c51a --- /dev/null +++ b/internal/cmd/banner_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + "testing" + + "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db/migrator" +) + +func TestFormatStartupBanner(t *testing.T) { + previousVersion := buildinfo.Version + previousBuildTime := buildinfo.BuildTime + previousEnv := config.Config.App.Env + previousAddr := config.Config.App.Addr + t.Cleanup(func() { + buildinfo.Version = previousVersion + buildinfo.BuildTime = previousBuildTime + config.Config.App.Env = previousEnv + config.Config.App.Addr = previousAddr + }) + + buildinfo.Version = "v3.2.1" + buildinfo.BuildTime = "2026-07-13T08:00:00Z" + config.Config.App.Env = "production" + config.Config.App.Addr = ":3000" + + banner := formatStartupBanner(startupState{ + mode: "API", + relationalDB: migrator.Report{ + Backend: "PostgreSQL", + Enabled: true, + Version: 202607150003, + Applied: true, + }, + clickHouseDB: migrator.Report{Backend: "ClickHouse"}, + listensForHTTP: true, + }) + + for _, want := range []string{ + "Wavelet v3.2.1", + "Environment: production", + "Build time: 2026-07-13T08:00:00Z", + "Database: PostgreSQL (version 202607150003, upgraded)", + "Analytics: disabled", + "Listening: http://:3000", + "Mode: API", + } { + if !strings.Contains(banner, want) { + t.Errorf("banner missing %q:\n%s", want, banner) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c287cf84..c798d5af 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -39,8 +39,7 @@ var rootCmd = &cobra.Command{ }) }, PreRun: func(_ *cobra.Command, _ []string) { - migrator.Migrate() - migrator.MigrateClickHouse() + runMigrations() }, PersistentPostRun: func(_ *cobra.Command, _ []string) { shutdownTraceProvider() @@ -51,6 +50,16 @@ var rootCmd = &cobra.Command{ }, } +var latestMigrationState struct { + relationalDB migrator.Report + clickHouseDB migrator.Report +} + +func runMigrations() { + latestMigrationState.relationalDB = migrator.Migrate() + latestMigrationState.clickHouseDB = migrator.MigrateClickHouse() +} + func shutdownTraceProvider() { ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout) defer cancel() @@ -63,8 +72,7 @@ func init() { // 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为 migratePreRun := func(_ *cobra.Command, _ []string) { - migrator.Migrate() - migrator.MigrateClickHouse() + runMigrations() } allCmd.PreRun = migratePreRun apiCmd.PreRun = migratePreRun diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index f2715a4a..f3ad56c4 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -18,6 +18,7 @@ var schedulerCmd = &cobra.Command{ 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) diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index 196d17f7..65705a37 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -18,6 +18,7 @@ var workerCmd = &cobra.Command{ Short: "wavelet Worker", Run: func(_ *cobra.Command, _ []string) { runBootstrap(bootstrap.Options{}) + printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) log.Println("[Worker] 启动任务处理服务") if err := worker.StartWorker(); err != nil { log.Fatalf("[工作器] 启动失败: %v", err) diff --git a/internal/db/migrator/clickhouse.go b/internal/db/migrator/clickhouse.go index d301ba50..78fa8cf8 100644 --- a/internal/db/migrator/clickhouse.go +++ b/internal/db/migrator/clickhouse.go @@ -30,9 +30,9 @@ const ( var clickhouseMigrationFS embed.FS // MigrateClickHouse runs goose migrations against ClickHouse when enabled. -func MigrateClickHouse() { +func MigrateClickHouse() Report { if !config.Config.ClickHouse.Enabled { - return + return Report{Backend: "ClickHouse"} } cfg := config.Config.ClickHouse @@ -74,14 +74,30 @@ func MigrateClickHouse() { closeClickHouseDB(sqlDB) log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err) } + previousVersion, err := provider.GetDBVersion(context.Background()) + if err != nil { + closeClickHouseDB(sqlDB) + log.Fatalf("[ClickHouse] get goose version failed: %v\n", err) + } if _, err := provider.Up(context.Background()); err != nil { closeClickHouseDB(sqlDB) log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err) } + currentVersion, err := provider.GetDBVersion(context.Background()) + if err != nil { + closeClickHouseDB(sqlDB) + log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err) + } closeClickHouseDB(sqlDB) log.Println("[ClickHouse] goose migrate success") + return Report{ + Backend: "ClickHouse", + Enabled: true, + Version: currentVersion, + Applied: currentVersion != previousVersion, + } } func closeClickHouseDB(sqlDB *sql.DB) { diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 36476446..db55b268 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -35,6 +35,14 @@ const ( cascadeSuffix = " CASCADE" ) +// Report describes the database migration state observed during startup. +type Report struct { + Backend string + Enabled bool + Version int64 + Applied bool +} + func gooseDialect() string { if !config.Config.Database.Enabled { return dialectSqlite @@ -50,7 +58,7 @@ func migrationDir() string { } // Migrate 执行数据库迁移 -func Migrate() { +func Migrate() Report { gormDB := db.DB(context.Background()) if gormDB == nil { log.Fatalf("[%s] database not initialized\n", dbType()) @@ -65,13 +73,27 @@ func Migrate() { if err := goose.SetDialect(gooseDialect()); err != nil { log.Fatalf("[%s] set goose dialect failed: %v\n", dbType(), err) } + previousVersion, err := goose.GetDBVersion(sqlDB) + if err != nil { + log.Fatalf("[%s] get goose version failed: %v\n", dbType(), err) + } if err := goose.Up(sqlDB, migrationDir()); err != nil { log.Fatalf("[%s] goose migrate failed: %v\n", dbType(), err) } clearSystemConfigCache() + currentVersion, err := goose.GetDBVersion(sqlDB) + if err != nil { + log.Fatalf("[%s] get migrated goose version failed: %v\n", dbType(), err) + } log.Printf("[%s] goose migrate success\n", dbType()) + return Report{ + Backend: dbType(), + Enabled: true, + Version: currentVersion, + Applied: currentVersion != previousVersion, + } } func clearSystemConfigCache() { diff --git a/internal/router/router.go b/internal/router/router.go index 74ba75e3..1d8a09c4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,7 @@ import ( "context" "errors" "log" + "net" "net/http" "os" "os/signal" @@ -29,8 +30,8 @@ import ( "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) -// Serve 启动 HTTP API 服务 -func Serve() { +// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。 +func Serve(onStarted func()) { // 运行模式 if config.Config.App.IsProduction() { gin.SetMode(gin.ReleaseMode) @@ -83,9 +84,17 @@ func Serve() { 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() + } + go func() { - log.Printf("[API] server starting on %s\n", config.Config.App.Addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + 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) } }()