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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user