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:
2026-02-15 17:46:34 +08:00
parent 57afb90bc0
commit 3284ce07c7
22 changed files with 1863 additions and 87 deletions

View 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
}

View File

@@ -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"))

View 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)
})
}