feat: enhance API and session management with Nacos and Redis integration

- Add Nacos registry for service registration and deregistration.
- Implement Redis registry for session management with heartbeat and session claiming.
- Improve completion service with session handling and request validation.
- Enhance WebSocket handling for completion requests with JSON-RPC support.
- Add tests for new registry implementations and completion manager functionalities.
- Refactor existing code for better readability and maintainability.
This commit is contained in:
2026-02-15 17:46:34 +08:00
parent 57afb90bc0
commit 3284ce07c7
22 changed files with 1863 additions and 87 deletions

View File

@@ -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`

View File

@@ -0,0 +1,90 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"monica-go-completion-backend/internal/completion"
)
func TestLoadConfigFileWithEnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := `{
"port": "8081",
"requestTimeout": "3s",
"enableRedis": false,
"servers": [
{
"language": "go",
"languageId": "go",
"command": "gopls-from-file",
"args": []
}
]
}`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config file failed: %v", err)
}
t.Setenv("CONFIG_FILE", path)
t.Setenv("PORT", "9090")
t.Setenv("GO_LSP_COMMAND", "gopls-from-env")
cfg, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if cfg.Port != "9090" {
t.Fatalf("expected env to override port, got %s", cfg.Port)
}
if cfg.RequestTimeout != 3*time.Second {
t.Fatalf("expected requestTimeout=3s, got %s", cfg.RequestTimeout)
}
if cfg.EnableRedis {
t.Fatal("expected enableRedis=false from config file")
}
goSpec, ok := findServer(cfg.Servers, "go")
if !ok {
t.Fatal("expected go server in config")
}
if goSpec.Command != "gopls-from-env" {
t.Fatalf("expected go command overridden by env, got %s", goSpec.Command)
}
}
func TestLoadConfigFileInvalidDuration(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := `{
"requestTimeout": "not-a-duration"
}`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config file failed: %v", err)
}
t.Setenv("CONFIG_FILE", path)
_, err := loadConfig()
if err == nil {
t.Fatal("expected loadConfig() to fail")
}
if !strings.Contains(err.Error(), "requestTimeout") {
t.Fatalf("expected requestTimeout parse error, got %v", err)
}
}
func findServer(specs []completion.LanguageServerSpec, language string) (completion.LanguageServerSpec, bool) {
target := strings.ToLower(strings.TrimSpace(language))
for _, spec := range specs {
if strings.ToLower(strings.TrimSpace(spec.Language)) == target {
return spec, true
}
}
return completion.LanguageServerSpec{}, false
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
@@ -24,33 +25,113 @@ import (
var requestIDSeed atomic.Int64
// config 汇总服务启动所需的环境配置。
type config struct {
Port string
WorkspaceDir string
AllowOrigin string
APIToken string
RequestTimeout time.Duration
MaxBodyBytes int64
SessionTTL time.Duration
Port string // HTTP 监听端口,如 8080。
// WorkspaceDir 是 LSP 进程使用的工作区根目录。
WorkspaceDir string
// AllowOrigin 控制 CORS 的 Access-Control-Allow-Origin
AllowOrigin string
// APIToken 为可选 API Key为空时不启用鉴权。
APIToken string
// RequestTimeout 为单次补全请求的超时时间。
RequestTimeout time.Duration
// MaxBodyBytes 为 HTTP/WS 消息体最大字节数。
MaxBodyBytes int64
// SessionTTL 为会话空闲超时,超过后会被清理。
SessionTTL time.Duration
// CleanupInterval 为后台扫描并清理闲置会话的周期。
CleanupInterval time.Duration
MaxSessions int
InstanceID string
InstanceURL string
EnableRedis bool
RedisAddr string
RedisPassword string
RedisDB int
RedisKeyPrefix string
InstanceTTL time.Duration
Heartbeat time.Duration
Servers []completion.LanguageServerSpec
// MaxSessions 限制本实例最多持有的活跃会话数。
MaxSessions int
// InstanceID 是当前实例唯一标识,用于分布式会话归属判断。
InstanceID string
// InstanceURL 是当前实例可被路由到的对外地址。
InstanceURL string
// EnableRedis 控制是否启用 Redis 粘性路由/会话归属。
EnableRedis bool
// RedisAddr 为 Redis 地址,例如 127.0.0.1:6379。
RedisAddr string
// RedisPassword 为 Redis 密码,可为空。
RedisPassword string
// RedisDB 为 Redis 数据库编号。
RedisDB int
// RedisKeyPrefix 为 Redis 键前缀,避免与其他业务冲突。
RedisKeyPrefix string
// InstanceTTL 为实例元数据在 Redis 中的过期时间。
InstanceTTL time.Duration
// Heartbeat 为实例心跳刷新周期。
Heartbeat time.Duration
// EnableNacosRegister 控制是否在启动时向 Nacos 注册实例。
EnableNacosRegister bool
// NacosServerAddr 为 Nacos 地址host:port
NacosServerAddr string
// NacosNamespace 为 Nacos namespace可为空。
NacosNamespace string
// NacosGroup 为 Nacos group默认 DEFAULT_GROUP。
NacosGroup string
// NacosServiceName 为当前服务在 Nacos 中的服务名。
NacosServiceName string
// NacosClusterName 为 Nacos clusterName可为空。
NacosClusterName string
// NacosUsername 为 Nacos 认证用户名,可为空。
NacosUsername string
// NacosPassword 为 Nacos 认证密码,可为空。
NacosPassword string
// NacosRegisterIP 为实例向 Nacos 注册时使用的 IP。
NacosRegisterIP string
// NacosRegisterPort 为实例向 Nacos 注册时使用的端口。
NacosRegisterPort int
// NacosEphemeral 控制是否使用临时实例模式。
NacosEphemeral bool
// Servers 定义各语言 LSP 服务的启动命令与参数。
Servers []completion.LanguageServerSpec
}
// fileConfig 对应 JSON 配置文件格式,使用指针区分“未设置”和“显式设置”。
type fileConfig struct {
Port *string `json:"port"`
WorkspaceDir *string `json:"workspaceDir"`
AllowOrigin *string `json:"allowOrigin"`
APIToken *string `json:"apiToken"`
RequestTimeout *string `json:"requestTimeout"`
MaxBodyBytes *int64 `json:"maxBodyBytes"`
SessionTTL *string `json:"sessionTTL"`
CleanupInterval *string `json:"cleanupInterval"`
MaxSessions *int `json:"maxSessions"`
InstanceID *string `json:"instanceID"`
InstanceURL *string `json:"instanceURL"`
EnableRedis *bool `json:"enableRedis"`
RedisAddr *string `json:"redisAddr"`
RedisPassword *string `json:"redisPassword"`
RedisDB *int `json:"redisDB"`
RedisKeyPrefix *string `json:"redisKeyPrefix"`
InstanceTTL *string `json:"instanceTTL"`
Heartbeat *string `json:"heartbeat"`
EnableNacosRegister *bool `json:"enableNacosRegister"`
NacosServerAddr *string `json:"nacosServerAddr"`
NacosNamespace *string `json:"nacosNamespace"`
NacosGroup *string `json:"nacosGroup"`
NacosServiceName *string `json:"nacosServiceName"`
NacosClusterName *string `json:"nacosClusterName"`
NacosUsername *string `json:"nacosUsername"`
NacosPassword *string `json:"nacosPassword"`
NacosRegisterIP *string `json:"nacosRegisterIP"`
NacosRegisterPort *int `json:"nacosRegisterPort"`
NacosEphemeral *bool `json:"nacosEphemeral"`
Servers []completion.LanguageServerSpec `json:"servers"`
}
func main() {
cfg := loadConfig()
// 读取环境变量并组装运行配置。
cfg, err := loadConfig()
if err != nil {
log.Fatalf("load config failed: %v", err)
}
var registry completion.SessionRegistry
if cfg.EnableRedis {
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
var err error
registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{
Addr: cfg.RedisAddr,
@@ -67,6 +148,31 @@ func main() {
log.Fatalf("create redis registry failed: %v", err)
}
}
var nacosRegistry *cluster.NacosRegistry
if cfg.EnableNacosRegister {
// Nacos 负责服务发现Redis 仍负责会话归属与粘性路由。
var err error
nacosRegistry, err = cluster.NewNacosRegistry(cluster.NacosRegistryConfig{
ServerAddr: cfg.NacosServerAddr,
Namespace: cfg.NacosNamespace,
Group: cfg.NacosGroup,
ServiceName: cfg.NacosServiceName,
ClusterName: cfg.NacosClusterName,
Username: cfg.NacosUsername,
Password: cfg.NacosPassword,
IP: cfg.NacosRegisterIP,
Port: uint64(cfg.NacosRegisterPort),
Ephemeral: cfg.NacosEphemeral,
Metadata: map[string]string{
"instanceId": cfg.InstanceID,
"instanceUrl": cfg.InstanceURL,
"component": "lsp-gateway",
},
})
if err != nil {
log.Fatalf("register nacos instance failed: %v", err)
}
}
manager := completion.NewManager(completion.ManagerConfig{
WorkspaceDir: cfg.WorkspaceDir,
@@ -88,6 +194,7 @@ func main() {
_ = manager.Close()
}()
// 注册通用中间件与业务路由。
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
@@ -109,12 +216,14 @@ func main() {
}
go func() {
// HTTP 服务主循环,非正常退出直接终止进程。
log.Printf(
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t",
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t, nacos=%t",
cfg.Port,
cfg.WorkspaceDir,
cfg.InstanceID,
cfg.EnableRedis,
cfg.EnableNacosRegister,
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server failed: %v", err)
@@ -125,6 +234,7 @@ func main() {
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
// 收到退出信号后,按超时窗口优雅关闭。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -134,56 +244,425 @@ func main() {
if err := manager.Close(); err != nil {
log.Printf("lsp manager close failed: %v", err)
}
if nacosRegistry != nil {
if err := nacosRegistry.Close(); err != nil {
log.Printf("nacos deregister failed: %v", err)
}
}
}
func loadConfig() config {
// loadConfig 从环境变量读取配置并应用默认值。
func loadConfig() (config, error) {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
return config{
Port: getenv("PORT", "8080"),
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")),
RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second),
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20),
SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute),
CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute),
MaxSessions: getenvInt("MAX_SESSIONS", 256),
InstanceID: getenv("INSTANCE_ID", defaultInstanceID()),
InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")),
EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true),
RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"),
RedisPassword: getenv("REDIS_PASSWORD", ""),
RedisDB: getenvInt("REDIS_DB", 1),
RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"),
InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second),
Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second),
Servers: []completion.LanguageServerSpec{
{
Language: "go",
LanguageID: "go",
Command: getenv("GO_LSP_COMMAND", "gopls"),
Args: getenvArgs("GO_LSP_ARGS", ""),
},
{
Language: "javascript",
LanguageID: "javascript",
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"),
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"),
},
{
Language: "typescript",
LanguageID: "typescript",
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"),
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"),
},
cfg := config{
Port: "8080",
WorkspaceDir: cwd,
AllowOrigin: "*",
RequestTimeout: 10 * time.Second,
MaxBodyBytes: 2 << 20,
SessionTTL: 20 * time.Minute,
CleanupInterval: 2 * time.Minute,
MaxSessions: 256,
EnableRedis: true,
RedisAddr: "10.0.0.10:6379",
RedisDB: 1,
RedisKeyPrefix: "lsp-gateway",
InstanceTTL: 30 * time.Second,
Heartbeat: 10 * time.Second,
NacosServerAddr: "10.0.0.10:8848",
NacosGroup: "DEFAULT_GROUP",
NacosServiceName: "lsp-gateway",
NacosEphemeral: true,
Servers: defaultLanguageServers(),
}
if configPath, ok := resolveConfigFilePath(); ok {
fileCfg, err := readConfigFile(configPath)
if err != nil {
return config{}, fmt.Errorf("read config file %q: %w", configPath, err)
}
if err := applyFileConfig(&cfg, fileCfg); err != nil {
return config{}, fmt.Errorf("apply config file %q: %w", configPath, err)
}
log.Printf("config file loaded: %s", configPath)
} else {
log.Printf("config file not found, using defaults + environment variables")
}
applyEnvOverrides(&cfg)
finalizeConfig(&cfg, cwd)
log.Printf(
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s",
cfg.Port,
cfg.WorkspaceDir,
cfg.EnableRedis,
cfg.EnableNacosRegister,
cfg.NacosServerAddr,
)
return cfg, nil
}
func resolveConfigFilePath() (string, bool) {
explicitPath := strings.TrimSpace(os.Getenv("CONFIG_FILE"))
if explicitPath != "" {
return explicitPath, true
}
const defaultPath = "config.json"
info, err := os.Stat(defaultPath)
if err == nil && !info.IsDir() {
return defaultPath, true
}
return "", false
}
func readConfigFile(path string) (fileConfig, error) {
body, err := os.ReadFile(path)
if err != nil {
return fileConfig{}, err
}
var cfg fileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
return fileConfig{}, err
}
return cfg, nil
}
func applyFileConfig(cfg *config, fc fileConfig) error {
if fc.Port != nil {
cfg.Port = strings.TrimSpace(*fc.Port)
}
if fc.WorkspaceDir != nil {
cfg.WorkspaceDir = strings.TrimSpace(*fc.WorkspaceDir)
}
if fc.AllowOrigin != nil {
cfg.AllowOrigin = strings.TrimSpace(*fc.AllowOrigin)
}
if fc.APIToken != nil {
cfg.APIToken = strings.TrimSpace(*fc.APIToken)
}
if fc.RequestTimeout != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.RequestTimeout))
if err != nil {
return fmt.Errorf("requestTimeout: %w", err)
}
cfg.RequestTimeout = d
}
if fc.MaxBodyBytes != nil {
cfg.MaxBodyBytes = *fc.MaxBodyBytes
}
if fc.SessionTTL != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.SessionTTL))
if err != nil {
return fmt.Errorf("sessionTTL: %w", err)
}
cfg.SessionTTL = d
}
if fc.CleanupInterval != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.CleanupInterval))
if err != nil {
return fmt.Errorf("cleanupInterval: %w", err)
}
cfg.CleanupInterval = d
}
if fc.MaxSessions != nil {
cfg.MaxSessions = *fc.MaxSessions
}
if fc.InstanceID != nil {
cfg.InstanceID = strings.TrimSpace(*fc.InstanceID)
}
if fc.InstanceURL != nil {
cfg.InstanceURL = strings.TrimSpace(*fc.InstanceURL)
}
if fc.EnableRedis != nil {
cfg.EnableRedis = *fc.EnableRedis
}
if fc.RedisAddr != nil {
cfg.RedisAddr = strings.TrimSpace(*fc.RedisAddr)
}
if fc.RedisPassword != nil {
cfg.RedisPassword = strings.TrimSpace(*fc.RedisPassword)
}
if fc.RedisDB != nil {
cfg.RedisDB = *fc.RedisDB
}
if fc.RedisKeyPrefix != nil {
cfg.RedisKeyPrefix = strings.TrimSpace(*fc.RedisKeyPrefix)
}
if fc.InstanceTTL != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.InstanceTTL))
if err != nil {
return fmt.Errorf("instanceTTL: %w", err)
}
cfg.InstanceTTL = d
}
if fc.Heartbeat != nil {
d, err := time.ParseDuration(strings.TrimSpace(*fc.Heartbeat))
if err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
cfg.Heartbeat = d
}
if fc.EnableNacosRegister != nil {
cfg.EnableNacosRegister = *fc.EnableNacosRegister
}
if fc.NacosServerAddr != nil {
cfg.NacosServerAddr = strings.TrimSpace(*fc.NacosServerAddr)
}
if fc.NacosNamespace != nil {
cfg.NacosNamespace = strings.TrimSpace(*fc.NacosNamespace)
}
if fc.NacosGroup != nil {
cfg.NacosGroup = strings.TrimSpace(*fc.NacosGroup)
}
if fc.NacosServiceName != nil {
cfg.NacosServiceName = strings.TrimSpace(*fc.NacosServiceName)
}
if fc.NacosClusterName != nil {
cfg.NacosClusterName = strings.TrimSpace(*fc.NacosClusterName)
}
if fc.NacosUsername != nil {
cfg.NacosUsername = strings.TrimSpace(*fc.NacosUsername)
}
if fc.NacosPassword != nil {
cfg.NacosPassword = strings.TrimSpace(*fc.NacosPassword)
}
if fc.NacosRegisterIP != nil {
cfg.NacosRegisterIP = strings.TrimSpace(*fc.NacosRegisterIP)
}
if fc.NacosRegisterPort != nil {
cfg.NacosRegisterPort = *fc.NacosRegisterPort
}
if fc.NacosEphemeral != nil {
cfg.NacosEphemeral = *fc.NacosEphemeral
}
if fc.Servers != nil {
cfg.Servers = normalizeServerSpecs(fc.Servers)
}
return nil
}
func applyEnvOverrides(cfg *config) {
cfg.Port = getenv("PORT", cfg.Port)
cfg.WorkspaceDir = getenv("WORKSPACE_DIR", cfg.WorkspaceDir)
cfg.AllowOrigin = getenv("CORS_ALLOW_ORIGIN", cfg.AllowOrigin)
cfg.APIToken = getenv("LSP_API_TOKEN", cfg.APIToken)
cfg.RequestTimeout = getenvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout)
cfg.MaxBodyBytes = getenvInt64("MAX_BODY_BYTES", cfg.MaxBodyBytes)
cfg.SessionTTL = getenvDuration("SESSION_TTL", cfg.SessionTTL)
cfg.CleanupInterval = getenvDuration("SESSION_CLEANUP_INTERVAL", cfg.CleanupInterval)
cfg.MaxSessions = getenvInt("MAX_SESSIONS", cfg.MaxSessions)
cfg.InstanceID = getenv("INSTANCE_ID", cfg.InstanceID)
cfg.InstanceURL = getenv("INSTANCE_URL", cfg.InstanceURL)
cfg.EnableRedis = getenvBool("ENABLE_REDIS_STICKY", cfg.EnableRedis)
cfg.RedisAddr = getenv("REDIS_ADDR", cfg.RedisAddr)
cfg.RedisPassword = getenv("REDIS_PASSWORD", cfg.RedisPassword)
cfg.RedisDB = getenvInt("REDIS_DB", cfg.RedisDB)
cfg.RedisKeyPrefix = getenv("REDIS_KEY_PREFIX", cfg.RedisKeyPrefix)
cfg.InstanceTTL = getenvDuration("INSTANCE_TTL", cfg.InstanceTTL)
cfg.Heartbeat = getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", cfg.Heartbeat)
cfg.EnableNacosRegister = getenvBool("ENABLE_NACOS_REGISTER", cfg.EnableNacosRegister)
cfg.NacosServerAddr = getenv("NACOS_SERVER_ADDR", cfg.NacosServerAddr)
cfg.NacosNamespace = getenv("NACOS_NAMESPACE", cfg.NacosNamespace)
cfg.NacosGroup = getenv("NACOS_GROUP", cfg.NacosGroup)
cfg.NacosServiceName = getenv("NACOS_SERVICE_NAME", cfg.NacosServiceName)
cfg.NacosClusterName = getenv("NACOS_CLUSTER_NAME", cfg.NacosClusterName)
cfg.NacosUsername = getenv("NACOS_USERNAME", cfg.NacosUsername)
cfg.NacosPassword = getenv("NACOS_PASSWORD", cfg.NacosPassword)
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral)
cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers)
}
func finalizeConfig(cfg *config, cwd string) {
if strings.TrimSpace(cfg.Port) == "" {
cfg.Port = "8080"
}
if strings.TrimSpace(cfg.WorkspaceDir) == "" {
cfg.WorkspaceDir = cwd
}
if strings.TrimSpace(cfg.AllowOrigin) == "" {
cfg.AllowOrigin = "*"
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 10 * time.Second
}
if cfg.MaxBodyBytes <= 0 {
cfg.MaxBodyBytes = 2 << 20
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 20 * time.Minute
}
if cfg.CleanupInterval <= 0 {
cfg.CleanupInterval = 2 * time.Minute
}
if cfg.MaxSessions <= 0 {
cfg.MaxSessions = 256
}
if strings.TrimSpace(cfg.InstanceID) == "" {
cfg.InstanceID = defaultInstanceID()
}
if strings.TrimSpace(cfg.InstanceURL) == "" {
cfg.InstanceURL = "http://127.0.0.1:" + cfg.Port
}
if strings.TrimSpace(cfg.RedisAddr) == "" {
cfg.RedisAddr = "10.0.0.10:6379"
}
if strings.TrimSpace(cfg.RedisKeyPrefix) == "" {
cfg.RedisKeyPrefix = "lsp-gateway"
}
if cfg.InstanceTTL <= 0 {
cfg.InstanceTTL = 30 * time.Second
}
if cfg.Heartbeat <= 0 {
cfg.Heartbeat = 10 * time.Second
}
if strings.TrimSpace(cfg.NacosServerAddr) == "" {
cfg.NacosServerAddr = "10.0.0.10:8848"
}
if strings.TrimSpace(cfg.NacosGroup) == "" {
cfg.NacosGroup = "DEFAULT_GROUP"
}
if strings.TrimSpace(cfg.NacosServiceName) == "" {
cfg.NacosServiceName = "lsp-gateway"
}
if cfg.NacosRegisterPort <= 0 {
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
}
cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL)
if len(cfg.Servers) == 0 {
cfg.Servers = defaultLanguageServers()
}
}
func parsePortOrDefault(port string, fallback int) int {
trimmed := strings.TrimSpace(port)
if trimmed == "" {
return fallback
}
n, err := strconv.Atoi(trimmed)
if err != nil || n <= 0 {
return fallback
}
return n
}
func defaultLanguageServers() []completion.LanguageServerSpec {
return []completion.LanguageServerSpec{
{
Language: "go",
LanguageID: "go",
Command: "gopls",
},
{
Language: "javascript",
LanguageID: "javascript",
Command: "typescript-language-server",
Args: []string{"--stdio"},
},
{
Language: "typescript",
LanguageID: "typescript",
Command: "typescript-language-server",
Args: []string{"--stdio"},
},
}
}
func normalizeServerSpecs(specs []completion.LanguageServerSpec) []completion.LanguageServerSpec {
out := make([]completion.LanguageServerSpec, 0, len(specs))
for _, spec := range specs {
spec.Language = strings.TrimSpace(spec.Language)
spec.LanguageID = strings.TrimSpace(spec.LanguageID)
spec.Command = strings.TrimSpace(spec.Command)
out = append(out, spec)
}
return out
}
func applyLanguageServerEnvOverrides(servers []completion.LanguageServerSpec) []completion.LanguageServerSpec {
overridden := normalizeServerSpecs(servers)
overridden = applySingleLanguageServerEnv(overridden, "go", "go", "GO_LSP_COMMAND", "GO_LSP_ARGS", "gopls", "")
overridden = applySingleLanguageServerEnv(overridden, "javascript", "javascript", "JAVASCRIPT_LSP_COMMAND", "JAVASCRIPT_LSP_ARGS", "typescript-language-server", "--stdio")
overridden = applySingleLanguageServerEnv(overridden, "typescript", "typescript", "TYPESCRIPT_LSP_COMMAND", "TYPESCRIPT_LSP_ARGS", "typescript-language-server", "--stdio")
return overridden
}
func applySingleLanguageServerEnv(
servers []completion.LanguageServerSpec,
language string,
defaultLanguageID string,
commandEnv string,
argsEnv string,
defaultCommand string,
defaultArgs string,
) []completion.LanguageServerSpec {
index := findServerSpecIndex(servers, language)
if index < 0 {
servers = append(servers, completion.LanguageServerSpec{
Language: language,
LanguageID: defaultLanguageID,
})
index = len(servers) - 1
}
spec := servers[index]
if strings.TrimSpace(spec.Language) == "" {
spec.Language = language
}
if strings.TrimSpace(spec.LanguageID) == "" {
spec.LanguageID = defaultLanguageID
}
if cmd, ok := lookupEnv(commandEnv); ok {
trimmed := strings.TrimSpace(cmd)
if trimmed == "" {
spec.Command = defaultCommand
} else {
spec.Command = trimmed
}
} else if strings.TrimSpace(spec.Command) == "" {
spec.Command = defaultCommand
}
if argsValue, ok := lookupEnv(argsEnv); ok {
spec.Args = splitArgs(argsValue)
} else if len(spec.Args) == 0 {
spec.Args = splitArgs(defaultArgs)
}
servers[index] = spec
return servers
}
func findServerSpecIndex(specs []completion.LanguageServerSpec, language string) int {
target := strings.ToLower(strings.TrimSpace(language))
for i, spec := range specs {
if strings.ToLower(strings.TrimSpace(spec.Language)) == target {
return i
}
}
return -1
}
func splitArgs(raw string) []string {
value := strings.TrimSpace(raw)
if value == "" {
return nil
}
return strings.Fields(value)
}
func lookupEnv(key string) (string, bool) {
v, ok := os.LookupEnv(key)
return v, ok
}
// defaultInstanceID 生成默认实例 ID便于在日志和 Redis 中区分节点。
func defaultInstanceID() string {
host, err := os.Hostname()
if err != nil || strings.TrimSpace(host) == "" {
@@ -196,6 +675,7 @@ func defaultInstanceID() string {
return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid())
}
// getenv 读取字符串环境变量,空值时返回 fallback。
func getenv(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -204,6 +684,7 @@ func getenv(key, fallback string) string {
return v
}
// getenvArgs 按 shell 风格拆分参数列表。
func getenvArgs(key, fallback string) []string {
value := getenv(key, fallback)
if value == "" {
@@ -212,6 +693,7 @@ func getenvArgs(key, fallback string) []string {
return strings.Fields(value)
}
// getenvInt 读取整数环境变量,解析失败时返回 fallback。
func getenvInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -224,6 +706,7 @@ func getenvInt(key string, fallback int) int {
return fallback
}
// getenvInt64 读取 int64 环境变量,解析失败时返回 fallback。
func getenvInt64(key string, fallback int64) int64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -236,6 +719,7 @@ func getenvInt64(key string, fallback int64) int64 {
return fallback
}
// getenvDuration 读取 time.Duration 格式的环境变量。
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -248,6 +732,7 @@ func getenvDuration(key string, fallback time.Duration) time.Duration {
return parsed
}
// getenvBool 读取布尔环境变量,支持常见开关写法。
func getenvBool(key string, fallback bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" {
@@ -263,6 +748,7 @@ func getenvBool(key string, fallback bool) bool {
}
}
// corsMiddleware 处理跨域响应头与 OPTIONS 预检请求。
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", allowOrigin)
@@ -277,6 +763,7 @@ func corsMiddleware(allowOrigin string) gin.HandlerFunc {
}
}
// apiTokenMiddleware 校验 X-API-Key未配置 token 时放行。
func apiTokenMiddleware(token string) gin.HandlerFunc {
required := strings.TrimSpace(token)
if required == "" {
@@ -295,6 +782,7 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
}
}
// requestIDMiddleware 为请求补齐并回传 X-Request-Id。
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))

View File

@@ -0,0 +1,103 @@
package main
import (
"os"
"testing"
)
func TestLoadConfigNacosDefaults(t *testing.T) {
withEnv(t, "ENABLE_NACOS_REGISTER", "")
withEnv(t, "NACOS_SERVER_ADDR", "")
withEnv(t, "NACOS_NAMESPACE", "")
withEnv(t, "NACOS_GROUP", "")
withEnv(t, "NACOS_SERVICE_NAME", "")
withEnv(t, "NACOS_CLUSTER_NAME", "")
withEnv(t, "NACOS_USERNAME", "")
withEnv(t, "NACOS_PASSWORD", "")
withEnv(t, "NACOS_IP", "")
withEnv(t, "NACOS_PORT", "")
withEnv(t, "PORT", "")
withEnv(t, "INSTANCE_URL", "")
cfg, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if cfg.EnableNacosRegister {
t.Fatalf("expected EnableNacosRegister default false")
}
if cfg.NacosServerAddr != "10.0.0.10:8848" {
t.Fatalf("expected default NacosServerAddr 10.0.0.10:8848, got %q", cfg.NacosServerAddr)
}
if cfg.NacosGroup != "DEFAULT_GROUP" {
t.Fatalf("expected default NacosGroup DEFAULT_GROUP, got %q", cfg.NacosGroup)
}
if cfg.NacosServiceName != "lsp-gateway" {
t.Fatalf("expected default NacosServiceName lsp-gateway, got %q", cfg.NacosServiceName)
}
if cfg.NacosRegisterPort != 8080 {
t.Fatalf("expected default NacosRegisterPort 8080, got %d", cfg.NacosRegisterPort)
}
}
func TestLoadConfigNacosFromEnv(t *testing.T) {
withEnv(t, "ENABLE_NACOS_REGISTER", "true")
withEnv(t, "NACOS_SERVER_ADDR", "10.0.0.10:8848")
withEnv(t, "NACOS_NAMESPACE", "prod-ns")
withEnv(t, "NACOS_GROUP", "editor")
withEnv(t, "NACOS_SERVICE_NAME", "editor-lsp")
withEnv(t, "NACOS_CLUSTER_NAME", "hz-a")
withEnv(t, "NACOS_USERNAME", "nacos")
withEnv(t, "NACOS_PASSWORD", "nacos")
withEnv(t, "NACOS_IP", "172.16.10.9")
withEnv(t, "NACOS_PORT", "19090")
withEnv(t, "PORT", "9999")
cfg, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if !cfg.EnableNacosRegister {
t.Fatalf("expected EnableNacosRegister true")
}
if cfg.NacosServerAddr != "10.0.0.10:8848" {
t.Fatalf("unexpected Nacos server %s", cfg.NacosServerAddr)
}
if cfg.NacosNamespace != "prod-ns" {
t.Fatalf("unexpected NacosNamespace %q", cfg.NacosNamespace)
}
if cfg.NacosGroup != "editor" {
t.Fatalf("unexpected NacosGroup %q", cfg.NacosGroup)
}
if cfg.NacosServiceName != "editor-lsp" {
t.Fatalf("unexpected NacosServiceName %q", cfg.NacosServiceName)
}
if cfg.NacosClusterName != "hz-a" {
t.Fatalf("unexpected NacosClusterName %q", cfg.NacosClusterName)
}
if cfg.NacosUsername != "nacos" || cfg.NacosPassword != "nacos" {
t.Fatalf("unexpected nacos auth")
}
if cfg.NacosRegisterIP != "172.16.10.9" || cfg.NacosRegisterPort != 19090 {
t.Fatalf("unexpected register endpoint %s:%d", cfg.NacosRegisterIP, cfg.NacosRegisterPort)
}
}
func withEnv(t *testing.T, key, value string) {
t.Helper()
old, existed := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if !existed {
_ = os.Unsetenv(key)
return
}
_ = os.Setenv(key, old)
})
}

View File

@@ -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"
]
}
]
}

55
backend/config.json Normal file
View File

@@ -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"
]
}
]
}

View File

@@ -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 AlibabaNacos示例
## 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/(?<segment>.*), /api/$\{segment}
- id: lsp-ws
uri: lb:ws://lsp-gateway
predicates:
- Path=/lsp/ws/**
filters:
- RewritePath=/lsp/ws/(?<segment>.*), /ws/$\{segment}
```
## 6.4 BFF 转发逻辑WebClient 伪代码)
```java
Mono<ResponseEntity<String>> 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 通道可建立,断线重连后会话仍可恢复。

View File

@@ -0,0 +1,94 @@
# 为什么要做 Redis 会话外置 + 粘性路由
## 背景
当前 LSP 网关的核心能力是:
- 把编辑器请求转成 LSPJSON-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`

View File

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

View File

@@ -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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 == "" {

View File

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

View File

@@ -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{

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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 {