feat(app): 实现优雅停机功能

- 新增 shuttingDown 原子布尔标志和 shutdownOnce 保证单次执行
- 添加 DrainTimeout 和 ShutdownTimeout 配置参数,默认 5s 和 30s
- 修改 ServeHTTP 拦截新请求返回 503 Service Unavailable
- 实现跨平台信号处理:Windows 关窗口和 Linux SIGTERM/SIGINT
- 添加 recover 保护避免停机过程中 panic 触发重启
- 优化 HTTP 服务退出判断,排除正常关闭情况
- 新增 log.LoggerNoCaller 用于访问日志等无意义调用方场景
- 更新 README 文档增加优雅停机特性说明和文档链接
- 创建详细的优雅停机文档和测试用例
- 添加 Windows 平台特定信号处理实现
- 新增 .cursor 计划文件记录开发方案
This commit is contained in:
2026-04-14 14:07:59 +08:00
parent 3430bdec19
commit 66f18a084a
9 changed files with 882 additions and 19 deletions
+78 -12
View File
@@ -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