feat(config,logging): 增强日志配置,支持全局格式和 nginx combined 格式

- 新增全局日志格式配置 (text/json),控制启动/停止日志输出格式
- 访问日志默认格式改为近似 nginx combined 格式
- 支持 $remote_user 变量,BasicAuth 认证成功后存储用户名到上下文
- 扩展 config.example.yaml 虚拟主机配置说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-03 15:02:27 +08:00
parent ac9153f09d
commit 94a2ae7113
5 changed files with 210 additions and 28 deletions

View File

@ -109,17 +109,54 @@ server:
- "application/json"
- "application/javascript"
# 多虚拟主机模式(可选
# 多虚拟主机模式(可选,每个虚拟主机支持完整的 server 配置
# servers:
# - listen: ":8080"
# name: "api.example.com"
# proxy:
# - listen: ":8080" # 监听地址
# name: "api.example.com" # 服务器名称(用于虚拟主机匹配)
# read_timeout: 30s # 读取超时0 表示不限制)
# write_timeout: 30s # 写入超时0 表示不限制)
# idle_timeout: 120s # 空闲超时0 表示不限制)
# max_conns_per_ip: 1000 # 每 IP 最大连接数0 表示不限制)
# max_requests_per_conn: 10000 # 每连接最大请求数0 表示不限制)
# static: # 静态文件配置
# root: /var/www/api
# index: [index.html]
# proxy: # 反向代理配置
# - path: /api
# targets: [http://backend:8080]
# - listen: ":8443"
# targets:
# - url: http://backend:8080
# load_balance: round_robin
# ssl: # SSL/TLS 配置
# cert: /path/to/api.cert.pem
# key: /path/to/api.key.pem
# protocols: [TLSv1.2, TLSv1.3]
# hsts:
# max_age: 31536000
# include_sub_domains: true
# security: # 安全配置
# access:
# default: allow
# rate_limit:
# request_rate: 100
# headers:
# x_frame_options: DENY
# compression: # 响应压缩配置
# type: gzip
# level: 6
# - listen: ":8443" # 另一个虚拟主机
# name: "static.example.com"
# static:
# root: /var/www/static
# index: [index.html, index.htm]
# ssl:
# cert: /path/to/static.cert.pem
# key: /path/to/static.key.pem
# compression:
# type: gzip
# SSL/TLS 默认配置说明(未配置证书时不启用)
# 默认 TLS 协议: TLSv1.2, TLSv1.3(不支持 TLSv1.0/1.1
# 默认 HSTS 配置: max_age=315360001年, include_sub_domains=true
# TCP/UDP Stream 代理配置(可选)
# stream:
@ -135,9 +172,12 @@ server:
# 日志配置
logging:
format: "text" # 全局日志格式(有效值: text, json控制启动/停止日志格式
access:
path: "" # 日志文件路径(空表示输出到 stdout
format: "$remote_addr - $request - $status - $body_bytes_sent" # 日志格式(支持变量: $remote_addr, $request, $status, $body_bytes_sent, $request_time, $http_referer, $http_user_agent
format: "$remote_addr - $remote_user [$time] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"" # 访问日志格式,近似 nginx combined
# 支持变量: $remote_addr, $remote_user, $request, $status, $body_bytes_sent, $request_time, $http_referer, $http_user_agent, $time
# 特殊值 "json" 输出结构化 JSON
error:
path: "" # 日志文件路径(空表示输出到 stderr
level: "info" # 日志级别(有效值: debug, info, warn, error级别越高日志越少

View File

