Files
MonocoEditor-With-Lsp-Backend/backend/cmd/server/main.go

1088 lines
34 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 main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"os/user"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"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"
"monica-go-completion-backend/internal/monitor"
)
var requestIDSeed atomic.Int64
// config 汇总服务启动所需的环境配置。
type config struct {
Port string // HTTP 监听端口,如 8080。
// WorkspaceDir 是 LSP 进程使用的工作区根目录。
WorkspaceDir string
// AllowOrigin 控制 CORS 的 Access-Control-Allow-Origin。
AllowOrigin string
// APIToken 为可选 API Key为空时不启用鉴权。
APIToken string
// RequestTimeout 为单次补全请求的超时时间。
RequestTimeout time.Duration
// MaxBodyBytes 为 HTTP/WS 消息体最大字节数。
MaxBodyBytes int64
// SessionTTL 为会话空闲超时,超过后会被清理。
SessionTTL time.Duration
// CleanupInterval 为后台扫描并清理闲置会话的周期。
CleanupInterval time.Duration
// MaxSessions 限制本实例最多持有的活跃会话数。
MaxSessions int
// InstanceID 是当前实例唯一标识,用于分布式会话归属判断。
InstanceID string
// InstanceURL 是当前实例可被路由到的对外地址。
InstanceURL string
// EnableRedis 控制是否启用 Redis 粘性路由/会话归属。
EnableRedis bool
// RedisAddr 为 Redis 地址,例如 127.0.0.1:6379。
RedisAddr string
// RedisPassword 为 Redis 密码,可为空。
RedisPassword string
// RedisDB 为 Redis 数据库编号。
RedisDB int
// RedisKeyPrefix 为 Redis 键前缀,避免与其他业务冲突。
RedisKeyPrefix string
// InstanceTTL 为实例元数据在 Redis 中的过期时间。
InstanceTTL time.Duration
// Heartbeat 为实例心跳刷新周期。
Heartbeat time.Duration
// EnableNacosRegister 控制是否在启动时向 Nacos 注册实例。
EnableNacosRegister bool
// NacosServerAddr 为 Nacos 地址host:port
NacosServerAddr string
// NacosNamespace 为 Nacos namespace可为空。
NacosNamespace string
// NacosGroup 为 Nacos group默认 DEFAULT_GROUP。
NacosGroup string
// NacosServiceName 为当前服务在 Nacos 中的服务名。
NacosServiceName string
// NacosClusterName 为 Nacos clusterName可为空。
NacosClusterName string
// NacosUsername 为 Nacos 认证用户名,可为空。
NacosUsername string
// NacosPassword 为 Nacos 认证密码,可为空。
NacosPassword string
// NacosRegisterIP 为实例向 Nacos 注册时使用的 IP。
NacosRegisterIP string
// NacosRegisterPort 为实例向 Nacos 注册时使用的端口。
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
}
// fileConfig 对应 JSON 配置文件格式,使用指针区分“未设置”和“显式设置”。
type fileConfig struct {
Port *string `json:"port"`
WorkspaceDir *string `json:"workspaceDir"`
AllowOrigin *string `json:"allowOrigin"`
APIToken *string `json:"apiToken"`
RequestTimeout *string `json:"requestTimeout"`
MaxBodyBytes *int64 `json:"maxBodyBytes"`
SessionTTL *string `json:"sessionTTL"`
CleanupInterval *string `json:"cleanupInterval"`
MaxSessions *int `json:"maxSessions"`
InstanceID *string `json:"instanceID"`
InstanceURL *string `json:"instanceURL"`
EnableRedis *bool `json:"enableRedis"`
RedisAddr *string `json:"redisAddr"`
RedisPassword *string `json:"redisPassword"`
RedisDB *int `json:"redisDB"`
RedisKeyPrefix *string `json:"redisKeyPrefix"`
InstanceTTL *string `json:"instanceTTL"`
Heartbeat *string `json:"heartbeat"`
EnableNacosRegister *bool `json:"enableNacosRegister"`
NacosServerAddr *string `json:"nacosServerAddr"`
NacosNamespace *string `json:"nacosNamespace"`
NacosGroup *string `json:"nacosGroup"`
NacosServiceName *string `json:"nacosServiceName"`
NacosClusterName *string `json:"nacosClusterName"`
NacosUsername *string `json:"nacosUsername"`
NacosPassword *string `json:"nacosPassword"`
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"`
}
func main() {
// 读取环境变量并组装运行配置。
cfg, err := loadConfig()
if err != nil {
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 注册中心用于多实例下的会话归属协调(粘性路由)。
var err error
registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
KeyPrefix: cfg.RedisKeyPrefix,
InstanceID: cfg.InstanceID,
InstanceEndpoint: cfg.InstanceURL,
SessionTTL: cfg.SessionTTL,
InstanceTTL: cfg.InstanceTTL,
HeartbeatInterval: cfg.Heartbeat,
})
if err != nil {
log.Fatalf("create redis registry failed: %v", err)
}
}
var nacosRegistry *cluster.NacosRegistry
if cfg.EnableNacosRegister {
// Nacos 负责服务发现Redis 仍负责会话归属与粘性路由。
var err error
nacosRegistry, err = cluster.NewNacosRegistry(cluster.NacosRegistryConfig{
ServerAddr: cfg.NacosServerAddr,
Namespace: cfg.NacosNamespace,
Group: cfg.NacosGroup,
ServiceName: cfg.NacosServiceName,
ClusterName: cfg.NacosClusterName,
Username: cfg.NacosUsername,
Password: cfg.NacosPassword,
IP: cfg.NacosRegisterIP,
Port: uint64(cfg.NacosRegisterPort),
Ephemeral: cfg.NacosEphemeral,
Metadata: map[string]string{
"instanceId": cfg.InstanceID,
"instanceUrl": cfg.InstanceURL,
"component": "lsp-gateway",
},
})
if err != nil {
log.Fatalf("register nacos instance failed: %v", err)
}
}
clientFactory := func(ctx context.Context, spec completion.LanguageServerSpec, workspaceDir string) (completion.RuntimeClient, error) {
return lsp.NewClient(ctx, lsp.Config{
Command: spec.Command,
Args: spec.Args,
RootPath: workspaceDir,
LanguageID: spec.LanguageID,
ClientName: "monica-lsp-gateway",
})
}
manager := completion.NewManager(completion.ManagerConfig{
WorkspaceDir: cfg.WorkspaceDir,
MaxSessions: cfg.MaxSessions,
SessionTTL: cfg.SessionTTL,
CleanupInterval: cfg.CleanupInterval,
InstanceID: cfg.InstanceID,
Registry: registry,
}, cfg.Servers, clientFactory)
defer func() {
_ = manager.Close()
}()
// 后台预热各语言 LSP 会话,首次请求无需等待冷启动。
go manager.WarmUp(context.Background())
// 后台探测各语言 LSP 可用性demo 实现:周期性握手探测)。
lspStatusMonitor := monitor.NewLSPStatusMonitor(cfg.Servers, cfg.WorkspaceDir, clientFactory, monitor.Config{
ProbeInterval: 60 * time.Second,
ProbeTimeout: 30 * time.Second,
FailureThreshold: 2,
})
defer lspStatusMonitor.Close()
// 注册通用中间件与业务路由。
router := gin.New()
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))
api.RegisterRoutes(router, manager, api.RouteOptions{
RequestTimeout: cfg.RequestTimeout,
MaxBodyBytes: cfg.MaxBodyBytes,
LSPStatusProvider: lspStatusMonitor,
})
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
// HTTP 服务主循环,非正常退出直接终止进程。
log.Printf(
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t, nacos=%t",
cfg.Port,
cfg.WorkspaceDir,
cfg.InstanceID,
cfg.EnableRedis,
cfg.EnableNacosRegister,
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server failed: %v", err)
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
// 收到退出信号后,按超时窗口优雅关闭。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown failed: %v", err)
}
if err := manager.Close(); err != nil {
log.Printf("lsp manager close failed: %v", err)
}
if nacosRegistry != nil {
if err := nacosRegistry.Close(); err != nil {
log.Printf("nacos deregister failed: %v", err)
}
}
}
// loadConfig 从环境变量读取配置并应用默认值。
func loadConfig() (config, error) {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
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,
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 {
fileCfg, err := readConfigFile(configPath)
if err != nil {
return config{}, fmt.Errorf("read config file %q: %w", configPath, err)
}
if err := applyFileConfig(&cfg, fileCfg); err != nil {
return config{}, fmt.Errorf("apply config file %q: %w", configPath, err)
}
log.Printf("config file loaded: %s", configPath)
} else {
log.Printf("config file not found, using defaults + environment variables")
}
applyEnvOverrides(&cfg)
finalizeConfig(&cfg, cwd)
log.Printf(
"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
}
func resolveConfigFilePath() (string, bool) {
explicitPath := strings.TrimSpace(os.Getenv("CONFIG_FILE"))
if explicitPath != "" {
return explicitPath, true
}
const defaultPath = "config.json"
info, err := os.Stat(defaultPath)
if err == nil && !info.IsDir() {
return defaultPath, true
}
return "", false
}
func readConfigFile(path string) (fileConfig, error) {
body, err := os.ReadFile(path)
if err != nil {
return fileConfig{}, err
}
var cfg fileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
return fileConfig{}, err
}
return cfg, nil
}
func applyFileConfig(cfg *config, fc fileConfig) error {
if fc.Port != nil {
cfg.Port = strings.TrimSpace(*fc.Port)
}
if fc.WorkspaceDir != nil {
cfg.WorkspaceDir = strings.TrimSpace(*fc.WorkspaceDir)
}
if fc.AllowOrigin != nil {
cfg.AllowOrigin = strings.TrimSpace(*fc.AllowOrigin)
}
if fc.APIToken != nil {
cfg.APIToken = strings.TrimSpace(*fc.APIToken)
}
if fc.RequestTimeout != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.RequestTimeout))
if err != nil {
return fmt.Errorf("requestTimeout: %w", err)
}
cfg.RequestTimeout = d
}
if fc.MaxBodyBytes != nil {
cfg.MaxBodyBytes = *fc.MaxBodyBytes
}
if fc.SessionTTL != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.SessionTTL))
if err != nil {
return fmt.Errorf("sessionTTL: %w", err)
}
cfg.SessionTTL = d
}
if fc.CleanupInterval != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.CleanupInterval))
if err != nil {
return fmt.Errorf("cleanupInterval: %w", err)
}
cfg.CleanupInterval = d
}
if fc.MaxSessions != nil {
cfg.MaxSessions = *fc.MaxSessions
}
if fc.InstanceID != nil {
cfg.InstanceID = strings.TrimSpace(*fc.InstanceID)
}
if fc.InstanceURL != nil {
cfg.InstanceURL = strings.TrimSpace(*fc.InstanceURL)
}
if fc.EnableRedis != nil {
cfg.EnableRedis = *fc.EnableRedis
}
if fc.RedisAddr != nil {
cfg.RedisAddr = strings.TrimSpace(*fc.RedisAddr)
}
if fc.RedisPassword != nil {
cfg.RedisPassword = strings.TrimSpace(*fc.RedisPassword)
}
if fc.RedisDB != nil {
cfg.RedisDB = *fc.RedisDB
}
if fc.RedisKeyPrefix != nil {
cfg.RedisKeyPrefix = strings.TrimSpace(*fc.RedisKeyPrefix)
}
if fc.InstanceTTL != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.InstanceTTL))
if err != nil {
return fmt.Errorf("instanceTTL: %w", err)
}
cfg.InstanceTTL = d
}
if fc.Heartbeat != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.Heartbeat))
if err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
cfg.Heartbeat = d
}
if fc.EnableNacosRegister != nil {
cfg.EnableNacosRegister = *fc.EnableNacosRegister
}
if fc.NacosServerAddr != nil {
cfg.NacosServerAddr = strings.TrimSpace(*fc.NacosServerAddr)
}
if fc.NacosNamespace != nil {
cfg.NacosNamespace = strings.TrimSpace(*fc.NacosNamespace)
}
if fc.NacosGroup != nil {
cfg.NacosGroup = strings.TrimSpace(*fc.NacosGroup)
}
if fc.NacosServiceName != nil {
cfg.NacosServiceName = strings.TrimSpace(*fc.NacosServiceName)
}
if fc.NacosClusterName != nil {
cfg.NacosClusterName = strings.TrimSpace(*fc.NacosClusterName)
}
if fc.NacosUsername != nil {
cfg.NacosUsername = strings.TrimSpace(*fc.NacosUsername)
}
if fc.NacosPassword != nil {
cfg.NacosPassword = strings.TrimSpace(*fc.NacosPassword)
}
if fc.NacosRegisterIP != nil {
cfg.NacosRegisterIP = strings.TrimSpace(*fc.NacosRegisterIP)
}
if fc.NacosRegisterPort != nil {
cfg.NacosRegisterPort = *fc.NacosRegisterPort
}
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)
}
return nil
}
func applyEnvOverrides(cfg *config) {
cfg.Port = getenv("PORT", cfg.Port)
cfg.WorkspaceDir = getenv("WORKSPACE_DIR", cfg.WorkspaceDir)
cfg.AllowOrigin = getenv("CORS_ALLOW_ORIGIN", cfg.AllowOrigin)
cfg.APIToken = getenv("LSP_API_TOKEN", cfg.APIToken)
cfg.RequestTimeout = getenvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout)
cfg.MaxBodyBytes = getenvInt64("MAX_BODY_BYTES", cfg.MaxBodyBytes)
cfg.SessionTTL = getenvDuration("SESSION_TTL", cfg.SessionTTL)
cfg.CleanupInterval = getenvDuration("SESSION_CLEANUP_INTERVAL", cfg.CleanupInterval)
cfg.MaxSessions = getenvInt("MAX_SESSIONS", cfg.MaxSessions)
cfg.InstanceID = getenv("INSTANCE_ID", cfg.InstanceID)
cfg.InstanceURL = getenv("INSTANCE_URL", cfg.InstanceURL)
cfg.EnableRedis = getenvBool("ENABLE_REDIS_STICKY", cfg.EnableRedis)
cfg.RedisAddr = getenv("REDIS_ADDR", cfg.RedisAddr)
cfg.RedisPassword = getenv("REDIS_PASSWORD", cfg.RedisPassword)
cfg.RedisDB = getenvInt("REDIS_DB", cfg.RedisDB)
cfg.RedisKeyPrefix = getenv("REDIS_KEY_PREFIX", cfg.RedisKeyPrefix)
cfg.InstanceTTL = getenvDuration("INSTANCE_TTL", cfg.InstanceTTL)
cfg.Heartbeat = getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", cfg.Heartbeat)
cfg.EnableNacosRegister = getenvBool("ENABLE_NACOS_REGISTER", cfg.EnableNacosRegister)
cfg.NacosServerAddr = getenv("NACOS_SERVER_ADDR", cfg.NacosServerAddr)
cfg.NacosNamespace = getenv("NACOS_NAMESPACE", cfg.NacosNamespace)
cfg.NacosGroup = getenv("NACOS_GROUP", cfg.NacosGroup)
cfg.NacosServiceName = getenv("NACOS_SERVICE_NAME", cfg.NacosServiceName)
cfg.NacosClusterName = getenv("NACOS_CLUSTER_NAME", cfg.NacosClusterName)
cfg.NacosUsername = getenv("NACOS_USERNAME", cfg.NacosUsername)
cfg.NacosPassword = getenv("NACOS_PASSWORD", cfg.NacosPassword)
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)
}
func finalizeConfig(cfg *config, cwd string) {
if strings.TrimSpace(cfg.Port) == "" {
cfg.Port = "8080"
}
if strings.TrimSpace(cfg.WorkspaceDir) == "" {
cfg.WorkspaceDir = cwd
}
if strings.TrimSpace(cfg.AllowOrigin) == "" {
cfg.AllowOrigin = "*"
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 10 * time.Second
}
if cfg.MaxBodyBytes <= 0 {
cfg.MaxBodyBytes = 2 << 20
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 20 * time.Minute
}
if cfg.CleanupInterval <= 0 {
cfg.CleanupInterval = 2 * time.Minute
}
if cfg.MaxSessions <= 0 {
cfg.MaxSessions = 256
}
if strings.TrimSpace(cfg.InstanceID) == "" {
cfg.InstanceID = defaultInstanceID()
}
if strings.TrimSpace(cfg.InstanceURL) == "" {
cfg.InstanceURL = "http://127.0.0.1:" + cfg.Port
}
if strings.TrimSpace(cfg.RedisAddr) == "" {
cfg.RedisAddr = "10.0.0.10:6379"
}
if strings.TrimSpace(cfg.RedisKeyPrefix) == "" {
cfg.RedisKeyPrefix = "lsp-gateway"
}
if cfg.InstanceTTL <= 0 {
cfg.InstanceTTL = 30 * time.Second
}
if cfg.Heartbeat <= 0 {
cfg.Heartbeat = 10 * time.Second
}
if strings.TrimSpace(cfg.NacosServerAddr) == "" {
cfg.NacosServerAddr = "10.0.0.10:8848"
}
if strings.TrimSpace(cfg.NacosGroup) == "" {
cfg.NacosGroup = "DEFAULT_GROUP"
}
if strings.TrimSpace(cfg.NacosServiceName) == "" {
cfg.NacosServiceName = "lsp-gateway"
}
if cfg.NacosRegisterPort <= 0 {
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()
}
}
func parsePortOrDefault(port string, fallback int) int {
trimmed := strings.TrimSpace(port)
if trimmed == "" {
return fallback
}
n, err := strconv.Atoi(trimmed)
if err != nil || n <= 0 {
return fallback
}
return n
}
func defaultLanguageServers() []completion.LanguageServerSpec {
return []completion.LanguageServerSpec{
{
Language: "go",
LanguageID: "go",
Command: "gopls",
},
{
Language: "javascript",
LanguageID: "javascript",
Command: "typescript-language-server",
Args: []string{"--stdio"},
},
{
Language: "typescript",
LanguageID: "typescript",
Command: "typescript-language-server",
Args: []string{"--stdio"},
},
}
}
func normalizeServerSpecs(specs []completion.LanguageServerSpec) []completion.LanguageServerSpec {
out := make([]completion.LanguageServerSpec, 0, len(specs))
for _, spec := range specs {
spec.Language = strings.TrimSpace(spec.Language)
spec.LanguageID = strings.TrimSpace(spec.LanguageID)
spec.Command = strings.TrimSpace(spec.Command)
out = append(out, spec)
}
return out
}
func applyLanguageServerEnvOverrides(servers []completion.LanguageServerSpec) []completion.LanguageServerSpec {
overridden := normalizeServerSpecs(servers)
overridden = applySingleLanguageServerEnv(overridden, "go", "go", "GO_LSP_COMMAND", "GO_LSP_ARGS", "gopls", "")
overridden = applySingleLanguageServerEnv(overridden, "javascript", "javascript", "JAVASCRIPT_LSP_COMMAND", "JAVASCRIPT_LSP_ARGS", "typescript-language-server", "--stdio")
overridden = applySingleLanguageServerEnv(overridden, "typescript", "typescript", "TYPESCRIPT_LSP_COMMAND", "TYPESCRIPT_LSP_ARGS", "typescript-language-server", "--stdio")
return overridden
}
func applySingleLanguageServerEnv(
servers []completion.LanguageServerSpec,
language string,
defaultLanguageID string,
commandEnv string,
argsEnv string,
defaultCommand string,
defaultArgs string,
) []completion.LanguageServerSpec {
index := findServerSpecIndex(servers, language)
if index < 0 {
servers = append(servers, completion.LanguageServerSpec{
Language: language,
LanguageID: defaultLanguageID,
})
index = len(servers) - 1
}
spec := servers[index]
if strings.TrimSpace(spec.Language) == "" {
spec.Language = language
}
if strings.TrimSpace(spec.LanguageID) == "" {
spec.LanguageID = defaultLanguageID
}
if cmd, ok := lookupEnv(commandEnv); ok {
trimmed := strings.TrimSpace(cmd)
if trimmed == "" {
spec.Command = defaultCommand
} else {
spec.Command = trimmed
}
} else if strings.TrimSpace(spec.Command) == "" {
spec.Command = defaultCommand
}
if argsValue, ok := lookupEnv(argsEnv); ok {
spec.Args = splitArgs(argsValue)
} else if len(spec.Args) == 0 {
spec.Args = splitArgs(defaultArgs)
}
servers[index] = spec
return servers
}
func findServerSpecIndex(specs []completion.LanguageServerSpec, language string) int {
target := strings.ToLower(strings.TrimSpace(language))
for i, spec := range specs {
if strings.ToLower(strings.TrimSpace(spec.Language)) == target {
return i
}
}
return -1
}
func splitArgs(raw string) []string {
value := strings.TrimSpace(raw)
if value == "" {
return nil
}
return strings.Fields(value)
}
func lookupEnv(key string) (string, bool) {
v, ok := os.LookupEnv(key)
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()
if err != nil || strings.TrimSpace(host) == "" {
host = "unknown-host"
}
userName := "unknown-user"
if u, err := user.Current(); err == nil && strings.TrimSpace(u.Username) != "" {
userName = strings.ReplaceAll(u.Username, "\\", "-")
}
return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid())
}
// getenv 读取字符串环境变量,空值时返回 fallback。
func getenv(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
return v
}
// getenvArgs 按 shell 风格拆分参数列表。
func getenvArgs(key, fallback string) []string {
value := getenv(key, fallback)
if value == "" {
return nil
}
return strings.Fields(value)
}
// getenvInt 读取整数环境变量,解析失败时返回 fallback。
func getenvInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := strconv.Atoi(v)
if err == nil {
return parsed
}
return fallback
}
// getenvInt64 读取 int64 环境变量,解析失败时返回 fallback。
func getenvInt64(key string, fallback int64) int64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err == nil {
return parsed
}
return fallback
}
// getenvDuration 读取 time.Duration 格式的环境变量。
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return parsed
}
// getenvBool 读取布尔环境变量,支持常见开关写法。
func getenvBool(key string, fallback bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" {
return fallback
}
switch v {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
// corsMiddleware 处理跨域响应头与 OPTIONS 预检请求。
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", allowOrigin)
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-API-Key")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// apiTokenMiddleware 校验 X-API-Key未配置 token 时放行。
func apiTokenMiddleware(token string) gin.HandlerFunc {
required := strings.TrimSpace(token)
if required == "" {
return func(c *gin.Context) {
c.Next()
}
}
return func(c *gin.Context) {
provided := strings.TrimSpace(c.GetHeader("X-API-Key"))
if provided == required {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
}
// requestIDMiddleware 为请求补齐并回传 X-Request-Id。
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
}