From 66f18a084acbbdcbb344693bc61c845cd2929e0a Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Tue, 14 Apr 2026 14:07:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=AE=9E=E7=8E=B0=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E5=81=9C=E6=9C=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shuttingDown 原子布尔标志和 shutdownOnce 保证单次执行 - 添加 DrainTimeout 和 ShutdownTimeout 配置参数,默认 5s 和 30s - 修改 ServeHTTP 拦截新请求返回 503 Service Unavailable - 实现跨平台信号处理:Windows 关窗口和 Linux SIGTERM/SIGINT - 添加 recover 保护避免停机过程中 panic 触发重启 - 优化 HTTP 服务退出判断,排除正常关闭情况 - 新增 log.LoggerNoCaller 用于访问日志等无意义调用方场景 - 更新 README 文档增加优雅停机特性说明和文档链接 - 创建详细的优雅停机文档和测试用例 - 添加 Windows 平台特定信号处理实现 - 新增 .cursor 计划文件记录开发方案 --- .cursor/plans/优雅停机方案_0b2a1432.plan.md | 250 ++++++++++++++++ README.md | 3 + application.go | 70 ++++- code/makecode.go | 4 +- docs/graceful-shutdown.md | 143 +++++++++ graceful_shutdown_test.go | 303 ++++++++++++++++++++ log/logger.go | 90 +++++- signal_other.go | 7 + signal_windows.go | 31 ++ 9 files changed, 882 insertions(+), 19 deletions(-) create mode 100644 .cursor/plans/优雅停机方案_0b2a1432.plan.md create mode 100644 docs/graceful-shutdown.md create mode 100644 graceful_shutdown_test.go create mode 100644 signal_other.go create mode 100644 signal_windows.go diff --git a/.cursor/plans/优雅停机方案_0b2a1432.plan.md b/.cursor/plans/优雅停机方案_0b2a1432.plan.md new file mode 100644 index 0000000..e822ce5 --- /dev/null +++ b/.cursor/plans/优雅停机方案_0b2a1432.plan.md @@ -0,0 +1,250 @@ +--- +name: 优雅停机方案 +overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_xbc.cmd 检测正常退出不重启。 +todos: + - id: framework-core + content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown() + status: completed + - id: framework-windows + content: hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口) + status: completed + - id: framework-other + content: hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现 + status: completed + - id: nginx-config + content: Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2 + status: completed + - id: cmd-exit-check + content: run_xbc.cmd:检测 EXIT_CODE == 0 时不重启直接退出 + status: completed + - id: docs + content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档 + status: completed +isProject: false +--- + +# 优雅停机方案 + +## 整体流程 + +```mermaid +sequenceDiagram + participant Op as 运维 + participant OS as 操作系统 + participant App as Go 进程 + participant Nginx as Nginx + + Op->>OS: Ctrl+C / kill / taskkill / 关窗口 + OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT + App->>App: shuttingDown = true(sync.Once 保证只执行一次) + Note over App,Nginx: 新请求立即返回 503 + Connection:close + Nginx->>App: 新请求 + App-->>Nginx: 503 Service Unavailable + Nginx->>Nginx: proxy_next_upstream http_503 → 转另一台 + Note over App: 等 DrainTimeout(默认5s,让nginx完成切流) + App->>App: server.Shutdown(ShutdownTimeout=30s) + Note over App: 等所有在途请求跑完 + App->>OS: os.Exit(0) + Note over Op: run_xbc.cmd 检测 exit 0 → 不重启 +``` + +## 信号触发场景对照表 + +| 操作方式 | 平台 | 信号/事件 | 是否可拦截 | 备注 | +|---|---|---|---|---| +| Ctrl+C | Windows/Linux | `os.Interrupt` | ✅ | 标准中断 | +| `kill ` 或 `kill -15 ` | Linux | `syscall.SIGTERM` | ✅ | 推荐生产关闭方式 | +| `taskkill /PID `(无 /F) | Windows | `os.Interrupt` | ✅ | 等价于 Ctrl+C | +| 关闭控制台窗口 X 按钮 | Windows | `CTRL_CLOSE_EVENT` | ✅(≤5s 限制)| Windows 给 ~5s 后强杀 | +| `taskkill /F /PID ` | Windows | 强制 SIGKILL | ❌ | 无法拦截,强杀 | +| `kill -9 ` | Linux | SIGKILL | ❌ | 无法拦截,强杀 | + +> **关窗口说明**:Windows 在触发 `CTRL_CLOSE_EVENT` 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 `taskkill /PID` 代替关窗口做计划性停机。 + +--- + +## 需要改动/新建的文件 + +### 1. [`hotimev1.5/application.go`](d:/work/hotimev1.5/application.go) + +**`Application` struct** 新增字段: +```go +type Application struct { + // ...原有字段... + shuttingDown atomic.Bool + shutdownOnce sync.Once // 保证多路触发只执行一次 + DrainTimeout time.Duration // 等 nginx 切流,默认 5s + ShutdownTimeout time.Duration // 等在途请求,默认 30s +} +``` + +**`ServeHTTP`** 新增停机检测: +```go +func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if that.shuttingDown.Load() { + w.Header().Set("Connection", "close") + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + that.handler(w, req) +} +``` + +**提取 `initiateGracefulShutdown(drain, shutdown time.Duration)` 方法**(被信号 goroutine 和 Windows 关窗 handler 共同调用): +```go +func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) { + that.shutdownOnce.Do(func() { + that.shuttingDown.Store(true) + that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain) + time.Sleep(drain) + ctx, cancel := context.WithTimeout(context.Background(), shutdown) + defer cancel() + that.Log.Infof("等进行中请求完成(最多 %v)...", shutdown) + if err := that.Server.Shutdown(ctx); err != nil { + that.Log.Errorf("优雅停机超时: %v", err) + } + that.Log.Infof("服务已安全关闭") + os.Exit(0) + }) +} +``` + +**`Run()`** 加信号监听 goroutine(在启动 ListenAndServe 之后): +```go +// 注册 Windows 关窗口处理(Linux 上是空实现) +that.registerWindowsCloseHandler() + +// 跨平台:Ctrl+C / SIGTERM / kill +go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + drain := that.DrainTimeout + if drain == 0 { drain = 5 * time.Second } + shutdown := that.ShutdownTimeout + if shutdown == 0 { shutdown = 30 * time.Second } + that.initiateGracefulShutdown(drain, shutdown) +}() +``` + +**`defer recover`** 加停机保护: +```go +defer func() { + if err := recover(); err != nil { + if that.shuttingDown.Load() { + return // 停机中的 panic 不触发重启 + } + that.Log.Warnf("%v", err) + that.Run(router) + } +}() +``` + +新增 import:`"context"`, `"os/signal"`, `"sync"`, `"sync/atomic"`, `"syscall"` + +--- + +### 2. `hotimev1.5/signal_windows.go`(新建) + +```go +//go:build windows + +package hotime + +import ( + "time" + "golang.org/x/sys/windows" +) + +func (that *Application) registerWindowsCloseHandler() { + windows.SetConsoleCtrlHandler(func(ctrl uint32) bool { + // CTRL_CLOSE_EVENT=2 CTRL_LOGOFF_EVENT=5 CTRL_SHUTDOWN_EVENT=6 + if ctrl == 2 || ctrl == 5 || ctrl == 6 { + that.initiateGracefulShutdown(2*time.Second, 3*time.Second) + // 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行 + // Windows 约 5s 后强杀,这里最多等 6s 兜底 + time.Sleep(6 * time.Second) + return true + } + return false + }, true) +} +``` + +> `golang.org/x/sys` 已在 `xbc/go.mod` 中作为间接依赖存在(v0.29.0),无需新增依赖。 + +--- + +### 3. `hotimev1.5/signal_other.go`(新建) + +```go +//go:build !windows + +package hotime + +func (that *Application) registerWindowsCloseHandler() {} +``` + +--- + +### 4. Nginx 配置(在已有 location 块中追加) + +```nginx +# 让后端返回 503 时自动切换到另一台(优雅停机核心) +proxy_next_upstream error timeout http_503; +proxy_next_upstream_tries 2; +``` + +已有的 `max_fails=1 fail_timeout=3s` 负责连接级失败(强杀后的端口拒绝),两者互补。 + +--- + +### 5. [`run_xbc.cmd`](d:/work/xbc/run_xbc.cmd) + +在 `goto run_app` 前插入 exit code 判断: + +```cmd +:: 优雅退出 (exit 0) 不重启 +if %EXIT_CODE% EQU 0 ( + echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%" + echo. + echo [优雅停机] 服务已安全关闭,不再自动重启。 + pause + exit /b 0 +) +``` + +--- + +### 6. `hotimev1.5/docs/graceful-shutdown.md`(新建) + +包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。 + +--- + +## 滚动重启操作步骤(Windows) + +``` +# 1. 找到端口 9998 的进程 PID +netstat -ano | findstr 9998 + +# 2. 优雅关闭该实例(不加 /F) +taskkill /PID + +# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动 +# 4. 再对 9999 执行同样操作 +``` + +## 滚动重启操作步骤(Linux) + +```bash +# 找 PID +pgrep xbc # 或 ps aux | grep xbc + +# 优雅关闭 +kill # 发 SIGTERM,触发优雅停机 +# 或 +kill -2 # 发 SIGINT,等同 Ctrl+C + +# 等日志出现 "服务已安全关闭" 再部署新版 +``` diff --git a/README.md b/README.md index 16c3ae6..7b21cf5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ D:\app\go1.23.1\bin\go.exe test ./dd/... -v - **Session 管理** - 内置会话管理,支持多种存储后端 - **代码生成** - 根据数据库表自动生成 CRUD 接口 - **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台 +- **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启 - **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置 ## 文档 @@ -31,6 +32,7 @@ D:\app\go1.23.1\bin\go.exe test ./dd/... -v | [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 | | [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 | | [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 | +| [优雅停机](docs/graceful-shutdown.md) | 跨平台优雅停机、nginx 配置、滚动重启操作指南 | | [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 | ## 安装 @@ -67,6 +69,7 @@ go get code.hoteas.com/golang/hotime | Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 | | 代码生成 | ✅ | ❌ | ❌ | ❌ | | API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 | +| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 | | 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ | ## 适用场景 diff --git a/application.go b/application.go index 5ff83db..28a360b 100644 --- a/application.go +++ b/application.go @@ -1,14 +1,19 @@ package hotime import ( + "context" "database/sql" "io/ioutil" "net/http" "net/url" "os" + "os/signal" "path/filepath" "strconv" "strings" + "sync" + "sync/atomic" + "syscall" "time" . "code.hoteas.com/golang/hotime/cache" @@ -34,12 +39,41 @@ type Application struct { *HoTimeCache *http.Server http.Handler + shuttingDown atomic.Bool // 停机标志,置为 true 后新请求立即返回 503 + shutdownOnce sync.Once // 保证多路触发(信号/关窗口)只执行一次 + DrainTimeout time.Duration // 返回 503 后等 nginx 切流的时长,默认 5s + ShutdownTimeout time.Duration // 等在途请求完成的最长时间,默认 30s } func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if that.shuttingDown.Load() { + w.Header().Set("Connection", "close") + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } that.handler(w, req) } +// initiateGracefulShutdown 触发优雅停机,通过 sync.Once 保证多路触发只执行一次。 +// drain: 返回 503 后等待上游(nginx)完成流量切换的时长。 +// shutdown: 等待在途请求完成的最长时间。 +func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) { + that.shutdownOnce.Do(func() { + that.shuttingDown.Store(true) + that.Log.Infof("优雅停机:新请求已返回 503,等 %v 让 nginx 完成流量切换...", drain) + time.Sleep(drain) + ctx, cancel := context.WithTimeout(context.Background(), shutdown) + defer cancel() + that.Log.Infof("停止接受新连接,等进行中请求完成(最多 %v)...", shutdown) + if err := that.Server.Shutdown(ctx); err != nil { + that.Log.Errorf("优雅停机超时,强制关闭: %v", err) + } else { + that.Log.Infof("服务已安全关闭") + } + os.Exit(0) + }) +} + // Run 启动实例 func (that *Application) Run(router Router) { //如果没有设置配置自动生成配置 @@ -125,6 +159,9 @@ func (that *Application) Run(router Router) { //异常处理 defer func() { if err := recover(); err != nil { + if that.shuttingDown.Load() { + return // 停机过程中的 panic 不触发重启 + } //that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT) that.Log.Warnf("%v", err) @@ -146,8 +183,10 @@ func (that *Application) Run(router Router) { //启动服务 that.Server.Addr = ":" + that.Port err := that.Server.ListenAndServe() - that.Log.Error().Err(err).Msg("HTTP 服务退出") - ch <- 1 + if err != nil && err != http.ErrServerClosed { + that.Log.Error().Err(err).Msg("HTTP 服务退出") + ch <- 1 + } }() } @@ -160,8 +199,10 @@ func (that *Application) Run(router Router) { //启动服务 that.Server.Addr = ":" + that.TLSPort err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey")) - that.Log.Error().Err(err).Msg("HTTPS 服务退出") - ch <- 2 + if err != nil && err != http.ErrServerClosed { + that.Log.Error().Err(err).Msg("HTTPS 服务退出") + ch <- 2 + } }() } @@ -171,6 +212,25 @@ func (that *Application) Run(router Router) { } + // 注册 Windows 关窗口处理(Linux/macOS 上是空实现) + that.registerWindowsCloseHandler() + + // 跨平台信号监听:Ctrl+C / SIGTERM(kill 命令)/ taskkill /PID + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + drain := that.DrainTimeout + if drain == 0 { + drain = 5 * time.Second + } + shutdown := that.ShutdownTimeout + if shutdown == 0 { + shutdown = 30 * time.Second + } + that.initiateGracefulShutdown(drain, shutdown) + }() + value := <-ch that.Log.Errorf("启动服务失败 : %s", ObjToStr(value)) @@ -256,7 +316,7 @@ func (that *Application) SetConfig(configPath ...string) { that.Log = log.NewLogger(logLevel, logFile, logHistory) that.Db.Log = that.Log if that.Config.Get("webConnectLogShow") == nil || that.Config.GetBool("webConnectLogShow") { - that.WebConnectLog = log.NewLogger(1, that.Config.GetString("webConnectLogFile"), 0) + that.WebConnectLog = log.NewLoggerNoCaller(1, that.Config.GetString("webConnectLogFile"), 0) } } diff --git a/code/makecode.go b/code/makecode.go index 74071fd..7070886 100644 --- a/code/makecode.go +++ b/code/makecode.go @@ -669,7 +669,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) { if err != nil { that.Log.Error().Err(err).Msg("写入 configDB 文件失败") } - that.Log.Warnf("新建 configDB: %s", config.GetString("configDB")) + that.Log.Infof("新建 configDB: %s", config.GetString("configDB")) } @@ -685,7 +685,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) { if err != nil { that.Log.Error().Err(err).Msg("写入 config 文件失败") } - that.Log.Warnf("新建 config: %s", config.GetString("config")) + that.Log.Infof("新建 config: %s", config.GetString("config")) } //fmt.Println("有新的代码生成,请重新运行") diff --git a/docs/graceful-shutdown.md b/docs/graceful-shutdown.md new file mode 100644 index 0000000..5d7b5df --- /dev/null +++ b/docs/graceful-shutdown.md @@ -0,0 +1,143 @@ +# HoTime 框架优雅停机 + +## 概述 + +HoTime 框架内置优雅停机支持,收到关闭信号后: + +1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例 +2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换 +3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒) +4. 以 `exit(0)` 正常退出,`run_xbc.cmd` 检测到正常退出后不自动重启 + +--- + +## 触发方式对照表 + +| 操作 | 平台 | 是否可优雅停机 | 说明 | +|---|---|:---:|---| +| `Ctrl+C` | Windows / Linux | ✅ | 发送 SIGINT / os.Interrupt | +| `kill ` 或 `kill -15 ` | Linux | ✅ | **推荐**,发送 SIGTERM | +| `kill -2 ` | Linux | ✅ | 等同 Ctrl+C,发送 SIGINT | +| `taskkill /PID `(不加 /F) | Windows | ✅ | **推荐**,等同发送 Ctrl+C | +| 关闭控制台/exe 窗口 X 按钮 | Windows | ✅(≤5s) | CTRL_CLOSE_EVENT,Windows 约 5s 后强杀 | +| `taskkill /F /PID ` | Windows | ❌ | 强制 SIGKILL,无法拦截 | +| `kill -9 ` | Linux | ❌ | SIGKILL,无法拦截 | + +> **关窗口的限制**:Windows 系统在触发 CTRL_CLOSE_EVENT 后约 5 秒会强制杀死进程。 +> 框架对此路径使用更短的 drain(2s) + shutdown(3s),总计 <5s。 +> 如果有接口执行时间超过 3 秒,建议改用 `taskkill /PID` 做计划性停机,不受 5s 限制。 + +--- + +## 配置参数 + +在 `Application` 实例上设置(需在调用 `Run()` 前配置): + +```go +appIns := Init("config/config.json") + +// 可选:自定义超时,不设置则使用默认值 +appIns.DrainTimeout = 5 * time.Second // 等 nginx 切流,默认 5s +appIns.ShutdownTimeout = 30 * time.Second // 等在途请求完成,默认 30s + +appIns.Run(Router{ ... }) +``` + +--- + +## Nginx 配置 + +在 `location` 块中添加以下两行,让后端返回 `503` 时 nginx 自动转到其他实例,客户端无感知: + +```nginx +upstream api_backend { + least_conn; + keepalive 64; + server 127.0.0.1:9998 max_fails=1 fail_timeout=3s; + server 127.0.0.1:9999 max_fails=1 fail_timeout=3s; +} + +server { + location /api/ { + proxy_pass http://api_backend; + + # 优雅停机核心配置:后端返回 503 时自动切换到另一台 + proxy_next_upstream error timeout http_503; + proxy_next_upstream_tries 2; + + # 其他常规配置... + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +- `max_fails=1 fail_timeout=3s`:连接级失败(如强杀后端口拒绝)时标记下线 3s +- `proxy_next_upstream http_503`:应用级 503 时 nginx 自动重试下一台 + +--- + +## 滚动重启步骤 + +### Windows + +```cmd +:: 1. 查找端口 9998 的进程 PID +netstat -ano | findstr :9998 + +:: 2. 优雅关闭该实例(不加 /F,发送 Ctrl+C 信号) +taskkill /PID + +:: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动 +:: run_xbc.cmd 检测到 exit(0) 后不会自动重启 + +:: 4. 对端口 9999 重复以上步骤 +netstat -ano | findstr :9999 +taskkill /PID +``` + +### Linux + +```bash +# 1. 查找进程 PID +pgrep -a xbc # 或 +ps aux | grep xbc + +# 2. 优雅关闭(发送 SIGTERM) +kill + +# 3. 观察日志确认关闭完成 +tail -f logs/xbc.log # 等待"服务已安全关闭" + +# 4. 部署新版并启动,再对另一个实例重复 +``` + +--- + +## 工作原理 + +``` +收到信号 / 关窗口 + │ + ▼ +shuttingDown = true + │ + ▼ DrainTimeout(默认 5s) +新请求 → 503 ──────────────────────► nginx 切流到另一台 + │ + ▼ +server.Shutdown(ShutdownTimeout=30s) + 等待所有进行中请求跑完 + │ + ▼ +os.Exit(0) → run_xbc.cmd 检测到 exit(0),不重启 +``` + +### 为何在框架层而非应用层实现 + + 通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。 + +### `sync.Once` 保证幂等性 + +框架同时监听多路触发源(信号 goroutine + Windows 关窗口 handler),`sync.Once` 保证无论哪路先触发,`initiateGracefulShutdown` 只执行一次,不会重复调用 `Shutdown()`。 diff --git a/graceful_shutdown_test.go b/graceful_shutdown_test.go new file mode 100644 index 0000000..8f160db --- /dev/null +++ b/graceful_shutdown_test.go @@ -0,0 +1,303 @@ +package hotime + +import ( + "context" + "fmt" + "io/ioutil" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "code.hoteas.com/golang/hotime/log" +) + +// getFreePort 获取一个可用的随机端口 +func getFreePort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return port, nil +} + +// newTestApp 创建一个用于测试的最小 Application,不依赖数据库和配置文件 +func newTestApp(port int, handler http.HandlerFunc) *Application { + app := &Application{} + app.Log = log.NewLogger(1, "", 10) + app.Server = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: app, + } + // 用自定义 handler 替代完整的路由系统 + app.Handler = handler + return app +} + +// ServeHTTP 已经在 application.go 中定义, +// 但 handler 方法依赖完整初始化,这里我们直接用 http.Handler 接口。 +// 因此测试 app 的 ServeHTTP 会先检查 shuttingDown,然后调用 that.handler()。 +// 但 that.handler() 需要完整的路由初始化,不适合单元测试。 +// 所以我们直接测试核心逻辑:shuttingDown 标志 + http.Server.Shutdown。 + +// TestServeHTTP_ShuttingDown 测试停机标志置为 true 后新请求返回 503 +func TestServeHTTP_ShuttingDown(t *testing.T) { + port, err := getFreePort() + if err != nil { + t.Fatalf("获取空闲端口失败: %v", err) + } + + var requestHandled atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestHandled.Add(1) + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + app := &Application{} + app.Log = log.NewLogger(1, "", 10) + app.Server = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + } + app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app.shuttingDown.Load() { + w.Header().Set("Connection", "close") + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + handler.ServeHTTP(w, r) + }) + + go app.Server.ListenAndServe() + time.Sleep(100 * time.Millisecond) + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + client := &http.Client{Timeout: 5 * time.Second} + + // --- 正常请求应该返回 200 --- + resp, err := client.Get(baseURL + "/test") + if err != nil { + t.Fatalf("正常请求失败: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("期望 200,实际 %d", resp.StatusCode) + } + resp.Body.Close() + t.Logf("[PASS] 正常请求返回 %d", resp.StatusCode) + + if requestHandled.Load() != 1 { + t.Fatalf("期望 handler 被调用 1 次,实际 %d 次", requestHandled.Load()) + } + + // --- 设置停机标志 --- + app.shuttingDown.Store(true) + t.Log("[INFO] shuttingDown 已置为 true") + + // --- 停机后请求应该返回 503 --- + resp2, err := client.Get(baseURL + "/test2") + if err != nil { + t.Fatalf("停机请求失败: %v", err) + } + if resp2.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("期望 503,实际 %d", resp2.StatusCode) + } + resp2.Body.Close() + t.Logf("[PASS] 停机后请求返回 %d", resp2.StatusCode) + + // handler 不应被再次调用 + if requestHandled.Load() != 1 { + t.Fatalf("停机后 handler 不应被调用,但被调用了 %d 次", requestHandled.Load()) + } + t.Log("[PASS] 停机后 handler 未被调用") + + app.Server.Close() +} + +// TestGracefulShutdown_InFlightRequestCompletes 测试优雅停机等待在途请求完成 +func TestGracefulShutdown_InFlightRequestCompletes(t *testing.T) { + port, err := getFreePort() + if err != nil { + t.Fatalf("获取空闲端口失败: %v", err) + } + + slowRequestStarted := make(chan struct{}) + slowRequestDone := make(chan struct{}) + + app := &Application{} + app.Log = log.NewLogger(1, "", 10) + app.Server = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + } + app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app.shuttingDown.Load() { + w.Header().Set("Connection", "close") + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + if r.URL.Path == "/slow" { + close(slowRequestStarted) + time.Sleep(2 * time.Second) // 模拟慢接口 + w.WriteHeader(http.StatusOK) + w.Write([]byte("SLOW_DONE")) + close(slowRequestDone) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + go app.Server.ListenAndServe() + time.Sleep(100 * time.Millisecond) + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + client := &http.Client{Timeout: 10 * time.Second} + + // --- 发起慢请求(模拟在途请求)--- + var slowResp *http.Response + var slowErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + slowResp, slowErr = client.Get(baseURL + "/slow") + }() + + // 等慢请求开始处理 + <-slowRequestStarted + t.Log("[INFO] 慢请求已开始处理") + + // --- 在慢请求进行中触发 Shutdown --- + app.shuttingDown.Store(true) + t.Log("[INFO] shuttingDown 已置为 true,开始 Shutdown") + + shutdownDone := make(chan struct{}) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + app.Server.Shutdown(ctx) + close(shutdownDone) + }() + + // --- 新请求应该返回 503(但因为 Shutdown 不接受新连接了,可能连接被拒绝)--- + // Shutdown 调用后,新的 TCP 连接也会被拒绝,所以这里验证连接级别的拒绝也OK + resp, err := client.Get(baseURL + "/new") + if err != nil { + t.Logf("[PASS] Shutdown 后新请求连接被拒绝(预期行为): %v", err) + } else { + if resp.StatusCode == http.StatusServiceUnavailable { + t.Logf("[PASS] Shutdown 后新请求返回 503") + } else { + t.Logf("[INFO] Shutdown 后新请求返回 %d", resp.StatusCode) + } + resp.Body.Close() + } + + // --- 等待慢请求完成 --- + wg.Wait() + if slowErr != nil { + t.Fatalf("慢请求返回错误(不应中断在途请求): %v", slowErr) + } + body, _ := ioutil.ReadAll(slowResp.Body) + slowResp.Body.Close() + + if slowResp.StatusCode != http.StatusOK { + t.Fatalf("慢请求期望 200,实际 %d", slowResp.StatusCode) + } + if string(body) != "SLOW_DONE" { + t.Fatalf("慢请求期望 body='SLOW_DONE',实际 '%s'", string(body)) + } + t.Logf("[PASS] 慢请求正常完成,状态=%d,body=%s", slowResp.StatusCode, string(body)) + + // --- 等 Shutdown 完成 --- + <-shutdownDone + t.Log("[PASS] Server.Shutdown() 在慢请求完成后返回") + + // 确认慢请求确实跑完了 + select { + case <-slowRequestDone: + t.Log("[PASS] 慢请求 handler 完整执行完毕") + default: + t.Fatal("慢请求 handler 未完整执行") + } +} + +// TestShutdownOnce_MultipleSignals 测试多次触发只执行一次 +func TestShutdownOnce_MultipleSignals(t *testing.T) { + var callCount atomic.Int32 + app := &Application{} + app.Log = log.NewLogger(1, "", 10) + app.Server = &http.Server{} + + // 为了避免 os.Exit(0) 导致测试进程退出, + // 我们直接测试 shutdownOnce + shuttingDown 逻辑 + var once sync.Once + var shuttingDown atomic.Bool + + mockShutdown := func() { + once.Do(func() { + shuttingDown.Store(true) + callCount.Add(1) + }) + } + + // 并发触发 10 次 + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + mockShutdown() + }() + } + wg.Wait() + + if callCount.Load() != 1 { + t.Fatalf("期望 shutdownOnce.Do 只执行 1 次,实际 %d 次", callCount.Load()) + } + if !shuttingDown.Load() { + t.Fatal("shuttingDown 应为 true") + } + t.Logf("[PASS] 10 次并发触发,shutdownOnce.Do 只执行了 %d 次", callCount.Load()) +} + +// TestRecoverSkipsDuringShutdown 测试停机中的 panic 不触发重启 +func TestRecoverSkipsDuringShutdown(t *testing.T) { + app := &Application{} + app.Log = log.NewLogger(1, "", 10) + + var restartCalled atomic.Bool + + // 模拟 Run() 中的 defer recover 逻辑 + testRecover := func() { + defer func() { + if err := recover(); err != nil { + if app.shuttingDown.Load() { + return // 停机中不重启 + } + restartCalled.Store(true) + } + }() + panic("test panic") + } + + // 未停机时,recover 应触发"重启" + restartCalled.Store(false) + testRecover() + if !restartCalled.Load() { + t.Fatal("未停机时 panic 应触发重启") + } + t.Log("[PASS] 未停机时 panic 正确触发重启逻辑") + + // 停机时,recover 不应触发"重启" + app.shuttingDown.Store(true) + restartCalled.Store(false) + testRecover() + if restartCalled.Load() { + t.Fatal("停机时 panic 不应触发重启") + } + t.Log("[PASS] 停机时 panic 正确跳过重启逻辑") +} diff --git a/log/logger.go b/log/logger.go index 62f153b..db36354 100644 --- a/log/logger.go +++ b/log/logger.go @@ -49,9 +49,9 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger { } consoleWriter := zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: "2006-01-02 15:04:05", - NoColor: false, + Out: os.Stderr, + TimeFormat: "2006-01-02 15:04:05", + NoColor: false, FormatLevel: formatLevelColor, FormatCaller: func(i interface{}) string { if i == nil { @@ -89,6 +89,52 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger { return l } +// NewLoggerNoCaller 创建不带自动 Caller 字段的日志实例(用于访问日志等 caller 无意义的场景) +func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger { + zerolog.CallerMarshalFunc = callerMarshalFunc + zerolog.TimeFieldFormat = "2006-01-02 15:04:05" + + var level zerolog.Level + if logLevel == 0 { + level = zerolog.ErrorLevel + } else { + level = zerolog.DebugLevel + } + + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "2006-01-02 15:04:05", + NoColor: false, + FormatLevel: formatLevelColor, + } + + var writers []io.Writer + writers = append(writers, consoleWriter) + + if logFile != "" { + fw := &TemplateFileWriter{pathTemplate: logFile} + writers = append(writers, fw) + } + + multi := zerolog.MultiLevelWriter(writers...) + zl := zerolog.New(multi). + Level(level). + With(). + Timestamp(). + Logger() + + l := &Logger{ + zl: zl, + logLevel: logLevel, + maxErrors: maxErrors, + } + if maxErrors > 0 { + l.errors = make([]ErrorRecord, maxErrors) + } + + return l +} + // NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard) func NewTestLogger() *Logger { zl := zerolog.New(io.Discard).Level(zerolog.Disabled) @@ -102,9 +148,9 @@ func (l *Logger) SetOutput(w io.Writer) { return } consoleWriter := zerolog.ConsoleWriter{ - Out: w, - TimeFormat: "2006-01-02 15:04:05", - NoColor: true, + Out: w, + TimeFormat: "2006-01-02 15:04:05", + NoColor: true, FormatLevel: formatLevelPlain, FormatCaller: func(i interface{}) string { if i == nil { @@ -319,6 +365,8 @@ func callerMarshalFunc(_ uintptr, file string, line int) string { func findCaller(origFile string, origLine int) string { var lastInfraFile string var lastInfraLine int + var primaryCaller string + var appLayerCaller string for i := 1; i < 20; i++ { _, file, line, ok := runtime.Caller(i) @@ -333,13 +381,28 @@ func findCaller(origFile string, origLine int) string { continue } - return fmt.Sprintf("%s:%d", shortFile, line) + if primaryCaller == "" { + primaryCaller = fmt.Sprintf("%s:%d", shortFile, line) + } + if appLayerCaller == "" && !strings.Contains(strings.ToLower(file), "hotime") { + appLayerCaller = fmt.Sprintf("%s:%d", shortFile, line) + } } - if lastInfraFile != "" { - return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine) + result := "" + if primaryCaller != "" { + result = primaryCaller + } else if lastInfraFile != "" { + result = fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine) + } else { + result = fmt.Sprintf("%s:%d", shortenPath(origFile), origLine) } - return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine) + + if appLayerCaller != "" && result != appLayerCaller { + result += " <- " + appLayerCaller + } + + return result } func formatCaller(file string, line int) string { @@ -361,8 +424,8 @@ func shortenPath(file string) string { // isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线, // 这些文件在调用栈中会被跳过,以显示真正的业务调用者。 -// 框架业务文件(application.go、context.go、code/makecode.go 等)不在此列, -// 会作为有意义的调用者返回。 +// context.go 是 Display/View 的纯中间层,也作为基础设施跳过。 +// application.go、code/makecode.go 等保留,会作为有意义的调用者返回。 func isInfrastructureFile(file string) bool { if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") { return true @@ -392,6 +455,9 @@ func isInfrastructureFile(file string) bool { return true } } + if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") { + return true + } } return false diff --git a/signal_other.go b/signal_other.go new file mode 100644 index 0000000..f144fb7 --- /dev/null +++ b/signal_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package hotime + +// registerWindowsCloseHandler 在非 Windows 平台上是空实现。 +// Linux/macOS 通过 SIGTERM (kill) 或 SIGINT (Ctrl+C) 触发优雅停机。 +func (that *Application) registerWindowsCloseHandler() {} diff --git a/signal_windows.go b/signal_windows.go new file mode 100644 index 0000000..7f391b0 --- /dev/null +++ b/signal_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package hotime + +import ( + "syscall" + "time" +) + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") +var procSetConsoleCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler") + +// registerWindowsCloseHandler 注册 Windows 控制台关闭事件处理器, +// 捕获用户点击控制台 X 按钮、系统注销/关机等事件,触发优雅停机。 +// +// Windows 在 CTRL_CLOSE_EVENT 后约 5 秒会强制结束进程, +// 因此此路径使用更短的 drain(2s)+shutdown(3s),总计 <5s。 +// 对于需要更长时间的场景,请改用 taskkill /PID 或 Ctrl+C。 +func (that *Application) registerWindowsCloseHandler() { + handler := func(ctrl uintptr) uintptr { + switch ctrl { + case 2, 5, 6: // CTRL_CLOSE_EVENT=2, CTRL_LOGOFF_EVENT=5, CTRL_SHUTDOWN_EVENT=6 + that.initiateGracefulShutdown(2*time.Second, 3*time.Second) + time.Sleep(6 * time.Second) + return 1 // TRUE, 已处理 + } + return 0 // FALSE, 交给下一个 handler + } + + procSetConsoleCtrlHandler.Call(syscall.NewCallback(handler), 1) +}