feat: enhance API and session management with Nacos and Redis integration
- 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.
This commit is contained in:
90
backend/cmd/server/config_test.go
Normal file
90
backend/cmd/server/config_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
)
|
||||
|
||||
func TestLoadConfigFileWithEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
content := `{
|
||||
"port": "8081",
|
||||
"requestTimeout": "3s",
|
||||
"enableRedis": false,
|
||||
"servers": [
|
||||
{
|
||||
"language": "go",
|
||||
"languageId": "go",
|
||||
"command": "gopls-from-file",
|
||||
"args": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write config file failed: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("CONFIG_FILE", path)
|
||||
t.Setenv("PORT", "9090")
|
||||
t.Setenv("GO_LSP_COMMAND", "gopls-from-env")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "9090" {
|
||||
t.Fatalf("expected env to override port, got %s", cfg.Port)
|
||||
}
|
||||
if cfg.RequestTimeout != 3*time.Second {
|
||||
t.Fatalf("expected requestTimeout=3s, got %s", cfg.RequestTimeout)
|
||||
}
|
||||
if cfg.EnableRedis {
|
||||
t.Fatal("expected enableRedis=false from config file")
|
||||
}
|
||||
|
||||
goSpec, ok := findServer(cfg.Servers, "go")
|
||||
if !ok {
|
||||
t.Fatal("expected go server in config")
|
||||
}
|
||||
if goSpec.Command != "gopls-from-env" {
|
||||
t.Fatalf("expected go command overridden by env, got %s", goSpec.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFileInvalidDuration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
content := `{
|
||||
"requestTimeout": "not-a-duration"
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write config file failed: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("CONFIG_FILE", path)
|
||||
|
||||
_, err := loadConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected loadConfig() to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requestTimeout") {
|
||||
t.Fatalf("expected requestTimeout parse error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findServer(specs []completion.LanguageServerSpec, language string) (completion.LanguageServerSpec, bool) {
|
||||
target := strings.ToLower(strings.TrimSpace(language))
|
||||
for _, spec := range specs {
|
||||
if strings.ToLower(strings.TrimSpace(spec.Language)) == target {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return completion.LanguageServerSpec{}, false
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -24,33 +25,113 @@ import (
|
||||
|
||||
var requestIDSeed atomic.Int64
|
||||
|
||||
// config 汇总服务启动所需的环境配置。
|
||||
type config struct {
|
||||
Port string
|
||||
WorkspaceDir string
|
||||
AllowOrigin string
|
||||
APIToken string
|
||||
RequestTimeout time.Duration
|
||||
MaxBodyBytes int64
|
||||
SessionTTL time.Duration
|
||||
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 int
|
||||
InstanceID string
|
||||
InstanceURL string
|
||||
EnableRedis bool
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisKeyPrefix string
|
||||
InstanceTTL time.Duration
|
||||
Heartbeat time.Duration
|
||||
Servers []completion.LanguageServerSpec
|
||||
// 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 := loadConfig()
|
||||
// 读取环境变量并组装运行配置。
|
||||
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,
|
||||
@@ -67,6 +148,31 @@ func main() {
|
||||
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,
|
||||
@@ -88,6 +194,7 @@ func main() {
|
||||
_ = manager.Close()
|
||||
}()
|
||||
|
||||
// 注册通用中间件与业务路由。
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger())
|
||||
router.Use(gin.Recovery())
|
||||
@@ -109,12 +216,14 @@ func main() {
|
||||
}
|
||||
|
||||
go func() {
|
||||
// HTTP 服务主循环,非正常退出直接终止进程。
|
||||
log.Printf(
|
||||
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t",
|
||||
"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)
|
||||
@@ -125,6 +234,7 @@ func main() {
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
// 收到退出信号后,按超时窗口优雅关闭。
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -134,56 +244,425 @@ func main() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
// loadConfig 从环境变量读取配置并应用默认值。
|
||||
func loadConfig() (config, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
return config{
|
||||
Port: getenv("PORT", "8080"),
|
||||
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
|
||||
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
|
||||
APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")),
|
||||
RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second),
|
||||
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20),
|
||||
SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute),
|
||||
CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute),
|
||||
MaxSessions: getenvInt("MAX_SESSIONS", 256),
|
||||
InstanceID: getenv("INSTANCE_ID", defaultInstanceID()),
|
||||
InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")),
|
||||
EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true),
|
||||
RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"),
|
||||
RedisPassword: getenv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getenvInt("REDIS_DB", 1),
|
||||
RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"),
|
||||
InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second),
|
||||
Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second),
|
||||
Servers: []completion.LanguageServerSpec{
|
||||
{
|
||||
Language: "go",
|
||||
LanguageID: "go",
|
||||
Command: getenv("GO_LSP_COMMAND", "gopls"),
|
||||
Args: getenvArgs("GO_LSP_ARGS", ""),
|
||||
},
|
||||
{
|
||||
Language: "javascript",
|
||||
LanguageID: "javascript",
|
||||
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"),
|
||||
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"),
|
||||
},
|
||||
{
|
||||
Language: "typescript",
|
||||
LanguageID: "typescript",
|
||||
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"),
|
||||
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"),
|
||||
},
|
||||
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) == "" {
|
||||
@@ -196,6 +675,7 @@ func defaultInstanceID() string {
|
||||
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 == "" {
|
||||
@@ -204,6 +684,7 @@ func getenv(key, fallback string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// getenvArgs 按 shell 风格拆分参数列表。
|
||||
func getenvArgs(key, fallback string) []string {
|
||||
value := getenv(key, fallback)
|
||||
if value == "" {
|
||||
@@ -212,6 +693,7 @@ func getenvArgs(key, fallback string) []string {
|
||||
return strings.Fields(value)
|
||||
}
|
||||
|
||||
// getenvInt 读取整数环境变量,解析失败时返回 fallback。
|
||||
func getenvInt(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
@@ -224,6 +706,7 @@ func getenvInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getenvInt64 读取 int64 环境变量,解析失败时返回 fallback。
|
||||
func getenvInt64(key string, fallback int64) int64 {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
@@ -236,6 +719,7 @@ func getenvInt64(key string, fallback int64) int64 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getenvDuration 读取 time.Duration 格式的环境变量。
|
||||
func getenvDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
@@ -248,6 +732,7 @@ func getenvDuration(key string, fallback time.Duration) time.Duration {
|
||||
return parsed
|
||||
}
|
||||
|
||||
// getenvBool 读取布尔环境变量,支持常见开关写法。
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if v == "" {
|
||||
@@ -263,6 +748,7 @@ func getenvBool(key string, fallback bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// corsMiddleware 处理跨域响应头与 OPTIONS 预检请求。
|
||||
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", allowOrigin)
|
||||
@@ -277,6 +763,7 @@ func corsMiddleware(allowOrigin string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// apiTokenMiddleware 校验 X-API-Key;未配置 token 时放行。
|
||||
func apiTokenMiddleware(token string) gin.HandlerFunc {
|
||||
required := strings.TrimSpace(token)
|
||||
if required == "" {
|
||||
@@ -295,6 +782,7 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// requestIDMiddleware 为请求补齐并回传 X-Request-Id。
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
||||
|
||||
103
backend/cmd/server/main_test.go
Normal file
103
backend/cmd/server/main_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigNacosDefaults(t *testing.T) {
|
||||
withEnv(t, "ENABLE_NACOS_REGISTER", "")
|
||||
withEnv(t, "NACOS_SERVER_ADDR", "")
|
||||
withEnv(t, "NACOS_NAMESPACE", "")
|
||||
withEnv(t, "NACOS_GROUP", "")
|
||||
withEnv(t, "NACOS_SERVICE_NAME", "")
|
||||
withEnv(t, "NACOS_CLUSTER_NAME", "")
|
||||
withEnv(t, "NACOS_USERNAME", "")
|
||||
withEnv(t, "NACOS_PASSWORD", "")
|
||||
withEnv(t, "NACOS_IP", "")
|
||||
withEnv(t, "NACOS_PORT", "")
|
||||
withEnv(t, "PORT", "")
|
||||
withEnv(t, "INSTANCE_URL", "")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.EnableNacosRegister {
|
||||
t.Fatalf("expected EnableNacosRegister default false")
|
||||
}
|
||||
if cfg.NacosServerAddr != "10.0.0.10:8848" {
|
||||
t.Fatalf("expected default NacosServerAddr 10.0.0.10:8848, got %q", cfg.NacosServerAddr)
|
||||
}
|
||||
if cfg.NacosGroup != "DEFAULT_GROUP" {
|
||||
t.Fatalf("expected default NacosGroup DEFAULT_GROUP, got %q", cfg.NacosGroup)
|
||||
}
|
||||
if cfg.NacosServiceName != "lsp-gateway" {
|
||||
t.Fatalf("expected default NacosServiceName lsp-gateway, got %q", cfg.NacosServiceName)
|
||||
}
|
||||
if cfg.NacosRegisterPort != 8080 {
|
||||
t.Fatalf("expected default NacosRegisterPort 8080, got %d", cfg.NacosRegisterPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigNacosFromEnv(t *testing.T) {
|
||||
withEnv(t, "ENABLE_NACOS_REGISTER", "true")
|
||||
withEnv(t, "NACOS_SERVER_ADDR", "10.0.0.10:8848")
|
||||
withEnv(t, "NACOS_NAMESPACE", "prod-ns")
|
||||
withEnv(t, "NACOS_GROUP", "editor")
|
||||
withEnv(t, "NACOS_SERVICE_NAME", "editor-lsp")
|
||||
withEnv(t, "NACOS_CLUSTER_NAME", "hz-a")
|
||||
withEnv(t, "NACOS_USERNAME", "nacos")
|
||||
withEnv(t, "NACOS_PASSWORD", "nacos")
|
||||
withEnv(t, "NACOS_IP", "172.16.10.9")
|
||||
withEnv(t, "NACOS_PORT", "19090")
|
||||
withEnv(t, "PORT", "9999")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.EnableNacosRegister {
|
||||
t.Fatalf("expected EnableNacosRegister true")
|
||||
}
|
||||
if cfg.NacosServerAddr != "10.0.0.10:8848" {
|
||||
t.Fatalf("unexpected Nacos server %s", cfg.NacosServerAddr)
|
||||
}
|
||||
if cfg.NacosNamespace != "prod-ns" {
|
||||
t.Fatalf("unexpected NacosNamespace %q", cfg.NacosNamespace)
|
||||
}
|
||||
if cfg.NacosGroup != "editor" {
|
||||
t.Fatalf("unexpected NacosGroup %q", cfg.NacosGroup)
|
||||
}
|
||||
if cfg.NacosServiceName != "editor-lsp" {
|
||||
t.Fatalf("unexpected NacosServiceName %q", cfg.NacosServiceName)
|
||||
}
|
||||
if cfg.NacosClusterName != "hz-a" {
|
||||
t.Fatalf("unexpected NacosClusterName %q", cfg.NacosClusterName)
|
||||
}
|
||||
if cfg.NacosUsername != "nacos" || cfg.NacosPassword != "nacos" {
|
||||
t.Fatalf("unexpected nacos auth")
|
||||
}
|
||||
if cfg.NacosRegisterIP != "172.16.10.9" || cfg.NacosRegisterPort != 19090 {
|
||||
t.Fatalf("unexpected register endpoint %s:%d", cfg.NacosRegisterIP, cfg.NacosRegisterPort)
|
||||
}
|
||||
}
|
||||
|
||||
func withEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, existed := os.LookupEnv(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if !existed {
|
||||
_ = os.Unsetenv(key)
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(key, old)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user