1060 lines
31 KiB
Go
1060 lines
31 KiB
Go
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 框架版本号。发版时只改这一处,避免版本字面量散落各处。
|
||
// CLI(xlgo 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 → stateStopped(failAfterInit 回滚)
|
||
//
|
||
// Go() 仅在 state < stateStopping 时放行 wg.Add;Init() 在 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.Wait(Add 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 // 根 ctx,App.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)。
|
||
//
|
||
// 注意:生命周期 hook(OnInit/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-2:cancel 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→logger(M1)。各均为幂等 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 hooks:listener 已成功创建,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 到 serverErr(cap=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 hooks:Shutdown 开头,关 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 时被 cancel,fn 应在 ctx.Done() 时及时退出。
|
||
// Shutdown/Init 失败后调用为 no-op(M1:state>=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
|
||
}
|