- Add Nacos registry for service registration and deregistration. - Implement Redis registry for session management with heartbeat and session claiming. - Improve completion service with session handling and request validation. - Enhance WebSocket handling for completion requests with JSON-RPC support. - Add tests for new registry implementations and completion manager functionalities. - Refactor existing code for better readability and maintainability.
797 lines
24 KiB
Go
797 lines
24 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"os/user"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"monica-go-completion-backend/internal/api"
|
||
"monica-go-completion-backend/internal/cluster"
|
||
"monica-go-completion-backend/internal/completion"
|
||
"monica-go-completion-backend/internal/lsp"
|
||
)
|
||
|
||
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
|
||
// 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"`
|
||
Servers []completion.LanguageServerSpec `json:"servers"`
|
||
}
|
||
|
||
func main() {
|
||
// 读取环境变量并组装运行配置。
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
log.Fatalf("load config failed: %v", err)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
manager := completion.NewManager(completion.ManagerConfig{
|
||
WorkspaceDir: cfg.WorkspaceDir,
|
||
MaxSessions: cfg.MaxSessions,
|
||
SessionTTL: cfg.SessionTTL,
|
||
CleanupInterval: cfg.CleanupInterval,
|
||
InstanceID: cfg.InstanceID,
|
||
Registry: registry,
|
||
}, cfg.Servers, 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",
|
||
})
|
||
})
|
||
defer func() {
|
||
_ = manager.Close()
|
||
}()
|
||
|
||
// 注册通用中间件与业务路由。
|
||
router := gin.New()
|
||
router.Use(gin.Logger())
|
||
router.Use(gin.Recovery())
|
||
router.Use(requestIDMiddleware())
|
||
router.Use(corsMiddleware(cfg.AllowOrigin))
|
||
router.Use(apiTokenMiddleware(cfg.APIToken))
|
||
|
||
api.RegisterRoutes(router, manager, api.RouteOptions{
|
||
RequestTimeout: cfg.RequestTimeout,
|
||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||
})
|
||
|
||
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,
|
||
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",
|
||
cfg.Port,
|
||
cfg.WorkspaceDir,
|
||
cfg.EnableRedis,
|
||
cfg.EnableNacosRegister,
|
||
cfg.NacosServerAddr,
|
||
)
|
||
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.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.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 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
|
||
}
|
||
|
||
// 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 = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
|
||
}
|
||
c.Header("X-Request-Id", rid)
|
||
c.Set("requestId", rid)
|
||
c.Next()
|
||
}
|
||
}
|