Files
xlgo-core/app.go
T
2026-07-08 19:11:37 +08:00

1060 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package xlgo
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/cron"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/EthanCodeCraft/xlgo-core/storage"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo version)、脚手架生成的 go.mod 等均引用此常量。
const Version = "1.2.0"
// HealthCheckFunc 健康检查函数
type HealthCheckFunc func(context.Context) error
// Migrator 数据库迁移函数
type Migrator func(*gorm.DB) error
// Hook 生命周期钩子。各回调在 App 生命周期的对应阶段被调用:
// - OnInit: Init() 内组件初始化完成后、路由注册前
// - OnStart: StartServer() 监听端口前
// - OnReady: 端口就绪后(已开始接受连接)
// - OnStop: Shutdown() 开头,关 HTTP 之前
//
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
type Hook struct {
Name string
OnInit func(*App) error
OnStart func(*App) error
OnReady func(*App) error
OnStop func(*App) error
}
type staticRoute struct {
relativePath string
root string
}
// appState 是 App 生命周期状态机(M1)。状态受 lifecycleMu 保护,单调推进:
//
// stateCreated → stateInitializing → stateInitialized → stateStopping → stateStopped
// ↓ (doInit 失败)
// stateStopping → stateStoppedfailAfterInit 回滚)
//
// Go() 仅在 state < stateStopping 时放行 wg.AddInit() 在 state >= stateStopping 时拒绝。
type appState int32
const (
stateCreated appState = iota
stateInitializing
stateInitialized
stateStopping
stateStopped
)
// ErrAppClosed 表示 App 已 Shutdown 或 Init 失败已回滚,拒绝再 Init(M1)。
var ErrAppClosed = errors.New("xlgo: app already shut down")
// App 应用实例
type App struct {
config *config.Config
configPath string
configManager *config.Manager
router *gin.Engine
registry *router.Registry
server *http.Server
enableLogger bool
enableMySQL bool
enableRedis bool
enableStorage bool
enableHealth bool
enableSwagger bool
enableAutoMigrate bool
enableLiveness bool
enableReadiness bool
enableMetrics bool
enableCron bool
metricsPath string
staticRoutes []staticRoute
migrators []Migrator
healthChecks []router.HealthCheck
hooks []Hook
// 生命周期状态机(M1:取代裸 initOnce,解决 Init/Shutdown 并发改资源、
// Init-after-Shutdown、App.Go 与 wg.Wait 的 WaitGroup 契约三类缺陷)。
// state 受 lifecycleMu 保护;initMu 串行化 doInit/doShutdown 长段,避免并发改资源。
// Go() 持 lifecycleMu.RLock 协调 wg.Add 与 Shutdown 的 wg.WaitAdd happens-before Wait)。
state appState
lifecycleMu sync.RWMutex
initMu sync.Mutex
initErr error
// shutdownOnce 确保 doShutdown 单次执行;shutdownErr 缓存结果供并发调用者共享。
shutdownOnce sync.Once
shutdownErr error
initializedLogger bool
initializedMySQL bool
initializedRedis bool
initializedCron bool
loggerManager *logger.LogManager
loggerSnapshot *logger.DefaultSnapshot
dbManager *database.Manager
previousDB *database.Manager
redisManager *database.RedisManager
previousRedis *database.RedisManager
// 请求级超时(#19),<=0 表示不启用
requestTimeout time.Duration
// in-flight goroutine 管理(#22
rootCtx context.Context // 根 ctxApp.Go 启动的 goroutine 共享
cancel context.CancelFunc // Shutdown 时 cancel,通知后台任务退出
wg sync.WaitGroup // 跟踪 App.Go 启动的 goroutine
}
// Option 应用选项
type Option func(*App)
// WithConfigPath 设置配置文件路径
func WithConfigPath(path string) Option {
return func(a *App) {
a.configPath = path
a.configManager = config.NewManager(path)
}
}
// WithConfig 设置配置对象。
// A3 修复:不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。
func WithConfig(cfg *config.Config) Option {
return func(a *App) {
a.config = cfg
}
}
// WithLogger 启用日志
func WithLogger() Option {
return func(a *App) { a.enableLogger = true }
}
// WithMySQL 启用 MySQL
func WithMySQL() Option {
return func(a *App) { a.enableMySQL = true }
}
// WithRedis 启用 Redis
func WithRedis() Option {
return func(a *App) { a.enableRedis = true }
}
// WithStorage 启用文件存储
func WithStorage() Option {
return func(a *App) { a.enableStorage = true }
}
// WithHealthRoutes 启用健康检查路由
func WithHealthRoutes() Option {
return func(a *App) { a.enableHealth = true }
}
// WithSwaggerRoutes 启用 Swagger 路由
func WithSwaggerRoutes() Option {
return func(a *App) { a.enableSwagger = true }
}
// WithDefaultRoutes 启用默认路由(健康检查、Swagger)
func WithDefaultRoutes() Option {
return func(a *App) {
a.enableHealth = true
a.enableSwagger = true
}
}
// WithLivenessRoute 启用存活性探针路由 GET /livez(#17)。
// 永不依赖外部,始终 200,供 K8s livenessProbe。
func WithLivenessRoute() Option {
return func(a *App) { a.enableLiveness = true }
}
// WithReadinessRoute 启用就绪性探针路由 GET /readyz#17)。
// 复用 healthChecks 检查依赖,失败返回 503,供 K8s readinessProbe。
func WithReadinessRoute() Option {
return func(a *App) { a.enableReadiness = true }
}
// WithMetricsRoute 启用 Prometheus 指标端点与采集中间件(#18)。
// path 默认 /metrics,传入可自定义。
func WithMetricsRoute(path ...string) Option {
return func(a *App) {
a.enableMetrics = true
if len(path) > 0 && path[0] != "" {
a.metricsPath = path[0]
}
}
}
// WithoutLogger 关闭日志。
//
// Without* 系列的定位:xlgo.New() 默认是轻量应用(组件全关),故 Without*
// 主要用于 NewFullStack / RunFullStack 启用全部组件后,排除个别不需要的项。
// 例如:xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes()) 全组件但关 Swagger。
func WithoutLogger() Option {
return func(a *App) { a.enableLogger = false }
}
// WithoutMySQL 关闭 MySQL
func WithoutMySQL() Option {
return func(a *App) { a.enableMySQL = false }
}
// WithoutRedis 关闭 Redis
func WithoutRedis() Option {
return func(a *App) { a.enableRedis = false }
}
// WithoutStorage 关闭文件存储
func WithoutStorage() Option {
return func(a *App) { a.enableStorage = false }
}
// WithoutWire 已移除(wire 包在 v1.1.0 删除)。保留空函数仅为编译兼容,
// 调用无副作用。后续版本将删除。
func WithoutWire() Option {
return func(a *App) {}
}
// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑)
func WithAutoMigrate() Option {
return func(a *App) { a.enableAutoMigrate = true }
}
// WithoutAutoMigrate 关闭数据库迁移(即使已通过 WithMigrator/WithModels 注册)
func WithoutAutoMigrate() Option {
return func(a *App) { a.enableAutoMigrate = false }
}
// WithoutHealthRoutes 关闭健康检查路由
func WithoutHealthRoutes() Option {
return func(a *App) { a.enableHealth = false }
}
// WithoutSwaggerRoutes 关闭 Swagger 路由
func WithoutSwaggerRoutes() Option {
return func(a *App) { a.enableSwagger = false }
}
// WithoutDefaultRoutes 关闭默认路由(健康检查、Swagger)
func WithoutDefaultRoutes() Option {
return func(a *App) {
a.enableHealth = false
a.enableSwagger = false
}
}
// WithStatic 注册静态文件服务
func WithStatic(relativePath, root string) Option {
return func(a *App) {
a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: relativePath, root: root})
}
}
// WithPublicStatic 注册默认 public 静态文件服务
func WithPublicStatic() Option {
return WithStatic("/public", "./public")
}
// WithHealthCheck 注册健康检查项
func WithHealthCheck(name string, check HealthCheckFunc) Option {
return func(a *App) {
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: name, Check: check})
}
}
// WithMigrator 注册数据库迁移函数(自动启用 AutoMigrate
func WithMigrator(m Migrator) Option {
return func(a *App) {
if m != nil {
a.migrators = append(a.migrators, m)
a.enableAutoMigrate = true
}
}
}
// WithHook 注册生命周期钩子(#12)。可多次调用注册多个,按注册顺序触发。
// 详见 Hook 类型注释。
func WithHook(h Hook) Option {
return func(a *App) {
a.hooks = append(a.hooks, h)
}
}
// WithRequestTimeout 设置请求级超时(#19)。下游 GORM/Redis 走
// c.Request.Context() 即可级联取消。d <= 0 不启用。
func WithRequestTimeout(d time.Duration) Option {
return func(a *App) { a.requestTimeout = d }
}
// WithCron 将全局 cron 调度器纳入 App 生命周期(P1 #10)。
// 启用后:Init 末尾 cron.Start() 启动调度循环;Shutdown 时在 ShutdownTimeout 约束内
// 停止全局调度器并等待在跑任务退出(cron.StopGlobalWithTimeout),避免调度 goroutine
// 与在跑任务在优雅关闭后残留。任务仍通过 cron.AddTask(...) 在 Run 前注册。
func WithCron() Option {
return func(a *App) { a.enableCron = true }
}
// WithModels 注册 GORM 自动迁移模型(自动启用 AutoMigrate
func WithModels(models ...any) Option {
return WithMigrator(func(db *gorm.DB) error {
return db.AutoMigrate(models...)
})
}
// WithModules 注册模块
func WithModules(modules ...router.Module) Option {
return func(a *App) {
for _, m := range modules {
a.registry.RegisterModule(m)
}
}
}
// WithVersions 注册版本化 API
func WithVersions(versions ...*router.VersionedAPI) Option {
return func(a *App) {
for _, v := range versions {
a.registry.RegisterVersion(v)
}
}
}
// WithMiddlewares 注册全局中间件
func WithMiddlewares(middlewares ...gin.HandlerFunc) Option {
return func(a *App) {
a.registry.Use(middlewares...)
}
}
// WithFullStack 启用全功能默认组件
func WithFullStack() Option {
return func(a *App) {
a.enableLogger = true
a.enableMySQL = true
a.enableRedis = true
a.enableStorage = true
a.enableHealth = true
a.enableSwagger = true
a.enableAutoMigrate = true
// 生产就绪路由(#17/#18
a.enableLiveness = true
a.enableReadiness = true
a.enableMetrics = true
a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: "/public", root: "./public"})
}
}
// New 创建新应用
func New(opts ...Option) *App {
app := &App{}
app.router = gin.New()
app.registry = router.NewRegistry(app.router)
// rootCtx 生命周期与 App 一致,不依赖 Init,使 App.Go 在 Init 前也可用(#22
app.rootCtx, app.cancel = context.WithCancel(context.Background())
for _, opt := range opts {
opt(app)
}
return app
}
// NewFullStack 创建启用默认全功能组件的应用
func NewFullStack(opts ...Option) *App {
all := append([]Option{WithFullStack()}, opts...)
return New(all...)
}
// RunFullStack 创建并启动启用默认全功能组件的应用
func RunFullStack(opts ...Option) error {
return NewFullStack(opts...).Run()
}
// Init 初始化应用,不启动 HTTP 监听(M1:状态机 + initMu 保证单次执行与并发安全)。
// 多次调用:已成功则返回缓存结果;已 Shutdown/Init 失败则返回 ErrAppClosed。
//
// initMu 串行化 doInit 与 doShutdown(避免并发改资源);lifecycleMu 仅短暂持有以翻状态,
// 不阻塞 Go()doInit 内 a.Go 只抢 lifecycleMu.RLock)。
//
// 注意:生命周期 hookOnInit/OnStart/OnReady/OnStop)不允许重入调用 Init/Shutdown/Run——
// initMu 非重入,hook 内调用会自锁死锁(M1 文档约束)。
func (a *App) Init() error {
a.initMu.Lock()
defer a.initMu.Unlock()
a.lifecycleMu.Lock()
switch a.state {
case stateCreated:
a.state = stateInitializing
a.lifecycleMu.Unlock()
case stateInitialized:
err := a.initErr
a.lifecycleMu.Unlock()
return err
case stateStopping, stateStopped:
a.lifecycleMu.Unlock()
return ErrAppClosed
default: // stateInitializing — initMu 串行下不可达,防御性返回
err := a.initErr
a.lifecycleMu.Unlock()
return err
}
a.initErr = a.doInit()
if a.initErr != nil {
a.failAfterInit()
return a.initErr
}
a.lifecycleMu.Lock()
a.state = stateInitialized
a.lifecycleMu.Unlock()
return nil
}
// failAfterInit 在 doInit 失败时执行资源回滚(M1)。
// 顺序:标记 stateStopping(阻新 Go)→ cancel rootCtx → wg.Wait(让 a.Go 启动的探活等
// goroutine 先退出)→ closeResources。先停 goroutine 再关资源,避免“关 DB 时探活 goroutine
// 仍在用”的竞态。回滚错误与原 initErr 合并上抛,不吞。
func (a *App) failAfterInit() {
a.lifecycleMu.Lock()
a.state = stateStopping
a.lifecycleMu.Unlock()
// H-2cancel rootCtx 让 a.Go 启动的后台 goroutine 退出,不泄漏。
if a.cancel != nil {
a.cancel()
}
waitDone := make(chan struct{})
go func() { a.wg.Wait(); close(waitDone) }()
select {
case <-waitDone:
case <-time.After(10 * time.Second):
logger.Warnf("Init 失败后等待后台 goroutine 退出超时(10s),可能存在未响应 ctx.Done 的 goroutine")
}
// M1:回滚 doInit 已初始化的资源。cron 显式 5s 超时不卡死回滚;
// logger/db/redis 恢复 Init 前默认 manager,再关闭本 App 新建的 manager。
if err := a.stopCron(5 * time.Second); err != nil {
a.initErr = errors.Join(a.initErr, err)
}
if rbErr := a.rollbackReplacedResources(); rbErr != nil {
a.initErr = errors.Join(a.initErr, fmt.Errorf("回滚已初始化资源失败: %w", rbErr))
}
a.lifecycleMu.Lock()
a.state = stateStopped
a.lifecycleMu.Unlock()
}
// closeResources 关闭 db→redis→loggerM1)。各均为幂等 no-op(未 init 时安全),
// 供 failAfterInit 与 doShutdown 复用。cron 因停止预算随调用方不同(Init 失败 5s /
// Shutdown 剩余预算),由各调用方单独停止,不在此处。返回聚合错误。
func (a *App) closeResources() error {
var errs []error
if a.initializedMySQL {
if a.dbManager != nil {
if err := a.dbManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := database.CloseAll(); err != nil {
errs = append(errs, err)
}
a.initializedMySQL = false
a.dbManager = nil
}
if a.initializedRedis {
if a.redisManager != nil {
if err := a.redisManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := database.CloseRedis(); err != nil {
errs = append(errs, err)
}
a.initializedRedis = false
a.redisManager = nil
}
if a.initializedLogger {
if a.loggerManager != nil {
if err := a.loggerManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := logger.Close(); err != nil {
errs = append(errs, err)
}
a.initializedLogger = false
a.loggerManager = nil
}
return errors.Join(errs...)
}
func (a *App) rollbackReplacedResources() error {
var errs []error
if a.initializedMySQL {
if a.previousDB != nil {
database.SwapDefaultManager(a.previousDB)
}
if a.dbManager != nil {
if err := a.dbManager.Close(); err != nil {
errs = append(errs, err)
}
}
a.dbManager = nil
a.previousDB = nil
a.initializedMySQL = false
}
if a.initializedRedis {
if a.previousRedis != nil {
database.SwapDefaultRedisManager(a.previousRedis)
}
if a.redisManager != nil {
if err := a.redisManager.Close(); err != nil {
errs = append(errs, err)
}
}
a.redisManager = nil
a.previousRedis = nil
a.initializedRedis = false
}
if a.initializedLogger {
if err := logger.RestoreDefaultSnapshot(a.loggerSnapshot); err != nil {
errs = append(errs, err)
}
a.loggerManager = nil
a.loggerSnapshot = nil
a.initializedLogger = false
}
return errors.Join(errs...)
}
func (a *App) commitReplacedResources() {
if a.previousDB != nil {
if err := a.previousDB.Close(); err != nil {
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
}
a.previousDB = nil
}
if a.previousRedis != nil {
if err := a.previousRedis.Close(); err != nil {
logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err)
}
a.previousRedis = nil
}
if a.loggerSnapshot != nil {
if err := logger.CloseDefaultSnapshot(a.loggerSnapshot); err != nil {
logger.Warnf("关闭被替换的旧 logger writer 失败: %v", err)
}
a.loggerSnapshot = nil
}
}
func (a *App) stopCron(timeout time.Duration) error {
if !a.initializedCron {
return nil
}
a.initializedCron = false
if !cron.StopGlobalWithTimeout(timeout) {
return fmt.Errorf("cron 调度器停止超时(%s", timeout)
}
return nil
}
func (a *App) shutdownAfterStartFailure(startErr error) error {
if startErr == nil {
return nil
}
if shutErr := a.Shutdown(); shutErr != nil {
return fmt.Errorf("%w (关闭错误: %v)", startErr, shutErr)
}
return startErr
}
// doInit 执行实际初始化流程。仅在 Init 持有 initMu 并完成状态切换后调用,不可直接调用。
func (a *App) doInit() error {
cfg, err := a.resolveConfig()
if err != nil {
return err
}
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
if a.enableLogger {
lm := logger.NewLogManager()
snapshot := logger.SnapshotDefault()
if err := lm.InitPreservingPrevious(cfg); err != nil {
return fmt.Errorf("初始化日志失败: %w", err)
}
logger.SetDefaultLogManager(lm)
a.loggerManager = lm
a.loggerSnapshot = snapshot
a.initializedLogger = true
}
if a.enableMySQL {
dbm := database.NewManager(cfg)
if err := dbm.InitDB(cfg); err != nil {
return fmt.Errorf("初始化数据库失败: %w", err)
}
a.previousDB = database.SwapDefaultManager(dbm)
a.dbManager = dbm
a.initializedMySQL = true
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(ctx context.Context) error {
// 优先读探活缓存标记(#21),避免每次探针都同步 ping
if !dbm.IsHealthy() {
return errors.New("mysql 主库探活不健康")
}
return dbm.HealthCheck(ctx)
}})
}
if a.enableRedis {
rm := database.NewRedisManager()
if err := rm.Init(cfg); err != nil {
return fmt.Errorf("初始化 Redis 失败: %w", err)
}
a.previousRedis = database.SwapDefaultRedisManager(rm)
a.redisManager = rm
a.initializedRedis = true
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "redis", Check: rm.HealthCheck})
}
if a.enableStorage {
if err := storage.Init(&cfg.Storage); err != nil {
return fmt.Errorf("初始化存储失败: %w", err)
}
}
if a.enableRedis {
// Redis 就绪后初始化缓存(cache 依赖 Redis 客户端)
cache.Init()
}
if a.enableAutoMigrate && len(a.migrators) > 0 {
if !a.enableMySQL {
return errors.New("注册了数据库迁移但未启用 MySQL")
}
db := database.GetDB()
if db == nil {
return errors.New("MySQL 未初始化,无法执行数据库迁移")
}
for _, migrator := range a.migrators {
if err := migrator(db); err != nil {
return fmt.Errorf("数据库迁移失败: %w", err)
}
}
}
// 全局中间件链:RequestID 必须最先装入,保证后续 Recovery/日志/响应都能拿到 request_id#24
a.router.Use(middleware.RequestID())
a.router.Use(middleware.Recover())
// 请求级超时(#19),配置后装入,下游走 c.Request.Context() 级联取消
if a.requestTimeout > 0 {
a.router.Use(middleware.Timeout(a.requestTimeout))
}
for _, staticRoute := range a.staticRoutes {
a.router.Static(staticRoute.relativePath, staticRoute.root)
}
if a.enableSwagger {
router.RegisterSwaggerRoutes(a.router)
}
if a.enableHealth {
router.RegisterHealthRoute(a.router, a.healthChecks...)
}
if a.enableLiveness {
router.RegisterLivenessRoute(a.router)
}
if a.enableReadiness {
router.RegisterReadinessRoute(a.router, a.healthChecks...)
}
if a.enableMetrics {
// 采集中间件交给注册中心,Apply 时作为首个全局中间件装入(H8c),
// 覆盖所有经注册中心注册的业务路由,不依赖调用顺序。
a.registry.SetMetricsMiddleware(middleware.Metrics())
if a.metricsPath != "" {
router.RegisterMetricsRoute(a.router, a.metricsPath)
} else {
router.RegisterMetricsRoute(a.router)
}
}
a.registry.Apply()
// 响应模式:默认 business(全 200 + 业务码),可配置 rest(按错误码映射 HTTP status)(#15
if mode := strings.TrimSpace(strings.ToLower(a.config.Server.ResponseMode)); mode != "" {
switch mode {
case "rest":
response.SetMode(response.ModeREST)
case "business":
response.SetMode(response.ModeBusiness)
}
}
// 错误 Detail 暴露策略(P1 #15):仅开发环境向客户端输出 Error.Detail
// 生产环境隐藏,避免内部错误细节(SQL/堆栈等)经 Detail 泄露给客户端。
response.SetExposeDetail(cfg.IsDevelopment())
// in-flight goroutine 根 ctx 在 New() 时已初始化(#22
// 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消
if a.enableMySQL {
a.Go(a.dbManager.StartProbing)
}
// 启动 cron 全局调度器(P1 #10),Shutdown 时统一停止。任务须在此前经 cron.AddTask 注册。
if a.enableCron {
cron.Start()
a.initializedCron = true
}
// OnInit hooks:组件初始化完成后触发(#12)
for _, h := range a.hooks {
if h.OnInit != nil {
if err := h.OnInit(a); err != nil {
return fmt.Errorf("OnInit hook %q 失败: %w", h.Name, err)
}
}
}
a.commitReplacedResources()
return nil
}
func (a *App) resolveConfig() (*config.Config, error) {
if a.config != nil {
return a.config, nil
}
if a.configManager == nil && a.configPath != "" {
a.configManager = config.NewManager(a.configPath)
}
if a.configManager != nil {
cfg, err := a.configManager.Load()
if err != nil {
return nil, err
}
// 让 App 持有的 manager 成为全局默认,
// 使 config.Get / config.GetString 等便捷函数仍能取到正确配置。
config.SetDefaultManager(a.configManager)
a.config = cfg
return cfg, nil
}
cfg := config.Get()
if cfg == nil {
return nil, config.ErrConfigNotLoaded
}
a.config = cfg
return cfg, nil
}
// Run 启动应用
func (a *App) Run() error {
if err := a.Init(); err != nil {
return err
}
return a.shutdownAfterStartFailure(a.StartServer())
}
// StartServer 启动 HTTP 服务器(支持优雅关闭)
func (a *App) StartServer() error {
if a.config == nil {
return config.ErrConfigNotLoaded
}
srvCfg := a.config.Server
a.server = &http.Server{
Handler: a.router,
ReadTimeout: srvCfg.EffectiveReadTimeout(),
WriteTimeout: srvCfg.EffectiveWriteTimeout(),
IdleTimeout: srvCfg.EffectiveIdleTimeout(),
MaxHeaderBytes: srvCfg.EffectiveMaxHeaderBytes(),
}
useUnix := strings.TrimSpace(srvCfg.UnixSocket) != ""
if useUnix {
a.server.Addr = srvCfg.UnixSocket
} else {
// Host 为空时监听所有接口(":port");设值时绑定指定地址("host:port"),
// 用于仅本机(127.0.0.1)或绑定内网网卡的场景
a.server.Addr = fmt.Sprintf("%s:%d", srvCfg.Host, srvCfg.Port)
}
// OnStart hooks:监听端口前
for _, h := range a.hooks {
if h.OnStart != nil {
if err := h.OnStart(a); err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err))
}
}
}
network := "tcp"
address := a.server.Addr
if useUnix {
network = "unix"
address = srvCfg.UnixSocket
}
ln, err := net.Listen(network, address)
if err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("服务器监听失败: %w", err))
}
if srvCfg.TLS.Enabled {
cert, err := tls.LoadX509KeyPair(srvCfg.TLS.CertFile, srvCfg.TLS.KeyFile)
if err != nil {
_ = ln.Close()
return a.shutdownAfterStartFailure(fmt.Errorf("服务器 TLS 配置无效: %w", err))
}
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
}
serverErr := make(chan error, 1)
serveStarted := make(chan struct{})
go func() {
close(serveStarted)
if useUnix {
logger.Infof("服务器启动,监听 unix socket %s", srvCfg.UnixSocket)
} else if srvCfg.Host != "" {
logger.Infof("服务器启动,监听 %s:%d", srvCfg.Host, srvCfg.Port)
} else {
logger.Infof("服务器启动,监听端口 %d(所有接口)", srvCfg.Port)
}
serveErr := a.server.Serve(ln)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
serverErr <- serveErr
return
}
serverErr <- nil
}()
<-serveStarted
select {
case err := <-serverErr:
if err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err))
}
return nil
default:
}
// OnReady hookslistener 已成功创建,Serve goroutine 已启动(M1)。
for _, h := range a.hooks {
if h.OnReady != nil {
if err := h.OnReady(a); err != nil {
logger.Errorf("OnReady hook %q 失败: %v", h.Name, err)
// H-1 修复:失败时走 Shutdown 释放 HTTP 端口、后台 goroutine 与已初始化资源,
// 避免监听 goroutine 永久阻塞在 Serve 致端口/goroutine 泄漏。
// 不再向 serverErr 写入——监听 goroutine 在 Shutdown 后会写 nil 到 serverErrcap=1),
// 若此处也写则第二次写阻塞致 goroutine 泄漏。
if shutErr := a.Shutdown(); shutErr != nil {
return fmt.Errorf("OnReady hook %q 失败: %w (关闭错误: %v)", h.Name, err, shutErr)
}
_ = ln.Close()
return fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err)
}
}
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(quit)
select {
case err := <-serverErr:
if err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err))
}
return nil
case sig := <-quit:
logger.Infof("收到信号 %v,开始优雅关闭...", sig)
return a.Shutdown()
}
}
// Shutdown 优雅关闭应用(M1:幂等 + 与 Init 互斥)。
// shutdownOnce 保证 doShutdown 单次执行,并发调用者阻塞至首个完成后返回同一 shutdownErr。
// initMu 串行化 doShutdown 与 doInit,避免并发改资源。
//
// 注意:生命周期 hook 不允许重入调用 Init/Shutdown/Run——initMu 非重入,hook 内调用会自锁。
func (a *App) Shutdown() error {
a.shutdownOnce.Do(func() {
a.initMu.Lock()
defer a.initMu.Unlock()
a.lifecycleMu.Lock()
if a.state >= stateStopping {
// shutdownOnce 已保证单次,此处不可达;防御性直接返回。
a.lifecycleMu.Unlock()
return
}
wasInitialized := a.state == stateInitialized
a.state = stateStopping
a.lifecycleMu.Unlock()
a.shutdownErr = a.doShutdown(wasInitialized)
a.lifecycleMu.Lock()
a.state = stateStopped
a.lifecycleMu.Unlock()
})
return a.shutdownErr
}
// doShutdown 执行实际关闭流程。仅在 Shutdown(持 initMu)中调用。
// 顺序(M1 调整):OnStop(仅 Init 曾成功时)→ cancel rootCtx → server.Shutdown →
// wg.Wait → cron / 限流器 / db / redis / logger。OnStop 在 shutdown 开头、关 HTTP 之前,
// 保有 cancel 前协调资源的机会;state=Stopping 已阻新 Go,无需先 cancel/wait 再跑 hook。
func (a *App) doShutdown(wasInitialized bool) error {
shutdownTimeout := 30 * time.Second
if a.config != nil {
shutdownTimeout = a.config.Server.EffectiveShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
var errs []error
// OnStop hooksShutdown 开头,关 HTTP 之前触发(#12)。仅 Init 曾成功时执行(M1)——
// Init 失败/未 Init 时 HTTP 从未启动,OnStop 语义不适用。
if wasInitialized {
for _, h := range a.hooks {
if h.OnStop != nil {
if err := h.OnStop(a); err != nil {
errs = append(errs, fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err))
}
}
}
}
// 取消根 ctx,通知 App.Go 启动的后台 goroutine 退出(#22
if a.cancel != nil {
a.cancel()
}
if a.server != nil {
logger.Info("关闭 HTTP 服务器...")
if err := a.server.Shutdown(ctx); err != nil {
logger.Warnf("HTTP 服务器关闭超时: %v", err)
errs = append(errs, err)
if closeErr := a.server.Close(); closeErr != nil {
errs = append(errs, closeErr)
}
}
}
// 等待业务 in-flight goroutine 退出(#22),受 shutdownTimeout 约束
waitDone := make(chan struct{})
go func() {
a.wg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-ctx.Done():
logger.Warnf("等待后台 goroutine 退出超时")
}
// 停止 cron 全局调度器(P1 #10),在剩余 shutdown 预算内等待在跑任务退出。
if a.initializedCron {
logger.Info("停止 cron 调度器...")
remaining := time.Second
if dl, ok := ctx.Deadline(); ok {
if r := time.Until(dl); r > 0 {
remaining = r
}
}
if err := a.stopCron(remaining); err != nil {
logger.Warnf("cron 调度器停止超时,可能有任务未响应 ctx 取消")
}
}
logger.Info("停止限流器...")
middleware.StopRateLimiters()
// db/redis/logger 经 closeResources 统一关闭(与 failAfterInit 复用,M1)。
// 先记最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop)。
logger.Info("关闭数据库连接/Redis/日志...")
logger.Info("应用已优雅关闭")
if err := a.closeResources(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
// Go 启动一个受 App 生命周期管理的后台 goroutine(#22)。
// fn 收到的 ctx 在 Shutdown 时被 cancelfn 应在 ctx.Done() 时及时退出。
// Shutdown/Init 失败后调用为 no-opM1state>=stateStopping 拒绝 wg.Add,避免与
// Shutdown 的 wg.Wait 竞争 WaitGroup 契约——Add 由 lifecycleMu.RLock 保护 happen-before Wait)。
func (a *App) Go(fn func(ctx context.Context)) {
if fn == nil {
return
}
a.lifecycleMu.RLock()
if a.state >= stateStopping {
a.lifecycleMu.RUnlock()
return
}
ctx := a.rootCtx
if ctx == nil {
ctx = context.Background()
}
a.wg.Add(1)
a.lifecycleMu.RUnlock()
go func() {
defer a.wg.Done()
fn(ctx)
}()
}
// GetRegistry 获取路由注册中心(用于动态注册)
func (a *App) GetRegistry() *router.Registry {
return a.registry
}
// GetRouter 获取 Gin Engine(用于高级自定义)
func (a *App) GetRouter() *gin.Engine {
return a.router
}
// GetServer 获取 HTTP Server(用于高级自定义)
func (a *App) GetServer() *http.Server {
return a.server
}