Files
hotime/log/logger.go
T
hoteas 66f18a084a feat(app): 实现优雅停机功能
- 新增 shuttingDown 原子布尔标志和 shutdownOnce 保证单次执行
- 添加 DrainTimeout 和 ShutdownTimeout 配置参数,默认 5s 和 30s
- 修改 ServeHTTP 拦截新请求返回 503 Service Unavailable
- 实现跨平台信号处理:Windows 关窗口和 Linux SIGTERM/SIGINT
- 添加 recover 保护避免停机过程中 panic 触发重启
- 优化 HTTP 服务退出判断,排除正常关闭情况
- 新增 log.LoggerNoCaller 用于访问日志等无意义调用方场景
- 更新 README 文档增加优雅停机特性说明和文档链接
- 创建详细的优雅停机文档和测试用例
- 添加 Windows 平台特定信号处理实现
- 新增 .cursor 计划文件记录开发方案
2026-04-14 14:07:59 +08:00

465 lines
10 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 log
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
)
// ErrorRecord 错误历史记录条目
type ErrorRecord struct {
Err error
Msg string
Time time.Time
Caller string
}
// Logger 日志核心结构体,封装 zerolog
type Logger struct {
zl zerolog.Logger
logLevel int
errors []ErrorRecord
errIdx int // 环形缓冲写入位置
errCount int // 实际写入总数(用于判断缓冲是否满)
errorsMu sync.RWMutex
maxErrors int
}
// NewLogger 创建日志实例
// logLevel: 0=仅 error>=1=全部
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
// maxErrors: 错误历史最大条数,0 则不记录
func NewLogger(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,
FormatCaller: func(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("[%s]", i)
},
}
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().
Caller().
Logger()
l := &Logger{
zl: zl,
logLevel: logLevel,
maxErrors: maxErrors,
}
if maxErrors > 0 {
l.errors = make([]ErrorRecord, maxErrors)
}
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)
return &Logger{zl: zl, logLevel: 0}
}
// SetOutput 设置日志输出(用于测试等场景)
func (l *Logger) SetOutput(w io.Writer) {
if w == io.Discard {
l.zl = zerolog.New(w).Level(zerolog.Disabled)
return
}
consoleWriter := zerolog.ConsoleWriter{
Out: w,
TimeFormat: "2006-01-02 15:04:05",
NoColor: true,
FormatLevel: formatLevelPlain,
FormatCaller: func(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("[%s]", i)
},
}
l.zl = zerolog.New(consoleWriter).
With().
Timestamp().
Caller().
Logger()
}
// GetLevel 获取当前日志等级
func (l *Logger) GetLevel() int {
return l.logLevel
}
// --- 链式调用 API ---
// 以下方法兼容两种调用风格:
// 无参数:返回 *zerolog.Event 用于链式调用,如 l.Error().Str("k","v").Msg("...")
// 有参数:直接拼接并打印日志(logrus 兼容),返回 no-op Event
func (l *Logger) Debug(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Debug().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Debug()
}
return l.zl.Debug()
}
func (l *Logger) Info(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Info().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Info()
}
return l.zl.Info()
}
func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Warn().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Warn()
}
return l.zl.Warn()
}
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
msg := fmt.Sprint(args...)
if l.maxErrors > 0 {
l.recordError(msg, nil)
}
l.zl.Error().Msg(msg)
nop := zerolog.Nop()
return nop.Error()
}
if l.maxErrors > 0 {
l.recordError("", nil)
}
return l.zl.Error()
}
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
func (l *Logger) RecordError(err error, msg string, caller string) {
if l.maxErrors <= 0 {
return
}
l.errorsMu.Lock()
defer l.errorsMu.Unlock()
l.errors[l.errIdx] = ErrorRecord{
Err: err,
Msg: msg,
Time: time.Now(),
Caller: caller,
}
l.errIdx = (l.errIdx + 1) % l.maxErrors
l.errCount++
}
func (l *Logger) recordError(msg string, err error) {
_, file, line, ok := runtime.Caller(2)
caller := ""
if ok {
caller = formatCaller(file, line)
}
l.RecordError(err, msg, caller)
}
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
if l.maxErrors <= 0 {
return nil
}
l.errorsMu.RLock()
defer l.errorsMu.RUnlock()
total := l.errCount
if total > l.maxErrors {
total = l.maxErrors
}
if total == 0 {
return nil
}
want := total
if len(n) > 0 && n[0] > 0 && n[0] < want {
want = n[0]
}
result := make([]ErrorRecord, 0, want)
// 从最新的往前读
for i := 0; i < want; i++ {
idx := (l.errIdx - 1 - i + l.maxErrors) % l.maxErrors
if l.errors[idx].Time.IsZero() {
break
}
result = append(result, l.errors[idx])
}
return result
}
// --- 格式化快捷方法 ---
func (l *Logger) Debugf(format string, v ...interface{}) {
l.zl.Debug().Msgf(format, v...)
}
func (l *Logger) Infof(format string, v ...interface{}) {
l.zl.Info().Msgf(format, v...)
}
func (l *Logger) Warnf(format string, v ...interface{}) {
l.zl.Warn().Msgf(format, v...)
}
func (l *Logger) Errorf(format string, v ...interface{}) {
if l.maxErrors > 0 {
l.recordError(fmt.Sprintf(format, v...), nil)
}
l.zl.Error().Msgf(format, v...)
}
// --- 日志级别颜色 ---
func formatLevelColor(i interface{}) string {
level := strings.ToUpper(fmt.Sprintf("%s", i))
switch level {
case "DEBUG":
return fmt.Sprintf("\x1b[36m|%s|\x1b[0m", level) // cyan
case "INFO":
return fmt.Sprintf("\x1b[32m|%s|\x1b[0m", level) // green
case "WARN":
return fmt.Sprintf("\x1b[33m|%s|\x1b[0m", level) // yellow
case "ERROR":
return fmt.Sprintf("\x1b[31m|%s|\x1b[0m", level) // red
case "FATAL":
return fmt.Sprintf("\x1b[35m|%s|\x1b[0m", level) // magenta
default:
return fmt.Sprintf("|%s|", level)
}
}
func formatLevelPlain(i interface{}) string {
return fmt.Sprintf("|%s|", strings.ToUpper(fmt.Sprintf("%s", i)))
}
// --- TemplateFileWriter ---
// TemplateFileWriter 按时间模板切换文件路径的 Writer
type TemplateFileWriter struct {
pathTemplate string
mu sync.Mutex
currentPath string
file *os.File
writer *bufio.Writer
}
func (w *TemplateFileWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
path := time.Now().Format(w.pathTemplate)
if path != w.currentPath {
if w.writer != nil {
_ = w.writer.Flush()
}
if w.file != nil {
_ = w.file.Close()
}
_ = os.MkdirAll(filepath.Dir(path), 0755)
w.file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return 0, err
}
w.writer = bufio.NewWriterSize(w.file, 4096)
w.currentPath = path
}
n, err = w.writer.Write(p)
_ = w.writer.Flush()
return
}
// --- 调用者智能过滤 ---
func callerMarshalFunc(_ uintptr, file string, line int) string {
return findCaller(file, line)
}
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)
if !ok {
break
}
shortFile := shortenPath(file)
if isInfrastructureFile(shortFile) {
lastInfraFile = shortFile
lastInfraLine = line
continue
}
if primaryCaller == "" {
primaryCaller = fmt.Sprintf("%s:%d", shortFile, line)
}
if appLayerCaller == "" && !strings.Contains(strings.ToLower(file), "hotime") {
appLayerCaller = fmt.Sprintf("%s:%d", shortFile, line)
}
}
result := ""
if primaryCaller != "" {
result = primaryCaller
} else if lastInfraFile != "" {
result = fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
} else {
result = fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
}
if appLayerCaller != "" && result != appLayerCaller {
result += " <- " + appLayerCaller
}
return result
}
func formatCaller(file string, line int) string {
return fmt.Sprintf("%s:%d", shortenPath(file), line)
}
func shortenPath(file string) string {
n := 0
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' || file[i] == '\\' {
n++
if n >= 2 {
return file[i+1:]
}
}
}
return file
}
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
// 这些文件在调用栈中会被跳过,以显示真正的业务调用者。
// 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
}
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
return true
}
if strings.HasPrefix(file, "runtime/") {
return true
}
if strings.HasPrefix(file, "log/") {
return true
}
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
for _, prefix := range infraPrefixes {
if strings.HasPrefix(file, prefix) {
return true
}
}
lowerFile := strings.ToLower(file)
if strings.Contains(lowerFile, "hotime") {
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
for _, dir := range infraDirs {
if strings.Contains(file, dir) {
return true
}
}
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
return true
}
}
return false
}