@ -189,8 +189,9 @@ type CompressionConfig struct {
// LoggingConfig 日志配置。
type LoggingConfig struct {
Access AccessLogConfig `yaml:"access"` // 访问日志
Error ErrorLogConfig `yaml:"error"` // 错误日志
Format string `yaml:"format"` // 全局格式text默认或 json控制启动/停止日志
Access AccessLogConfig `yaml:"access"` // 访问日志
Error ErrorLogConfig `yaml:"error"` // 错误日志
}
// AccessLogConfig 访问日志配置。

View File

@ -96,8 +96,12 @@ func DefaultConfig() *Config {
},
},
Logging: LoggingConfig{
Format: "text",
Access: AccessLogConfig{
Format: "$remote_addr - $request - $status - $body_bytes_sent",
// 近似 nginx combined 格式
// nginx: $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
// lolly: $request 不含 HTTP 版本,$time 为 RFC3339 格式
Format: "$remote_addr - $remote_user [$time] \"$request\" $status $body_bytes_sent \"$http_referer\" \"$http_user_agent\"",
},
Error: ErrorLogConfig{
Level: "info",
@ -348,9 +352,12 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
// logging 配置
buf.WriteString("# 日志配置\n")
buf.WriteString("logging:\n")
buf.WriteString(fmt.Sprintf(" format: \"%s\" # 全局日志格式(有效值: text, json控制启动/停止日志格式\n", cfg.Logging.Format))
buf.WriteString(" access:\n")
buf.WriteString(" path: \"\" # 日志文件路径(空表示输出到 stdout\n")
buf.WriteString(fmt.Sprintf(" format: \"%s\" # 日志格式(支持变量: $remote_addr, $request, $status, $body_bytes_sent, $request_time, $http_referer, $http_user_agent\n", cfg.Logging.Access.Format))
buf.WriteString(fmt.Sprintf(" format: \"%s\" # 访问日志格式,近似 nginx combined\n", cfg.Logging.Access.Format))
buf.WriteString(" # 支持变量: $remote_addr, $remote_user, $request, $status, $body_bytes_sent, $request_time, $http_referer, $http_user_agent, $time\n")
buf.WriteString(" # 特殊值 \"json\" 输出结构化 JSON\n")
buf.WriteString(" error:\n")
buf.WriteString(" path: \"\" # 日志文件路径(空表示输出到 stderr\n")
buf.WriteString(fmt.Sprintf(" level: \"%s\" # 日志级别(有效值: debug, info, warn, error级别越高日志越少\n", cfg.Logging.Error.Level))

View File

@ -1,8 +1,10 @@
package logging
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
@ -13,10 +15,19 @@ import (
// Logger 日志管理器,分离访问日志和错误日志。
type Logger struct {
accessLog zerolog.Logger
errorLog zerolog.Logger
accessFile *os.File
errorFile *os.File
accessLog zerolog.Logger
errorLog zerolog.Logger
accessFormat string // 访问日志格式模板
accessWriter io.Writer // 访问日志输出目标
accessFile *os.File
errorFile *os.File
}
// AppLogger 应用日志管理器,统一管理启动/停止日志。
type AppLogger struct {
format string // "text" 或 "json"
errorLog zerolog.Logger
writer io.Writer
}
var log zerolog.Logger
@ -37,9 +48,13 @@ func New(cfg *config.LoggingConfig) *Logger {
cfg = &config.LoggingConfig{}
}
accessWriter := getOutput(cfg.Access.Path)
logger := &Logger{
accessLog: zerolog.New(getOutput(cfg.Access.Path)).With().Timestamp().Logger(),
errorLog: zerolog.New(getOutput(cfg.Error.Path)).Level(parseLevel(cfg.Error.Level)).With().Timestamp().Logger(),
accessFormat: cfg.Access.Format,
accessWriter: accessWriter,
accessLog: zerolog.New(accessWriter).With().Timestamp().Logger(),
errorLog: zerolog.New(getOutput(cfg.Error.Path)).Level(parseLevel(cfg.Error.Level)).With().Timestamp().Logger(),
}
return logger
@ -74,17 +89,54 @@ func LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.D
Msg("request")
}
// LogAccessWithLogger 使用 Logger 实例记录访问日志nginx 格式变量)
// LogAccess 记录访问日志,支持模板格式或 JSON
func (l *Logger) LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) {
l.accessLog.Info().
Str("remote_addr", ctx.RemoteAddr().String()).
Str("request", string(ctx.Method())+" "+string(ctx.Path())).
Int("status", status).
Int64("body_bytes_sent", size).
Dur("request_time", duration).
Str("http_referrer", string(ctx.Request.Header.Peek("Referer"))).
Str("http_user_agent", string(ctx.Request.Header.Peek("User-Agent"))).
Msg("")
// JSON 格式或空格式:输出结构化 JSON
if l.accessFormat == "json" || l.accessFormat == "" {
l.accessLog.Info().
Str("remote_addr", ctx.RemoteAddr().String()).
Str("request", string(ctx.Method())+" "+string(ctx.Path())).
Int("status", status).
Int64("body_bytes_sent", size).
Dur("request_time", duration).
Str("http_referrer", string(ctx.Request.Header.Peek("Referer"))).
Str("http_user_agent", string(ctx.Request.Header.Peek("User-Agent"))).
Msg("")
return
}
// 模板格式:直接输出纯文本
output := l.formatAccessLog(ctx, status, size, duration)
fmt.Fprintln(l.accessWriter, output)
}
// formatAccessLog 根据模板格式化访问日志。
func (l *Logger) formatAccessLog(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) string {
// 获取认证用户名,无认证时为 "-"
remoteUser := "-"
if user := ctx.UserValue("remote_user"); user != nil {
if username, ok := user.(string); ok && username != "" {
remoteUser = username
}
}
replacements := map[string]string{
"$remote_addr": ctx.RemoteAddr().String(),
"$remote_user": remoteUser,
"$request": string(ctx.Method()) + " " + string(ctx.Path()) + " " + string(ctx.Request.Header.Protocol()),
"$status": strconv.Itoa(status),
"$body_bytes_sent": strconv.FormatInt(size, 10),
"$request_time": fmt.Sprintf("%.6f", duration.Seconds()),
"$http_referer": string(ctx.Request.Header.Peek("Referer")),
"$http_user_agent": string(ctx.Request.Header.Peek("User-Agent")),
"$time": time.Now().Format(time.RFC3339),
}
result := l.accessFormat
for varName, value := range replacements {
result = strings.ReplaceAll(result, varName, value)
}
return result
}
// Debug 返回 Debug 级别日志记录器。
@ -138,3 +190,82 @@ func parseLevel(level string) zerolog.Level {
return zerolog.InfoLevel
}
}
// NewAppLogger 创建应用日志管理器。
func NewAppLogger(cfg *config.LoggingConfig) *AppLogger {
if cfg == nil {
cfg = &config.LoggingConfig{}
}
format := cfg.Format
if format == "" {
format = "text" // 默认纯文本
}
writer := getOutput(cfg.Error.Path)
errorLog := zerolog.New(writer).Level(parseLevel(cfg.Error.Level)).With().Timestamp().Logger()
return &AppLogger{
format: format,
errorLog: errorLog,
writer: writer,
}
}
// LogStartup 记录启动消息。
func (l *AppLogger) LogStartup(msg string, fields map[string]string) {
if l.format == "json" {
event := l.errorLog.Info()
for k, v := range fields {
event.Str(k, v)
}
event.Msg(msg)
return
}
// 纯文本格式
timestamp := time.Now().Format("2006-01-02 15:04:05")
if len(fields) == 0 {
fmt.Fprintf(l.writer, "[%s] INFO %s\n", timestamp, msg)
return
}
// 带字段的文本格式
extra := ""
for k, v := range fields {
extra += fmt.Sprintf(" %s=%s", k, v)
}
fmt.Fprintf(l.writer, "[%s] INFO %s%s\n", timestamp, msg, extra)
}
// LogShutdown 记录停止消息。
func (l *AppLogger) LogShutdown(msg string) {
if l.format == "json" {
l.errorLog.Info().Msg(msg)
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(l.writer, "[%s] INFO %s\n", timestamp, msg)
}
// LogSignal 记录信号处理消息。
func (l *AppLogger) LogSignal(sig string, action string) {
if l.format == "json" {
l.errorLog.Info().Str("signal", sig).Str("action", action).Msg("")
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(l.writer, "[%s] INFO 收到 %s%s\n", timestamp, sig, action)
}
// Info 返回 Info 级别日志记录器。
func (l *AppLogger) Info() *zerolog.Event {
return l.errorLog.Info()
}
// Error 返回 Error 级别日志记录器。
func (l *AppLogger) Error() *zerolog.Event {
return l.errorLog.Error()
}

View File

@ -204,7 +204,10 @@ func (ba *BasicAuth) Process(next fasthttp.RequestHandler) fasthttp.RequestHandl
return
}
// 认证成功,继续执行下一个处理器
// 认证成功,存储用户名到上下文(用于访问日志 $remote_user
ctx.SetUserValue("remote_user", username)
// 继续执行下一个处理器
next(ctx)
}
}