From 3284ce07c786576f80e6bce641f303a0a0c47a72 Mon Sep 17 00:00:00 2001 From: meowrain Date: Sun, 15 Feb 2026 17:46:34 +0800 Subject: [PATCH] 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. --- backend/README.md | 46 ++ backend/cmd/server/config_test.go | 90 +++ backend/cmd/server/main.go | 606 ++++++++++++++++-- backend/cmd/server/main_test.go | 103 +++ backend/config.example.json | 55 ++ backend/config.json | 55 ++ backend/docs/java-nacos-integration-guide.md | 186 ++++++ backend/docs/why-redis-sticky-routing.md | 94 +++ backend/go.mod | 46 +- backend/go.sum | 302 ++++++++- backend/internal/api/handler.go | 10 +- backend/internal/api/handler_test.go | 5 + backend/internal/api/ws_handler.go | 14 + backend/internal/cluster/nacos_registry.go | 186 ++++++ .../internal/cluster/nacos_registry_test.go | 36 ++ backend/internal/cluster/redis_registry.go | 29 +- backend/internal/completion/manager.go | 38 +- backend/internal/completion/manager_test.go | 3 + backend/internal/completion/service.go | 7 + backend/internal/completion/service_test.go | 4 + backend/internal/lsp/client.go | 34 +- backend/internal/lsp/client_test.go | 1 + 22 files changed, 1863 insertions(+), 87 deletions(-) create mode 100644 backend/cmd/server/config_test.go create mode 100644 backend/cmd/server/main_test.go create mode 100644 backend/config.example.json create mode 100644 backend/config.json create mode 100644 backend/docs/java-nacos-integration-guide.md create mode 100644 backend/docs/why-redis-sticky-routing.md create mode 100644 backend/internal/cluster/nacos_registry.go create mode 100644 backend/internal/cluster/nacos_registry_test.go diff --git a/backend/README.md b/backend/README.md index 873df50..facb92f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -19,6 +19,28 @@ go run ./cmd/server 默认地址:`http://127.0.0.1:8080` +## 配置文件(推荐) + +后端现在支持 JSON 配置文件: + +1. 复制 `config.example.json` 为 `config.json` +2. 修改你需要的字段 +3. 启动服务(会自动读取当前目录 `config.json`) + +```bash +cp config.example.json config.json +go run ./cmd/server +``` + +也可以显式指定路径: + +```bash +CONFIG_FILE=./config.local.json go run ./cmd/server +``` + +优先级为:`默认值 < 配置文件 < 环境变量`。 +也就是说你可以把大部分配置放在文件里,临时参数再用环境变量覆盖。 + ## 企业化配置(环境变量) - `PORT`:默认 `8080` @@ -39,6 +61,17 @@ go run ./cmd/server - `INSTANCE_URL`:实例可回源地址(用于路由提示),默认 `http://127.0.0.1:${PORT}` - `INSTANCE_TTL`:实例注册 TTL,默认 `30s` - `INSTANCE_HEARTBEAT_INTERVAL`:实例心跳周期,默认 `10s` +- `ENABLE_NACOS_REGISTER`:是否启用 Nacos SDK 注册,默认 `false` +- `NACOS_SERVER_ADDR`:Nacos 地址,默认 `10.0.0.10:8848` +- `NACOS_NAMESPACE`:Nacos namespace,默认空(public) +- `NACOS_GROUP`:Nacos group,默认 `DEFAULT_GROUP` +- `NACOS_SERVICE_NAME`:Nacos 服务名,默认 `lsp-gateway` +- `NACOS_CLUSTER_NAME`:可选 clusterName +- `NACOS_USERNAME`:Nacos 用户名,可空 +- `NACOS_PASSWORD`:Nacos 密码,可空 +- `NACOS_IP`:实例注册 IP(建议注入 Pod/主机内网 IP) +- `NACOS_PORT`:实例注册端口,默认取 `PORT` +- `NACOS_EPHEMERAL`:是否临时实例,默认 `true` 语言服务器命令(可替换为企业内部镜像/封装): - `GO_LSP_COMMAND`,`GO_LSP_ARGS` @@ -48,6 +81,19 @@ go run ./cmd/server 默认 JS/TS 命令: - `typescript-language-server --stdio` +Nacos SDK 注册示例: + +```bash +ENABLE_NACOS_REGISTER=true +NACOS_SERVER_ADDR=10.0.0.10:8848 +NACOS_USERNAME=nacos +NACOS_PASSWORD=nacos +NACOS_SERVICE_NAME=lsp-gateway +NACOS_GROUP=DEFAULT_GROUP +NACOS_IP=10.0.2.15 +NACOS_PORT=8080 +``` + ## 健康检查 - `GET /health` diff --git a/backend/cmd/server/config_test.go b/backend/cmd/server/config_test.go new file mode 100644 index 0000000..39a175b --- /dev/null +++ b/backend/cmd/server/config_test.go @@ -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 +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1528e99..bbf9afc 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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")) diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go new file mode 100644 index 0000000..a0a2732 --- /dev/null +++ b/backend/cmd/server/main_test.go @@ -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) + }) +} diff --git a/backend/config.example.json b/backend/config.example.json new file mode 100644 index 0000000..ad138e9 --- /dev/null +++ b/backend/config.example.json @@ -0,0 +1,55 @@ +{ + "port": "8080", + "workspaceDir": ".", + "allowOrigin": "*", + "apiToken": "", + "requestTimeout": "10s", + "maxBodyBytes": 2097152, + "sessionTTL": "20m", + "cleanupInterval": "2m", + "maxSessions": 256, + "instanceID": "", + "instanceURL": "http://127.0.0.1:8080", + "enableRedis": true, + "redisAddr": "10.0.0.10:6379", + "redisPassword": "", + "redisDB": 1, + "redisKeyPrefix": "lsp-gateway", + "instanceTTL": "30s", + "heartbeat": "10s", + "enableNacosRegister": false, + "nacosServerAddr": "10.0.0.10:8848", + "nacosNamespace": "", + "nacosGroup": "DEFAULT_GROUP", + "nacosServiceName": "lsp-gateway", + "nacosClusterName": "", + "nacosUsername": "", + "nacosPassword": "", + "nacosRegisterIP": "", + "nacosRegisterPort": 8080, + "nacosEphemeral": true, + "servers": [ + { + "language": "go", + "languageId": "go", + "command": "gopls", + "args": [] + }, + { + "language": "javascript", + "languageId": "javascript", + "command": "typescript-language-server", + "args": [ + "--stdio" + ] + }, + { + "language": "typescript", + "languageId": "typescript", + "command": "typescript-language-server", + "args": [ + "--stdio" + ] + } + ] +} diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..56cd977 --- /dev/null +++ b/backend/config.json @@ -0,0 +1,55 @@ +{ + "port": "8080", + "workspaceDir": ".", + "allowOrigin": "*", + "apiToken": "", + "requestTimeout": "10s", + "maxBodyBytes": 2097152, + "sessionTTL": "20m", + "cleanupInterval": "2m", + "maxSessions": 256, + "instanceID": "", + "instanceURL": "http://127.0.0.1:8080", + "enableRedis": true, + "redisAddr": "10.0.0.10:6379", + "redisPassword": "", + "redisDB": 1, + "redisKeyPrefix": "lsp-gateway", + "instanceTTL": "30s", + "heartbeat": "10s", + "enableNacosRegister": true, + "nacosServerAddr": "10.0.0.10:8848", + "nacosNamespace": "", + "nacosGroup": "DEFAULT_GROUP", + "nacosServiceName": "lsp-gateway", + "nacosClusterName": "", + "nacosUsername": "", + "nacosPassword": "", + "nacosRegisterIP": "", + "nacosRegisterPort": 8080, + "nacosEphemeral": true, + "servers": [ + { + "language": "go", + "languageId": "go", + "command": "gopls", + "args": [] + }, + { + "language": "javascript", + "languageId": "javascript", + "command": "typescript-language-server", + "args": [ + "--stdio" + ] + }, + { + "language": "typescript", + "languageId": "typescript", + "command": "typescript-language-server", + "args": [ + "--stdio" + ] + } + ] +} diff --git a/backend/docs/java-nacos-integration-guide.md b/backend/docs/java-nacos-integration-guide.md new file mode 100644 index 0000000..000fa3f --- /dev/null +++ b/backend/docs/java-nacos-integration-guide.md @@ -0,0 +1,186 @@ +# Go LSP Gateway 接入 Java 微服务(Nacos)指南 + +## 1. 结论先说 + +可以。 +你完全可以把当前 Go LSP Gateway 注册到 Nacos,然后由 Java 微服务通过服务名发现并转发请求。 + +但要注意: +- Nacos 解决的是「服务发现」问题。 +- LSP 场景还需要「会话粘性」问题(同 `sessionId` 要持续命中同一实例)。 +- 粘性在你现在的实现里由 Redis 会话目录 + `routeTo` 重试机制承担,Nacos 本身不替代这部分。 + +## 2. 当前 Go 服务能力(已具备) + +对外接口: +- HTTP: `POST /api/v1/completions/{language}` +- WS: `GET /ws/completions`、`GET /ws/completions/{language}` +- 健康检查:`/health`、`/health/live`、`/health/ready` + +多副本会话能力: +- Redis 会话外置(默认 `10.0.0.10:6379`, DB `1`) +- 会话归属冲突返回 `409`,并带 `routeTo` + `X-LSP-Route-To` + +## 3. 推荐架构 + +1. 前端 -> Java Gateway/BFF -> Go LSP Gateway +2. Go LSP Gateway 多副本部署(Nacos 注册) +3. Redis 作为会话目录(`language + sessionId -> owner instance`) + +## 4. Nacos 接入方式 + +你有两种落地方式: + +1. 由部署系统注册(推荐) + 指 K8s/运维平台在发布时自动调用 Nacos OpenAPI 注册实例,Go 程序本身不依赖 Nacos SDK。 +2. Go 进程内注册 + 在 Go 服务启动时使用 `nacos-sdk-go` 注册/心跳/下线。 + +你当前明确要求走 SDK,本项目已支持方式 2。 + +## 5. Java 侧必须做的事 + +## 5.1 统一转发入口 + +Java 提供统一接口给前端,例如: +- `POST /editor/completions/{language}` + +内部转发到: +- `http://{lsp-service}/api/v1/completions/{language}` + +## 5.2 强制透传稳定 sessionId + +请求体必须带稳定 `sessionId`,建议格式: +- `tenant:user:project:tab` + +如果 `sessionId` 不稳定,会导致会话频繁重建、补全抖动。 + +## 5.3 处理 409 routeTo(关键) + +当 Go 返回 `409` 时: +- 读取响应体 `routeTo`(或 header `X-LSP-Route-To`) +- Java 侧自动重试一次到 `routeTo` + +这样可以跨实例正确命中会话拥有者。 + +## 5.4 透传请求头 + +- `X-Request-Id`(链路追踪) +- `X-API-Key`(若 Go 配置了 `LSP_API_TOKEN`) + +## 6. Spring Cloud Alibaba(Nacos)示例 + +## 6.1 application.yml(服务发现) + +```yaml +spring: + application: + name: editor-bff + cloud: + nacos: + discovery: + server-addr: 10.0.0.20:8848 +``` + +Go 服务在 Nacos 中注册名假设为:`lsp-gateway` + +## 6.2 Go 侧与 Spring 配置映射(SDK 注册) + +你的 Java 配置: + +```yaml +cloud: + nacos: + discovery: + enabled: true + register-enabled: true + server-addr: 10.0.0.10:8848 + username: nacos + password: nacos +``` + +对应 Go 环境变量: + +- `ENABLE_NACOS_REGISTER=true` +- `NACOS_SERVER_ADDR=10.0.0.10:8848` +- `NACOS_USERNAME=nacos` +- `NACOS_PASSWORD=nacos` +- `NACOS_SERVICE_NAME=lsp-gateway` +- `NACOS_GROUP=DEFAULT_GROUP` +- `NACOS_IP=<当前实例可达IP>` +- `NACOS_PORT=8080`(或你的服务端口) + +## 6.3 Gateway 路由示例(HTTP + WS) + +```yaml +spring: + cloud: + gateway: + routes: + - id: lsp-http + uri: lb://lsp-gateway + predicates: + - Path=/lsp/api/** + filters: + - RewritePath=/lsp/api/(?.*), /api/$\{segment} + + - id: lsp-ws + uri: lb:ws://lsp-gateway + predicates: + - Path=/lsp/ws/** + filters: + - RewritePath=/lsp/ws/(?.*), /ws/$\{segment} +``` + +## 6.4 BFF 转发逻辑(WebClient 伪代码) + +```java +Mono> proxyCompletion(String language, String body, HttpHeaders headers) { + return call("lb://lsp-gateway/api/v1/completions/" + language, body, headers) + .flatMap(resp -> { + if (resp.getStatusCodeValue() != 409) return Mono.just(resp); + + String routeTo = extractRouteTo(resp); // from body.routeTo or X-LSP-Route-To + if (routeTo == null || routeTo.isBlank()) return Mono.just(resp); + + String direct = routeTo + "/api/v1/completions/" + language; + return call(direct, body, headers); // retry once + }); +} +``` + +## 7. Go 服务部署参数建议(生产) + +- `ENABLE_REDIS_STICKY=true` +- `REDIS_ADDR=10.0.0.10:6379` +- `REDIS_DB=1` +- `REDIS_PASSWORD=`(空) +- `INSTANCE_ID`:每个实例唯一(建议 Pod 名) +- `INSTANCE_URL`:实例可回源地址(供 routeTo 使用) +- `ENABLE_NACOS_REGISTER=true` +- `NACOS_SERVER_ADDR=10.0.0.10:8848` +- `NACOS_USERNAME=nacos` +- `NACOS_PASSWORD=nacos` +- `NACOS_IP`:实例可达内网 IP(不要填 127.0.0.1) +- `NACOS_PORT`:实例监听端口 +- `MAX_SESSIONS`:按机器资源评估(如 200~500) +- `SESSION_TTL`:建议 10~30 分钟 + +## 8. 常见误区 + +1. “用了 Nacos 就不需要 Redis 会话目录” + 错。Nacos 只告诉你“有哪些实例”,不维护“某会话属于哪台实例”。 + +2. “随机 LB 也能跑” + 能跑但体验会抖,LSP 上下文会丢。 + +3. “不传 sessionId 也没关系” + 错。会话粘性依赖稳定 sessionId。 + +## 9. 联调 checklist + +1. Nacos 中能看到 `lsp-gateway` 实例。 +2. Java 转发 HTTP 可通。 +3. 同一 `sessionId` 连续请求响应稳定。 +4. 人为让请求打到非 owner 实例时,Java 能按 `routeTo` 自动重试成功。 +5. WS 通道可建立,断线重连后会话仍可恢复。 diff --git a/backend/docs/why-redis-sticky-routing.md b/backend/docs/why-redis-sticky-routing.md new file mode 100644 index 0000000..1132122 --- /dev/null +++ b/backend/docs/why-redis-sticky-routing.md @@ -0,0 +1,94 @@ +# 为什么要做 Redis 会话外置 + 粘性路由 + +## 背景 + +当前 LSP 网关的核心能力是: +- 把编辑器请求转成 LSP(JSON-RPC over stdio) +- 为每个 `language + sessionId` 维护一个长生命周期会话(对应语言服务器进程与文档状态) + +单实例时,这套方案天然稳定。 +一旦进入微服务部署(多副本 + 负载均衡),如果没有额外机制,会出现严重一致性问题。 + +## 不做会发生什么 + +### 1. 会话状态丢失 + +同一个用户会话的请求可能先到实例 A、再到实例 B。 +但 B 没有 A 的内存态(didOpen/didChange 版本、LSP 上下文),会导致: +- 补全质量波动 +- 诊断/跳转不一致 +- 重复初始化语言服务器 + +### 2. 成本放大 + +会话不稳定会触发频繁建进程,带来: +- 更高 CPU/内存 +- 更高请求尾延迟(P95/P99) +- 更多瞬时失败 + +### 3. 故障不可控 + +无统一会话归属时,问题难定位(到底是哪台实例持有上下文、何时丢失)。 + +## 为什么要 Redis 会话外置 + +Redis 不是替代 LSP 进程,而是做“会话目录(Session Directory)”: +- 记录某个 `language + sessionId` 当前归属哪个实例 +- 记录实例心跳与可回源地址 +- 给会话绑定 TTL,支持自动过期与回收 + +它带来的价值: +- 多副本下会话归属可见、可控 +- 实例重启/扩容/缩容时路由行为可预测 +- 可以做统一治理(观测、告警、自动修复) + +## 为什么要粘性路由 + +LSP 天然是“有状态协议”:同一会话必须持续命中同一实例才能复用上下文。 +粘性路由的目标就是保证这一点。 + +网关当前策略: +1. 请求到达后先在 Redis `claim` 会话归属 +2. 如果归属自己:本地处理 +3. 如果归属其他实例:返回 `409 + routeTo`(HTTP 头 `X-LSP-Route-To`) +4. 上游(前端或 Java 网关)据此重试到目标实例 + +这比纯随机 LB 更符合 LSP 工作方式。 + +## 设计取舍 + +### 收益 +- 会话一致性显著提升 +- 进程复用率提高,资源更稳 +- 故障域清晰,便于排障 + +### 代价 +- 引入 Redis 依赖(网络与可用性要保障) +- 路由逻辑更复杂(冲突重试、TTL 管理) +- 需要对上游调用方约束:必须稳定传 `sessionId` + +## 什么时候可以不做 + +可以暂不启用 Redis/粘性路由的场景: +- 仅单实例部署 +- 开发/演示环境 +- 对补全一致性不敏感 + +但只要进入生产多副本,建议启用。 + +## 适配 Java 微服务体系的意义 + +将 LSP 网关作为独立微服务后: +- Java 业务服务无需关心各语言 LSP 细节 +- 可以统一接入鉴权、限流、链路追踪 +- LSP 能力扩展(Go/JS/TS/Java/Python)不会反复侵入业务服务 + +这就是把它做成独立 LSP 服务的核心原因:**把状态复杂性收敛在一个可治理的边界里**。 + +## 当前实现对应点 + +- Redis 默认配置:`10.0.0.10:6379`, `DB=1`, 无密码 +- 会话注册与认领:`internal/cluster/redis_registry.go` +- 会话管理与归属检查:`internal/completion/manager.go` +- 路由冲突返回:`internal/api/handler.go`, `internal/api/ws_handler.go` +- 启动配置入口:`cmd/server/main.go` diff --git a/backend/go.mod b/backend/go.mod index bbc1dd2..920100e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,14 +5,40 @@ go 1.25 require ( github.com/gin-gonic/gin v1.11.0 github.com/gorilla/websocket v1.5.3 + github.com/nacos-group/nacos-sdk-go/v2 v2.3.5 github.com/redis/go-redis/v9 v9.17.3 ) require ( + github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/darabonba-array v0.1.0 // indirect + github.com/alibabacloud-go/darabonba-encode-util v0.0.2 // indirect + github.com/alibabacloud-go/darabonba-map v0.0.2 // indirect + github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 // indirect + github.com/alibabacloud-go/darabonba-signature-util v0.0.7 // indirect + github.com/alibabacloud-go/darabonba-string v1.0.2 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect + github.com/alibabacloud-go/kms-20160120/v3 v3.2.3 // indirect + github.com/alibabacloud-go/openapi-util v0.1.0 // indirect + github.com/alibabacloud-go/tea v1.2.2 // indirect + github.com/alibabacloud-go/tea-utils v1.4.4 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/alibabacloud-go/tea-xml v1.1.3 // indirect + github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect + github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 // indirect + github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 // indirect + github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 // indirect + github.com/aliyun/credentials-go v1.4.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.5.5 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/deckarep/golang-set v1.7.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -21,18 +47,31 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/atomic v1.7.0 // indirect go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.21.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect @@ -40,6 +79,11 @@ require ( golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect + golang.org/x/time v0.1.0 // indirect golang.org/x/tools v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/grpc v1.67.3 // indirect google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7e4d4c0..1244051 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,100 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.9/go.mod h1:bb+Io8Sn2RuM3/Rpme6ll86jMyFSrD1bxeV/+v61KeU= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 h1:GEYkMApgpKEVDn6z12DcH1EGYpDYRB8JxsazM4Rywak= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/kms-20160120/v3 v3.2.3 h1:vamGcYQFwXVqR6RWcrVTTqlIXZVsYjaA7pZbx+Xw6zw= +github.com/alibabacloud-go/kms-20160120/v3 v3.2.3/go.mod h1:3rIyughsFDLie1ut9gQJXkWkMg/NfXBCk+OtXnPu3lw= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA= +github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o= +github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw= +github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU= +github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 h1:nJYyoFP+aqGKgPs9JeZgS1rWQ4NndNR0Zfhh161ZltU= +github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1/go.mod h1:WzGOmFFTlUzXM03CJnHWMQ85UN6QGpOXZocCjwkiyOg= +github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 h1:QeUdR7JF7iNCvO/81EhxEr3wDwxk4YBoYZOq6E0AjHI= +github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8/go.mod h1:xP0KIZry6i7oGPF24vhAPr1Q8vLZRcMcxtft5xDKwCU= +github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 h1:8S0mtD101RDYa0LXwdoqgN0RxdMmmJYjq8g2mk7/lQ4= +github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5/go.mod h1:M19fxYz3gpm0ETnoKweYyYtqrtnVtrpKFpwsghbw+cQ= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/aliyun/credentials-go v1.4.3 h1:N3iHyvHRMyOwY1+0qBLSf3hb5JFiOujVSVuEpgeGttY= +github.com/aliyun/credentials-go v1.4.3/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set v1.7.1 h1:SCQV0S6gTtp6itiFrTqI+pfmJ4LN85S1YzhDf9rTHJQ= +github.com/deckarep/golang-set v1.7.1/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -33,68 +113,288 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nacos-group/nacos-sdk-go/v2 v2.3.5 h1:Hux7C4N4rWhwBF5Zm4yyYskrs9VTgrRTA8DZjoEhQTs= +github.com/nacos-group/nacos-sdk-go/v2 v2.3.5/go.mod h1:ygUBdt7eGeYBt6Lz2HO3wx7crKXk25Mp80568emGMWU= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc h1:Ak86L+yDSOzKFa7WM5bf5itSOo1e3Xh8bm5YCMUXIjQ= +github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 39df0f6..b7e5000 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -15,15 +15,18 @@ type CompletionService interface { Complete(ctx context.Context, req completion.Request) (completion.Response, error) } +// SessionStatsProvider 暴露会话统计信息,供就绪探针输出。 type SessionStatsProvider interface { ActiveSessions() map[string]int } +// RouteOptions 控制 HTTP/WS 接口的超时与请求体上限。 type RouteOptions struct { - RequestTimeout time.Duration - MaxBodyBytes int64 + RequestTimeout time.Duration // 单次补全调用超时时间。 + MaxBodyBytes int64 // 请求体最大字节数(HTTP/WS 共用)。 } +// RegisterRoutes 注册健康检查、HTTP 补全接口和 WebSocket 补全接口。 func RegisterRoutes(router *gin.Engine, service CompletionService, options ...RouteOptions) { opts := RouteOptions{ RequestTimeout: 10 * time.Second, @@ -60,6 +63,7 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro registerWSRoutes(router, service, opts) handleCompletion := func(c *gin.Context) { + // 为单次请求限制 body 大小,避免异常大包占满内存。 c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes) var req completion.Request if err := c.ShouldBindJSON(&req); err != nil { @@ -68,10 +72,12 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro } routeLang := c.Param("language") + // 若 body 未显式给出 language,则使用路由参数。 if req.Language == "" { req.Language = routeLang } + // 对下游补全调用增加超时保护,防止请求长时间悬挂。 ctx, cancel := context.WithTimeout(c.Request.Context(), opts.RequestTimeout) defer cancel() diff --git a/backend/internal/api/handler_test.go b/backend/internal/api/handler_test.go index 5966e44..b147e12 100644 --- a/backend/internal/api/handler_test.go +++ b/backend/internal/api/handler_test.go @@ -28,6 +28,7 @@ func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request return f.resp, nil } +// 验证 HTTP 补全接口的成功路径。 func TestRegisterRoutesCompletionSuccess(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -63,6 +64,7 @@ func TestRegisterRoutesCompletionSuccess(t *testing.T) { } } +// 验证非法 JSON 会返回 400。 func TestRegisterRoutesCompletionBadJSON(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -78,6 +80,7 @@ func TestRegisterRoutesCompletionBadJSON(t *testing.T) { } } +// 验证业务校验错误会映射为 400。 func TestRegisterRoutesCompletionValidationError(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -100,6 +103,7 @@ func TestRegisterRoutesCompletionValidationError(t *testing.T) { } } +// 验证未知内部错误会映射为 500。 func TestRegisterRoutesCompletionServerError(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -121,6 +125,7 @@ func TestRegisterRoutesCompletionServerError(t *testing.T) { } } +// 验证 WebSocket 补全协议的基础成功流程。 func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() diff --git a/backend/internal/api/ws_handler.go b/backend/internal/api/ws_handler.go index 893a6da..231d730 100644 --- a/backend/internal/api/ws_handler.go +++ b/backend/internal/api/ws_handler.go @@ -21,6 +21,7 @@ var wsUpgrader = websocket.Upgrader{ }, } +// wsCompletionRequest 是普通 WS 消息的补全请求格式。 type wsCompletionRequest struct { ID string `json:"id"` Language string `json:"language,omitempty"` @@ -31,6 +32,7 @@ type wsCompletionRequest struct { Character int `json:"character"` } +// wsCompletionResponse 是普通 WS 消息的补全响应格式。 type wsCompletionResponse struct { ID string `json:"id"` Items []completion.Item `json:"items,omitempty"` @@ -40,6 +42,7 @@ type wsCompletionResponse struct { Error string `json:"error,omitempty"` } +// wsRPCRequest/wsRPCResponse 用于兼容 JSON-RPC 2.0 客户端。 type wsRPCRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` @@ -54,6 +57,7 @@ type wsRPCResponse struct { Error any `json:"error,omitempty"` } +// registerWSRoutes 注册 WebSocket 补全入口(含可选语言路由)。 func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteOptions) { handler := func(c *gin.Context, defaultLanguage string) { conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) @@ -67,6 +71,7 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO var writeMu sync.Mutex for { + // 单连接串行读取消息,写操作通过 writeMu 保证并发安全。 _, payload, err := conn.ReadMessage() if err != nil { break @@ -83,6 +88,7 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO }) } +// handleWSMessage 先尝试按 JSON-RPC 处理;失败后回退到普通 JSON 协议。 func handleWSMessage( conn *websocket.Conn, writeMu *sync.Mutex, @@ -110,6 +116,7 @@ func handleWSMessage( processWSCompletion(conn, writeMu, service, req, opts) } +// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。 func tryHandleRPCMessage( conn *websocket.Conn, writeMu *sync.Mutex, @@ -127,6 +134,7 @@ func tryHandleRPCMessage( } if rpcReq.Method != "completion/complete" && rpcReq.Method != "completion.complete" { + // 非补全方法按 JSON-RPC 规范返回 method not found。 sendWSRPCResponse(conn, writeMu, wsRPCResponse{ JSONRPC: "2.0", ID: rpcReq.ID, @@ -140,6 +148,7 @@ func tryHandleRPCMessage( var req wsCompletionRequest if err := json.Unmarshal(rpcReq.Params, &req); err != nil { + // 参数反序列化失败按 invalid params 处理。 sendWSRPCResponse(conn, writeMu, wsRPCResponse{ JSONRPC: "2.0", ID: rpcReq.ID, @@ -154,6 +163,7 @@ func tryHandleRPCMessage( req.Language = defaultLanguage } if req.ID == "" { + // 兼容未在 params 提供业务 ID 的客户端。 req.ID = string(rpcReq.ID) } @@ -188,6 +198,7 @@ func tryHandleRPCMessage( return true } +// processWSCompletion 处理普通 WS 协议下的补全请求。 func processWSCompletion( conn *websocket.Conn, writeMu *sync.Mutex, @@ -220,6 +231,7 @@ func processWSCompletion( default: var ownedErr *completion.ErrSessionOwnedByOtherInstance if errors.As(err, &ownedErr) { + // 会话在其他实例上时返回路由提示,客户端可重连对应节点。 msg = err.Error() routeTo = ownedErr.OwnerEndpoint ownerID = ownedErr.OwnerID @@ -241,12 +253,14 @@ func processWSCompletion( }) } +// sendWSResponse 统一串行写回普通 WS 响应。 func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) { writeMu.Lock() defer writeMu.Unlock() _ = conn.WriteJSON(resp) } +// sendWSRPCResponse 统一串行写回 JSON-RPC 响应。 func sendWSRPCResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsRPCResponse) { writeMu.Lock() defer writeMu.Unlock() diff --git a/backend/internal/cluster/nacos_registry.go b/backend/internal/cluster/nacos_registry.go new file mode 100644 index 0000000..d8432ac --- /dev/null +++ b/backend/internal/cluster/nacos_registry.go @@ -0,0 +1,186 @@ +package cluster + +import ( + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/nacos-group/nacos-sdk-go/v2/clients" + "github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client" + "github.com/nacos-group/nacos-sdk-go/v2/common/constant" + "github.com/nacos-group/nacos-sdk-go/v2/vo" +) + +type NacosRegistryConfig struct { + ServerAddr string // Nacos 地址,例如 10.0.0.10:8848。 + Namespace string // Nacos namespace,可为空表示 public。 + Group string // 服务组,默认 DEFAULT_GROUP。 + ServiceName string // 注册服务名,例如 lsp-gateway。 + ClusterName string // 可选集群名。 + Username string // Nacos 用户名,可为空。 + Password string // Nacos 密码,可为空。 + IP string // 实例注册 IP,建议注入可达内网地址。 + Port uint64 // 实例注册端口(服务监听端口)。 + Metadata map[string]string // 实例元数据。 + Ephemeral bool // 是否临时实例,默认 true。 + Weight float64 // 实例权重,默认 1。 + TimeoutMs uint64 // SDK 请求超时,默认 5000ms。 +} + +// NacosRegistry 负责将当前实例注册到 Nacos,并在退出时反注册。 +type NacosRegistry struct { + client naming_client.INamingClient + serviceName string + groupName string + clusterName string + ip string + port uint64 + ephemeral bool + + closeOnce sync.Once +} + +func NewNacosRegistry(cfg NacosRegistryConfig) (*NacosRegistry, error) { + serverHost, serverPort, err := parseServerAddr(cfg.ServerAddr) + if err != nil { + return nil, err + } + if strings.TrimSpace(cfg.ServiceName) == "" { + return nil, errors.New("nacos service name is required") + } + if strings.TrimSpace(cfg.IP) == "" { + return nil, errors.New("nacos register ip is required") + } + if cfg.Port == 0 { + return nil, errors.New("nacos register port is required") + } + if strings.TrimSpace(cfg.Group) == "" { + cfg.Group = "DEFAULT_GROUP" + } + if cfg.Weight <= 0 { + cfg.Weight = 1 + } + if cfg.TimeoutMs == 0 { + cfg.TimeoutMs = 5000 + } + + clientConfig := constant.NewClientConfig( + constant.WithNamespaceId(strings.TrimSpace(cfg.Namespace)), + constant.WithTimeoutMs(cfg.TimeoutMs), + constant.WithUsername(strings.TrimSpace(cfg.Username)), + constant.WithPassword(strings.TrimSpace(cfg.Password)), + ) + + namingClient, err := clients.NewNamingClient(vo.NacosClientParam{ + ClientConfig: clientConfig, + ServerConfigs: []constant.ServerConfig{ + { + IpAddr: serverHost, + Port: serverPort, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("create nacos naming client failed: %w", err) + } + + registry := &NacosRegistry{ + client: namingClient, + serviceName: strings.TrimSpace(cfg.ServiceName), + groupName: strings.TrimSpace(cfg.Group), + clusterName: strings.TrimSpace(cfg.ClusterName), + ip: strings.TrimSpace(cfg.IP), + port: cfg.Port, + ephemeral: cfg.Ephemeral, + } + + ok, err := registry.client.RegisterInstance(vo.RegisterInstanceParam{ + Ip: registry.ip, + Port: registry.port, + Weight: cfg.Weight, + Enable: true, + Healthy: true, + Metadata: cfg.Metadata, + ClusterName: registry.clusterName, + ServiceName: registry.serviceName, + GroupName: registry.groupName, + Ephemeral: registry.ephemeral, + }) + if err != nil { + registry.client.CloseClient() + return nil, fmt.Errorf("register nacos instance failed: %w", err) + } + if !ok { + registry.client.CloseClient() + return nil, errors.New("register nacos instance returned false") + } + + return registry, nil +} + +func (r *NacosRegistry) Close() error { + var closeErr error + r.closeOnce.Do(func() { + ok, err := r.client.DeregisterInstance(vo.DeregisterInstanceParam{ + Ip: r.ip, + Port: r.port, + Cluster: r.clusterName, + ServiceName: r.serviceName, + GroupName: r.groupName, + Ephemeral: r.ephemeral, + }) + if err != nil { + closeErr = fmt.Errorf("deregister nacos instance failed: %w", err) + } else if !ok { + closeErr = errors.New("deregister nacos instance returned false") + } + r.client.CloseClient() + }) + return closeErr +} + +func parseServerAddr(addr string) (string, uint64, error) { + trimmed := strings.TrimSpace(addr) + if trimmed == "" { + return "", 0, errors.New("nacos server addr is required") + } + + host := trimmed + port := uint64(8848) + if strings.Contains(trimmed, ":") { + parsedHost, parsedPort, err := net.SplitHostPort(trimmed) + if err != nil { + return "", 0, fmt.Errorf("invalid nacos server addr %q: %w", addr, err) + } + host = parsedHost + rawPort, err := strconv.ParseUint(parsedPort, 10, 64) + if err != nil { + return "", 0, fmt.Errorf("invalid nacos server port %q: %w", parsedPort, err) + } + port = rawPort + } + + host = strings.TrimSpace(host) + if host == "" { + return "", 0, errors.New("nacos server host is required") + } + return host, port, nil +} + +func ResolveRegisterIP(explicitIP, instanceURL string) string { + if ip := strings.TrimSpace(explicitIP); ip != "" { + return ip + } + + u, err := url.Parse(strings.TrimSpace(instanceURL)) + if err == nil { + if host := strings.TrimSpace(u.Hostname()); host != "" { + return host + } + } + return "" +} diff --git a/backend/internal/cluster/nacos_registry_test.go b/backend/internal/cluster/nacos_registry_test.go new file mode 100644 index 0000000..e605aa1 --- /dev/null +++ b/backend/internal/cluster/nacos_registry_test.go @@ -0,0 +1,36 @@ +package cluster + +import "testing" + +func TestParseServerAddr(t *testing.T) { + host, port, err := parseServerAddr("10.0.0.10:8848") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if host != "10.0.0.10" || port != 8848 { + t.Fatalf("unexpected parse result %s:%d", host, port) + } + + host, port, err = parseServerAddr("nacos.internal") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if host != "nacos.internal" || port != 8848 { + t.Fatalf("unexpected parse result %s:%d", host, port) + } +} + +func TestParseServerAddrInvalid(t *testing.T) { + if _, _, err := parseServerAddr("10.0.0.10:"); err == nil { + t.Fatalf("expected parse error") + } +} + +func TestResolveRegisterIP(t *testing.T) { + if got := ResolveRegisterIP("172.16.1.9", "http://127.0.0.1:8080"); got != "172.16.1.9" { + t.Fatalf("expected explicit ip, got %q", got) + } + if got := ResolveRegisterIP("", "http://10.2.3.4:8080"); got != "10.2.3.4" { + t.Fatalf("expected host from instance url, got %q", got) + } +} diff --git a/backend/internal/cluster/redis_registry.go b/backend/internal/cluster/redis_registry.go index 45d1f19..4f8654e 100644 --- a/backend/internal/cluster/redis_registry.go +++ b/backend/internal/cluster/redis_registry.go @@ -12,17 +12,18 @@ import ( ) type RedisRegistryConfig struct { - Addr string - Password string - DB int - KeyPrefix string - InstanceID string - InstanceEndpoint string - SessionTTL time.Duration - InstanceTTL time.Duration - HeartbeatInterval time.Duration + Addr string // Redis 地址,例如 127.0.0.1:6379。 + Password string // Redis 密码,可为空。 + DB int // Redis 数据库编号。 + KeyPrefix string // 键前缀,隔离不同环境/业务。 + InstanceID string // 当前实例唯一 ID。 + InstanceEndpoint string // 当前实例对外访问地址。 + SessionTTL time.Duration // 会话键的过期时间。 + InstanceTTL time.Duration // 实例元数据过期时间。 + HeartbeatInterval time.Duration // 实例心跳刷新周期。 } +// RedisRegistry 负责在 Redis 中维护实例心跳与会话归属。 type RedisRegistry struct { client *redis.Client @@ -37,6 +38,7 @@ type RedisRegistry struct { stopOnce sync.Once } +// claimSessionScript 原子地抢占/续租会话,返回当前 owner。 var claimSessionScript = redis.NewScript(` local sessionKey = KEYS[1] local owner = ARGV[1] @@ -54,6 +56,7 @@ redis.call('PEXPIRE', sessionKey, ttl) return existing `) +// releaseSessionScript 仅允许 owner 主动释放自己的会话。 var releaseSessionScript = redis.NewScript(` local sessionKey = KEYS[1] local owner = ARGV[1] @@ -65,6 +68,7 @@ end return 0 `) +// NewRedisRegistry 初始化 Redis 客户端并启动实例心跳。 func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegistry, error) { if strings.TrimSpace(cfg.Addr) == "" { return nil, errors.New("redis addr is required") @@ -113,6 +117,7 @@ func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegis return registry, nil } +// ClaimSession 尝试声明会话归属,并解析 owner 对应的实例地址。 func (r *RedisRegistry) ClaimSession( ctx context.Context, language string, @@ -147,6 +152,7 @@ func (r *RedisRegistry) ClaimSession( return owner, endpoint, nil } +// ReleaseSession 释放当前实例持有的会话键。 func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID string) error { key := r.sessionKey(language, sessionID) if _, err := releaseSessionScript.Run(ctx, r.client, []string{key}, r.instanceID).Result(); err != nil { @@ -155,6 +161,7 @@ func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID return nil } +// Close 停止心跳并关闭 Redis 连接。 func (r *RedisRegistry) Close() error { r.stopOnce.Do(func() { close(r.stopCh) @@ -162,6 +169,7 @@ func (r *RedisRegistry) Close() error { return r.client.Close() } +// heartbeatLoop 周期性刷新实例元数据,维持实例在线状态。 func (r *RedisRegistry) heartbeatLoop() { ticker := time.NewTicker(r.heartbeatInterval) defer ticker.Stop() @@ -178,6 +186,7 @@ func (r *RedisRegistry) heartbeatLoop() { } } +// refreshInstance 更新实例 endpoint 和 TTL。 func (r *RedisRegistry) refreshInstance(ctx context.Context) error { key := r.instanceKey(r.instanceID) now := time.Now().UTC().Format(time.RFC3339Nano) @@ -193,6 +202,7 @@ func (r *RedisRegistry) refreshInstance(ctx context.Context) error { return nil } +// resolveInstanceEndpoint 根据实例 ID 读取其对外地址。 func (r *RedisRegistry) resolveInstanceEndpoint(ctx context.Context, ownerID string) (string, error) { key := r.instanceKey(ownerID) endpoint, err := r.client.HGet(ctx, key, "endpoint").Result() @@ -213,6 +223,7 @@ func (r *RedisRegistry) instanceKey(instanceID string) string { return fmt.Sprintf("%s:instances:%s", r.keyPrefix, normalizePart(instanceID)) } +// normalizePart 规范化 Redis key 片段,避免空字符串破坏 key 结构。 func normalizePart(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/backend/internal/completion/manager.go b/backend/internal/completion/manager.go index 208837f..4ae13ca 100644 --- a/backend/internal/completion/manager.go +++ b/backend/internal/completion/manager.go @@ -13,35 +13,40 @@ import ( var ErrUnsupportedLanguage = errors.New("unsupported language") var ErrTooManySessions = errors.New("too many active lsp sessions") +// RuntimeClient 是带生命周期管理能力的补全客户端。 type RuntimeClient interface { Client Close() error } +// SessionRegistry 抽象跨实例会话归属协调能力(如 Redis)。 type SessionRegistry interface { ClaimSession(ctx context.Context, language, sessionID string) (ownerID string, ownerEndpoint string, err error) ReleaseSession(ctx context.Context, language, sessionID string) error Close() error } +// LanguageServerSpec 描述某种语言对应的 LSP 启动参数。 type LanguageServerSpec struct { - Language string - LanguageID string - Command string - Args []string + Language string // 语言名(如 go/typescript),用于路由匹配。 + LanguageID string // 传给 LSP 的 languageId。 + Command string // LSP 可执行命令。 + Args []string // LSP 启动参数。 } type ClientFactory func(ctx context.Context, spec LanguageServerSpec, workspaceDir string) (RuntimeClient, error) +// ManagerConfig 控制会话池容量、TTL 与实例信息。 type ManagerConfig struct { - WorkspaceDir string - MaxSessions int - SessionTTL time.Duration - CleanupInterval time.Duration - InstanceID string - Registry SessionRegistry + WorkspaceDir string // LSP 进程工作区目录。 + MaxSessions int // 本实例会话上限。 + SessionTTL time.Duration // 会话空闲超时。 + CleanupInterval time.Duration // 会话清理周期。 + InstanceID string // 当前实例 ID。 + Registry SessionRegistry // 可选分布式会话注册中心。 } +// Manager 按 language/session 复用 LSP 会话,并负责清理与淘汰。 type Manager struct { mu sync.Mutex @@ -63,6 +68,7 @@ type managedSession struct { createdAt time.Time } +// ErrSessionOwnedByOtherInstance 表示会话已被其他实例持有。 type ErrSessionOwnedByOtherInstance struct { OwnerID string OwnerEndpoint string @@ -75,6 +81,7 @@ func (e *ErrSessionOwnedByOtherInstance) Error() string { return fmt.Sprintf("session owned by another instance: %s", e.OwnerID) } +// NewManager 构建会话管理器并启动后台清理协程。 func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory ClientFactory) *Manager { if config.MaxSessions <= 0 { config.MaxSessions = 256 @@ -109,6 +116,7 @@ func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory Client return m } +// Complete 处理补全请求,包含语言匹配、会话归属、会话复用/创建。 func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) { language := normalizeLanguage(req.Language) if language == "" { @@ -124,6 +132,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) { sessionID := normalizeSessionID(req.SessionID) if m.config.Registry != nil { + // 分布式模式下先声明会话归属,避免多实例并发写同一会话。 ownerID, ownerEndpoint, err := m.config.Registry.ClaimSession(ctx, language, sessionID) if err != nil { return Response{}, err @@ -154,6 +163,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) { return resp, nil } +// ActiveSessions 统计当前实例内各语言活跃会话数。 func (m *Manager) ActiveSessions() map[string]int { m.mu.Lock() defer m.mu.Unlock() @@ -165,6 +175,7 @@ func (m *Manager) ActiveSessions() map[string]int { return out } +// Close 停止后台任务并释放所有会话与注册中心资源。 func (m *Manager) Close() error { m.stoppedOnce.Do(func() { close(m.stopCh) @@ -186,6 +197,7 @@ func (m *Manager) Close() error { return nil } +// cleanupLoop 周期清理闲置会话。 func (m *Manager) cleanupLoop() { ticker := time.NewTicker(m.config.CleanupInterval) defer ticker.Stop() @@ -200,6 +212,7 @@ func (m *Manager) cleanupLoop() { } } +// cleanupIdleSessions 关闭超过 TTL 未使用的会话。 func (m *Manager) cleanupIdleSessions() { cutoff := time.Now().Add(-m.config.SessionTTL) @@ -218,6 +231,7 @@ func (m *Manager) cleanupIdleSessions() { } } +// getOrCreateSession 返回已有会话,或按需新建一个会话。 func (m *Manager) getOrCreateSession( ctx context.Context, sessionKey string, @@ -258,6 +272,7 @@ func (m *Manager) getOrCreateSession( m.mu.Lock() defer m.mu.Unlock() if existing, ok := m.sessions[sessionKey]; ok { + // 并发竞争下可能已经被其他协程创建,直接复用并关闭新 client。 _ = client.Close() existing.lastUsed = now return existing, nil @@ -266,6 +281,7 @@ func (m *Manager) getOrCreateSession( return newSession, nil } +// evictLeastRecentlyUsedLocked 在达到上限时淘汰最久未使用会话。 func (m *Manager) evictLeastRecentlyUsedLocked() bool { if len(m.sessions) == 0 { return false @@ -301,6 +317,7 @@ func buildSessionKey(language, sessionID string) string { return language + ":" + normalizeSessionID(sessionID) } +// normalizeSessionID 将空 session 归一为 default,便于复用同一会话键。 func normalizeSessionID(sessionID string) string { sid := strings.TrimSpace(sessionID) if sid == "" { @@ -309,6 +326,7 @@ func normalizeSessionID(sessionID string) string { return sid } +// normalizeLanguage 统一语言名大小写和空白。 func normalizeLanguage(language string) string { return strings.ToLower(strings.TrimSpace(language)) } diff --git a/backend/internal/completion/manager_test.go b/backend/internal/completion/manager_test.go index 9acdc0e..ec7fa38 100644 --- a/backend/internal/completion/manager_test.go +++ b/backend/internal/completion/manager_test.go @@ -31,6 +31,7 @@ func (f *fakeRuntimeClient) Close() error { return nil } +// 同 language+session 的请求应复用同一个底层 client。 func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) { createCount := 0 factory := func(_ context.Context, spec LanguageServerSpec, _ string) (RuntimeClient, error) { @@ -68,6 +69,7 @@ func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) { } } +// 未配置的语言应返回 ErrUnsupportedLanguage。 func TestManagerCompleteUnsupportedLanguage(t *testing.T) { m := NewManager(ManagerConfig{}, []LanguageServerSpec{ {Language: "go", LanguageID: "go", Command: "gopls"}, @@ -88,6 +90,7 @@ func TestManagerCompleteUnsupportedLanguage(t *testing.T) { } } +// 超过空闲 TTL 的会话应被后台清理并关闭 client。 func TestManagerCleanupIdleSession(t *testing.T) { client := &fakeRuntimeClient{} m := NewManager(ManagerConfig{ diff --git a/backend/internal/completion/service.go b/backend/internal/completion/service.go index 80456f2..9bb1424 100644 --- a/backend/internal/completion/service.go +++ b/backend/internal/completion/service.go @@ -9,6 +9,7 @@ import ( var ErrInvalidRequest = errors.New("invalid completion request") +// Request 是统一的补全请求模型。 type Request struct { Language string `json:"language,omitempty"` SessionID string `json:"sessionId,omitempty"` @@ -18,6 +19,7 @@ type Request struct { Character int `json:"character"` } +// Item 对应一个补全候选项。 type Item struct { Label string `json:"label"` Kind int `json:"kind,omitempty"` @@ -28,11 +30,13 @@ type Item struct { FilterText string `json:"filterText,omitempty"` } +// Response 是补全结果集合。 type Response struct { Items []Item `json:"items"` IsIncomplete bool `json:"isIncomplete"` } +// Client 抽象 LSP 客户端所需的最小能力。 type Client interface { DidOpen(ctx context.Context, uri, text string, version int) error DidChange(ctx context.Context, uri, text string, version int) error @@ -43,6 +47,7 @@ type documentState struct { version int } +// Service 负责文档生命周期同步(didOpen/didChange)与补全调用。 type Service struct { client Client @@ -57,6 +62,7 @@ func NewService(client Client) *Service { } } +// Complete 根据 URI 的历史状态决定发送 didOpen 或 didChange,再请求补全。 func (s *Service) Complete(ctx context.Context, req Request) (Response, error) { if err := validateRequest(req); err != nil { return Response{}, err @@ -86,6 +92,7 @@ func (s *Service) Complete(ctx context.Context, req Request) (Response, error) { return resp, nil } +// validateRequest 做基础参数校验,避免向 LSP 发送非法请求。 func validateRequest(req Request) error { if req.URI == "" { return ErrInvalidRequest diff --git a/backend/internal/completion/service_test.go b/backend/internal/completion/service_test.go index eb416df..aaf83d8 100644 --- a/backend/internal/completion/service_test.go +++ b/backend/internal/completion/service_test.go @@ -52,6 +52,7 @@ func (f *fakeLSPClient) Completion(_ context.Context, uri string, line, characte return f.completionResp, nil } +// 首次请求应发送 didOpen,并继续请求 completion。 func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) { fake := &fakeLSPClient{ completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true}, @@ -84,6 +85,7 @@ func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) { } } +// 同一文档第二次请求应发送 didChange,且版本号递增。 func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) { fake := &fakeLSPClient{} svc := NewService(fake) @@ -119,6 +121,7 @@ func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) { } } +// 参数不合法时应直接返回 ErrInvalidRequest。 func TestServiceCompleteValidatesRequest(t *testing.T) { svc := NewService(&fakeLSPClient{}) @@ -128,6 +131,7 @@ func TestServiceCompleteValidatesRequest(t *testing.T) { } } +// 底层 client 出错应向上透传错误。 func TestServiceCompleteReturnsClientError(t *testing.T) { fake := &fakeLSPClient{openErr: errors.New("open failed")} svc := NewService(fake) diff --git a/backend/internal/lsp/client.go b/backend/internal/lsp/client.go index 2f2eb14..0069f39 100644 --- a/backend/internal/lsp/client.go +++ b/backend/internal/lsp/client.go @@ -24,6 +24,7 @@ import ( var errClientClosed = errors.New("lsp client closed") +// Client 封装与 Language Server 的 JSON-RPC/LSP 通信。 type Client struct { cmd *exec.Cmd stdin io.WriteCloser @@ -49,12 +50,14 @@ type rpcResponse struct { err error } +// rpcError 对应 JSON-RPC 错误对象。 type rpcError struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data,omitempty"` } +// incomingEnvelope 表示从服务端读到的响应/通知外层结构。 type incomingEnvelope struct { ID *json.RawMessage `json:"id,omitempty"` Result json.RawMessage `json:"result,omitempty"` @@ -76,14 +79,16 @@ type lspCompletionList struct { Items []lspCompletionItem `json:"items"` } +// Config 定义 LSP 子进程启动参数及客户端标识。 type Config struct { - Command string - Args []string - RootPath string - LanguageID string - ClientName string + Command string // LSP 可执行命令。 + Args []string // 启动参数(通常包含 --stdio)。 + RootPath string // 工作区根路径。 + LanguageID string // didOpen/didChange 使用的 languageId。 + ClientName string // initialize.clientInfo.name。 } +// NewClient 启动语言服务器进程并完成 initialize 握手。 func NewClient(parent context.Context, cfg Config) (*Client, error) { if cfg.Command == "" { cfg.Command = "gopls" @@ -130,6 +135,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) { go func() { client.exitCh <- cmd.Wait() }() + // 独立协程持续读取 stdout 并分发响应。 go client.readLoop(stdout) initCtx, cancel := context.WithTimeout(parent, 10*time.Second) @@ -143,6 +149,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) { return client, nil } +// initialize 完成 LSP initialize/initialized 流程。 func (c *Client) initialize(ctx context.Context, rootPath string) error { rootURI, err := pathToURI(rootPath) if err != nil { @@ -183,6 +190,7 @@ func (c *Client) initialize(ctx context.Context, rootPath string) error { return nil } +// DidOpen 发送 textDocument/didOpen,告知服务端首次打开文档。 func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error { normalizedURI, err := c.normalizeURI(uri) if err != nil { @@ -200,6 +208,7 @@ func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) err return c.notifyWithContext(ctx, "textDocument/didOpen", params) } +// DidChange 发送 textDocument/didChange,推送文档全文与版本号。 func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error { normalizedURI, err := c.normalizeURI(uri) if err != nil { @@ -220,6 +229,7 @@ func (c *Client) DidChange(ctx context.Context, uri, text string, version int) e return c.notifyWithContext(ctx, "textDocument/didChange", params) } +// Completion 调用 textDocument/completion,并兼容两种返回形态。 func (c *Client) Completion(ctx context.Context, uri string, line, character int) (completion.Response, error) { normalizedURI, err := c.normalizeURI(uri) if err != nil { @@ -247,6 +257,7 @@ func (c *Client) Completion(ctx context.Context, uri string, line, character int } if body[0] == '[' { + // 部分 LSP 服务端直接返回 CompletionItem[]。 var items []lspCompletionItem if err := json.Unmarshal(body, &items); err != nil { return completion.Response{}, fmt.Errorf("decode completion items: %w", err) @@ -266,6 +277,7 @@ func (c *Client) Completion(ctx context.Context, uri string, line, character int var windowsDrivePattern = regexp.MustCompile(`^[A-Za-z]:`) +// normalizeURI 将相对 file URI 重写为工作区绝对路径 URI。 func (c *Client) normalizeURI(rawURI string) (string, error) { if rawURI == "" { return "", errors.New("empty uri") @@ -296,6 +308,7 @@ func (c *Client) normalizeURI(rawURI string) (string, error) { return pathToURI(localPath) } +// Close 优雅关闭 LSP 进程并失败通知所有未完成请求。 func (c *Client) Close() error { c.closeOnce.Do(func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -320,6 +333,7 @@ func (c *Client) Close() error { return nil } +// request 发送 JSON-RPC 请求并阻塞等待对应响应。 func (c *Client) request(ctx context.Context, method string, params any, out any) error { if c.closed.Load() { return errClientClosed @@ -365,6 +379,7 @@ func (c *Client) notify(method string, params any) error { return c.notifyWithContext(context.Background(), method, params) } +// notifyWithContext 发送 JSON-RPC 通知(无响应)。 func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error { if c.closed.Load() { return errClientClosed @@ -383,6 +398,7 @@ func (c *Client) notifyWithContext(ctx context.Context, method string, params an return c.writeMessage(msg) } +// writeMessage 按 LSP framing 写入消息头和消息体。 func (c *Client) writeMessage(msg any) error { payload, err := json.Marshal(msg) if err != nil { @@ -403,6 +419,7 @@ func (c *Client) writeMessage(msg any) error { return nil } +// readLoop 持续读取 LSP 消息并交给 handleIncoming 分发。 func (c *Client) readLoop(stdout io.Reader) { reader := bufio.NewReader(stdout) for { @@ -415,6 +432,7 @@ func (c *Client) readLoop(stdout io.Reader) { } } +// readMessage 按 Content-Length 协议边界读取单条 JSON-RPC 消息。 func readMessage(reader *bufio.Reader) ([]byte, error) { contentLength := 0 for { @@ -451,6 +469,7 @@ func readMessage(reader *bufio.Reader) ([]byte, error) { return body, nil } +// handleIncoming 将响应按 id 投递给对应等待中的请求通道。 func (c *Client) handleIncoming(body []byte) { var envelope incomingEnvelope if err := json.Unmarshal(body, &envelope); err != nil { @@ -485,6 +504,7 @@ func (c *Client) handleIncoming(body []byte) { wait <- rpcResponse{result: envelope.Result} } +// normalizeID 统一处理数字/字符串两种 JSON-RPC id。 func normalizeID(raw json.RawMessage) string { raw = bytes.TrimSpace(raw) if len(raw) == 0 { @@ -506,6 +526,7 @@ func (c *Client) removePending(key string) { c.pendingMu.Unlock() } +// failPending 在连接异常时让所有挂起请求立即失败返回。 func (c *Client) failPending(err error) { c.pendingMu.Lock() defer c.pendingMu.Unlock() @@ -516,6 +537,7 @@ func (c *Client) failPending(err error) { } } +// mapCompletionItems 将 LSP 项结构映射为网关统一输出结构。 func mapCompletionItems(items []lspCompletionItem) []completion.Item { out := make([]completion.Item, 0, len(items)) for _, it := range items { @@ -532,6 +554,7 @@ func mapCompletionItems(items []lspCompletionItem) []completion.Item { return out } +// decodeDocumentation 兼容 string 和 MarkupContent 两种文档字段。 func decodeDocumentation(raw json.RawMessage) string { raw = bytes.TrimSpace(raw) if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { @@ -556,6 +579,7 @@ func decodeDocumentation(raw json.RawMessage) string { return "" } +// pathToURI 将本地路径转换为标准 file URI。 func pathToURI(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { diff --git a/backend/internal/lsp/client_test.go b/backend/internal/lsp/client_test.go index e908ff3..55d8040 100644 --- a/backend/internal/lsp/client_test.go +++ b/backend/internal/lsp/client_test.go @@ -26,6 +26,7 @@ func TestNormalizeURIRebasesRelativeFileURI(t *testing.T) { } } +// 绝对 file URI 不应被重写。 func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) { workspace, err := filepath.Abs(filepath.Join("testdata", "ws")) if err != nil {