feat: add Docker support and logging enhancements

- Introduced Dockerfile and .dockerignore for containerization of the backend service.
- Added logging configuration with support for daily rolling logs and customizable log levels.
- Enhanced the server to utilize structured logging with zap, including detailed request and error logging.
- Updated configuration to include logging parameters and proxy settings.
- Implemented tests for logging configuration and proxy settings.
This commit is contained in:
2026-02-15 18:24:48 +08:00
parent 3284ce07c7
commit eab464060b
9 changed files with 870 additions and 27 deletions

View File

@@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"os/user"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
@@ -16,10 +17,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"monica-go-completion-backend/internal/api"
"monica-go-completion-backend/internal/cluster"
"monica-go-completion-backend/internal/completion"
"monica-go-completion-backend/internal/logging"
"monica-go-completion-backend/internal/lsp"
)
@@ -84,6 +87,30 @@ type config struct {
NacosRegisterPort int
// NacosEphemeral 控制是否使用临时实例模式。
NacosEphemeral bool
// AppName 为日志目录与日志字段中的应用名。
AppName string
// AppEnv 为运行环境标识,如 dev/prod。
AppEnv string
// LogPath 为日志根目录。
LogPath string
// LogLevel 控制全局日志等级。
LogLevel string
// LogMaxSizeMB 控制单个日志文件滚动阈值MB
LogMaxSizeMB int
// LogMaxBackups 控制保留滚动文件数量。
LogMaxBackups int
// LogMaxAgeDays 控制日志保留天数。
LogMaxAgeDays int
// LogCompress 控制滚动日志是否压缩。
LogCompress bool
// LogConsoleEnabled 控制是否输出到控制台。
LogConsoleEnabled bool
// TrustedProxies 为受信代理列表IP/CIDR用于安全解析 X-Forwarded-For。
TrustedProxies []string
// RemoteIPHeaders 为提取客户端真实 IP 的请求头白名单。
RemoteIPHeaders []string
// ForwardedByClientIP 控制是否按代理头解析客户端 IP。
ForwardedByClientIP bool
// Servers 定义各语言 LSP 服务的启动命令与参数。
Servers []completion.LanguageServerSpec
}
@@ -119,6 +146,18 @@ type fileConfig struct {
NacosRegisterIP *string `json:"nacosRegisterIP"`
NacosRegisterPort *int `json:"nacosRegisterPort"`
NacosEphemeral *bool `json:"nacosEphemeral"`
AppName *string `json:"appName"`
AppEnv *string `json:"appEnv"`
LogPath *string `json:"logPath"`
LogLevel *string `json:"logLevel"`
LogMaxSizeMB *int `json:"logMaxSizeMB"`
LogMaxBackups *int `json:"logMaxBackups"`
LogMaxAgeDays *int `json:"logMaxAgeDays"`
LogCompress *bool `json:"logCompress"`
LogConsoleEnabled *bool `json:"logConsoleEnabled"`
TrustedProxies []string `json:"trustedProxies"`
RemoteIPHeaders []string `json:"remoteIPHeaders"`
ForwardedByClientIP *bool `json:"forwardedByClientIP"`
Servers []completion.LanguageServerSpec `json:"servers"`
}
@@ -129,6 +168,28 @@ func main() {
log.Fatalf("load config failed: %v", err)
}
appLogger, logCleanup, err := logging.New(logging.Config{
AppName: cfg.AppName,
Environment: cfg.AppEnv,
BasePath: cfg.LogPath,
Level: cfg.LogLevel,
MaxSizeMB: cfg.LogMaxSizeMB,
MaxBackups: cfg.LogMaxBackups,
MaxAgeDays: cfg.LogMaxAgeDays,
Compress: cfg.LogCompress,
ConsoleEnabled: cfg.LogConsoleEnabled,
})
if err != nil {
log.Fatalf("init logger failed: %v", err)
}
defer func() {
_ = logCleanup()
}()
restoreGlobals := zap.ReplaceGlobals(appLogger)
defer restoreGlobals()
restoreStdLog := zap.RedirectStdLog(appLogger)
defer restoreStdLog()
var registry completion.SessionRegistry
if cfg.EnableRedis {
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
@@ -196,9 +257,12 @@ func main() {
// 注册通用中间件与业务路由。
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
if err := configureProxySettings(router, cfg); err != nil {
log.Fatalf("configure trusted proxies failed: %v", err)
}
router.Use(requestIDMiddleware())
router.Use(accessLogMiddleware())
router.Use(recoveryMiddleware())
router.Use(corsMiddleware(cfg.AllowOrigin))
router.Use(apiTokenMiddleware(cfg.APIToken))
@@ -259,25 +323,35 @@ func loadConfig() (config, error) {
}
cfg := config{
Port: "8080",
WorkspaceDir: cwd,
AllowOrigin: "*",
RequestTimeout: 10 * time.Second,
MaxBodyBytes: 2 << 20,
SessionTTL: 20 * time.Minute,
CleanupInterval: 2 * time.Minute,
MaxSessions: 256,
EnableRedis: true,
RedisAddr: "10.0.0.10:6379",
RedisDB: 1,
RedisKeyPrefix: "lsp-gateway",
InstanceTTL: 30 * time.Second,
Heartbeat: 10 * time.Second,
NacosServerAddr: "10.0.0.10:8848",
NacosGroup: "DEFAULT_GROUP",
NacosServiceName: "lsp-gateway",
NacosEphemeral: true,
Servers: defaultLanguageServers(),
Port: "8080",
WorkspaceDir: cwd,
AllowOrigin: "*",
RequestTimeout: 10 * time.Second,
MaxBodyBytes: 2 << 20,
SessionTTL: 20 * time.Minute,
CleanupInterval: 2 * time.Minute,
MaxSessions: 256,
EnableRedis: true,
RedisAddr: "10.0.0.10:6379",
RedisDB: 1,
RedisKeyPrefix: "lsp-gateway",
InstanceTTL: 30 * time.Second,
Heartbeat: 10 * time.Second,
NacosServerAddr: "10.0.0.10:8848",
NacosGroup: "DEFAULT_GROUP",
NacosServiceName: "lsp-gateway",
NacosEphemeral: true,
AppName: "lsp-gateway",
AppEnv: "dev",
LogLevel: "info",
LogMaxSizeMB: 100,
LogMaxBackups: 31,
LogMaxAgeDays: 31,
LogCompress: true,
LogConsoleEnabled: true,
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
ForwardedByClientIP: true,
Servers: defaultLanguageServers(),
}
if configPath, ok := resolveConfigFilePath(); ok {
@@ -296,12 +370,15 @@ func loadConfig() (config, error) {
applyEnvOverrides(&cfg)
finalizeConfig(&cfg, cwd)
log.Printf(
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s",
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s, logPath=%s, logLevel=%s, trustedProxies=%d",
cfg.Port,
cfg.WorkspaceDir,
cfg.EnableRedis,
cfg.EnableNacosRegister,
cfg.NacosServerAddr,
cfg.LogPath,
cfg.LogLevel,
len(cfg.TrustedProxies),
)
return cfg, nil
}
@@ -439,6 +516,42 @@ func applyFileConfig(cfg *config, fc fileConfig) error {
if fc.NacosEphemeral != nil {
cfg.NacosEphemeral = *fc.NacosEphemeral
}
if fc.AppName != nil {
cfg.AppName = strings.TrimSpace(*fc.AppName)
}
if fc.AppEnv != nil {
cfg.AppEnv = strings.TrimSpace(*fc.AppEnv)
}
if fc.LogPath != nil {
cfg.LogPath = strings.TrimSpace(*fc.LogPath)
}
if fc.LogLevel != nil {
cfg.LogLevel = strings.TrimSpace(*fc.LogLevel)
}
if fc.LogMaxSizeMB != nil {
cfg.LogMaxSizeMB = *fc.LogMaxSizeMB
}
if fc.LogMaxBackups != nil {
cfg.LogMaxBackups = *fc.LogMaxBackups
}
if fc.LogMaxAgeDays != nil {
cfg.LogMaxAgeDays = *fc.LogMaxAgeDays
}
if fc.LogCompress != nil {
cfg.LogCompress = *fc.LogCompress
}
if fc.LogConsoleEnabled != nil {
cfg.LogConsoleEnabled = *fc.LogConsoleEnabled
}
if fc.TrustedProxies != nil {
cfg.TrustedProxies = filterEmptyStrings(fc.TrustedProxies)
}
if fc.RemoteIPHeaders != nil {
cfg.RemoteIPHeaders = filterEmptyStrings(fc.RemoteIPHeaders)
}
if fc.ForwardedByClientIP != nil {
cfg.ForwardedByClientIP = *fc.ForwardedByClientIP
}
if fc.Servers != nil {
cfg.Servers = normalizeServerSpecs(fc.Servers)
}
@@ -475,6 +588,18 @@ func applyEnvOverrides(cfg *config) {
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral)
cfg.AppName = getenv("APP_NAME", cfg.AppName)
cfg.AppEnv = getenv("APP_ENV", cfg.AppEnv)
cfg.LogPath = getenv("LOG_PATH", cfg.LogPath)
cfg.LogLevel = getenv("LOG_LEVEL", cfg.LogLevel)
cfg.LogMaxSizeMB = getenvInt("LOG_MAX_SIZE_MB", cfg.LogMaxSizeMB)
cfg.LogMaxBackups = getenvInt("LOG_MAX_BACKUPS", cfg.LogMaxBackups)
cfg.LogMaxAgeDays = getenvInt("LOG_MAX_AGE_DAYS", cfg.LogMaxAgeDays)
cfg.LogCompress = getenvBool("LOG_COMPRESS", cfg.LogCompress)
cfg.LogConsoleEnabled = getenvBool("LOG_CONSOLE_ENABLED", cfg.LogConsoleEnabled)
cfg.TrustedProxies = getenvCSV("TRUSTED_PROXIES", cfg.TrustedProxies)
cfg.RemoteIPHeaders = getenvCSV("REMOTE_IP_HEADERS", cfg.RemoteIPHeaders)
cfg.ForwardedByClientIP = getenvBool("FORWARDED_BY_CLIENT_IP", cfg.ForwardedByClientIP)
cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers)
}
@@ -534,6 +659,31 @@ func finalizeConfig(cfg *config, cwd string) {
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
}
cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL)
if strings.TrimSpace(cfg.AppName) == "" {
cfg.AppName = "lsp-gateway"
}
if strings.TrimSpace(cfg.AppEnv) == "" {
cfg.AppEnv = "dev"
}
if strings.TrimSpace(cfg.LogLevel) == "" {
cfg.LogLevel = "info"
}
if cfg.LogMaxSizeMB <= 0 {
cfg.LogMaxSizeMB = 100
}
if cfg.LogMaxBackups <= 0 {
cfg.LogMaxBackups = 31
}
if cfg.LogMaxAgeDays <= 0 {
cfg.LogMaxAgeDays = 31
}
if strings.TrimSpace(cfg.LogPath) == "" {
cfg.LogPath = filepath.Join(".", "logs", cfg.AppName)
}
if cfg.RemoteIPHeaders == nil || len(cfg.RemoteIPHeaders) == 0 {
cfg.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"}
}
cfg.TrustedProxies = filterEmptyStrings(cfg.TrustedProxies)
if len(cfg.Servers) == 0 {
cfg.Servers = defaultLanguageServers()
}
@@ -662,6 +812,40 @@ func lookupEnv(key string) (string, bool) {
return v, ok
}
func filterEmptyStrings(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed != "" {
out = append(out, trimmed)
}
}
if len(out) == 0 {
return nil
}
return out
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
return filterEmptyStrings(parts)
}
func getenvCSV(key string, fallback []string) []string {
v, ok := lookupEnv(key)
if !ok {
return fallback
}
parsed := splitCSV(v)
if parsed == nil {
return nil
}
return parsed
}
// defaultInstanceID 生成默认实例 ID便于在日志和 Redis 中区分节点。
func defaultInstanceID() string {
host, err := os.Hostname()
@@ -786,11 +970,103 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
if rid == "" {
rid = strings.TrimSpace(c.GetHeader("X-Trace-Id"))
}
if rid == "" {
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
}
c.Header("X-Request-Id", rid)
c.Header("X-Trace-Id", rid)
c.Set("requestId", rid)
c.Set("traceId", rid)
c.Next()
}
}
// accessLogMiddleware 输出统一访问日志,便于排查前端请求链路。
func accessLogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
traceID := strings.TrimSpace(c.GetString("traceId"))
if traceID == "" {
traceID = strings.TrimSpace(c.GetString("requestId"))
}
fields := []zap.Field{
zap.String("traceId", traceID),
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.String("rawPath", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", latency),
zap.String("clientIP", c.ClientIP()),
zap.String("userAgent", c.Request.UserAgent()),
}
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
switch status := c.Writer.Status(); {
case status >= http.StatusInternalServerError:
zap.L().Error("http request finished", fields...)
case status >= http.StatusBadRequest:
zap.L().Warn("http request finished", fields...)
default:
zap.L().Info("http request finished", fields...)
}
}
}
// recoveryMiddleware 捕获 panic 并落盘错误日志,避免进程崩溃无痕。
func recoveryMiddleware() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
traceID := strings.TrimSpace(c.GetString("traceId"))
if traceID == "" {
traceID = strings.TrimSpace(c.GetString("requestId"))
}
zap.L().Error(
"panic recovered",
zap.Any("panic", recovered),
zap.String("traceId", traceID),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("clientIP", c.ClientIP()),
)
c.AbortWithStatus(http.StatusInternalServerError)
})
}
func configureProxySettings(router *gin.Engine, cfg config) error {
if router == nil {
return fmt.Errorf("router is nil")
}
router.ForwardedByClientIP = cfg.ForwardedByClientIP
router.RemoteIPHeaders = append([]string(nil), cfg.RemoteIPHeaders...)
if len(cfg.TrustedProxies) == 0 {
if err := router.SetTrustedProxies(nil); err != nil {
return err
}
zap.L().Warn(
"trusted proxies disabled; using direct peer IP",
zap.Strings("remoteIPHeaders", router.RemoteIPHeaders),
zap.Bool("forwardedByClientIP", router.ForwardedByClientIP),
)
return nil
}
if err := router.SetTrustedProxies(cfg.TrustedProxies); err != nil {
return err
}
zap.L().Info(
"trusted proxies configured",
zap.Strings("trustedProxies", cfg.TrustedProxies),
zap.Strings("remoteIPHeaders", router.RemoteIPHeaders),
zap.Bool("forwardedByClientIP", router.ForwardedByClientIP),
)
return nil
}