feat(startup): print service banner after listener ready

This commit is contained in:
ryan
2026-07-13 15:56:30 +08:00
parent 9cac25696a
commit 08fac67f2a
10 changed files with 198 additions and 13 deletions
+3 -1
View File
@@ -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 任务处理服务
+3 -1
View File
@@ -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})
})
},
}
+67
View File
@@ -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)
}
+57
View File
@@ -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)
}
}
}
+12 -4
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -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)
+18 -2
View File
@@ -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) {
+23 -1
View File
@@ -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() {
+13 -4
View File
@@ -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)
}
}()