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` 默认地址:`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` - `PORT`:默认 `8080`
@@ -39,6 +61,17 @@ go run ./cmd/server
- `INSTANCE_URL`:实例可回源地址(用于路由提示),默认 `http://127.0.0.1:${PORT}` - `INSTANCE_URL`:实例可回源地址(用于路由提示),默认 `http://127.0.0.1:${PORT}`
- `INSTANCE_TTL`:实例注册 TTL默认 `30s` - `INSTANCE_TTL`:实例注册 TTL默认 `30s`
- `INSTANCE_HEARTBEAT_INTERVAL`:实例心跳周期,默认 `10s` - `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` - `GO_LSP_COMMAND``GO_LSP_ARGS`
@@ -48,6 +81,19 @@ go run ./cmd/server
默认 JS/TS 命令: 默认 JS/TS 命令:
- `typescript-language-server --stdio` - `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` - `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 ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -24,33 +25,113 @@ import (
var requestIDSeed atomic.Int64 var requestIDSeed atomic.Int64
// config 汇总服务启动所需的环境配置。
type config struct { type config struct {
Port string Port string // HTTP 监听端口,如 8080。
WorkspaceDir string // WorkspaceDir 是 LSP 进程使用的工作区根目录。
AllowOrigin string WorkspaceDir string
APIToken string // AllowOrigin 控制 CORS 的 Access-Control-Allow-Origin
RequestTimeout time.Duration AllowOrigin string
MaxBodyBytes int64 // APIToken 为可选 API Key为空时不启用鉴权。
SessionTTL time.Duration APIToken string
// RequestTimeout 为单次补全请求的超时时间。
RequestTimeout time.Duration
// MaxBodyBytes 为 HTTP/WS 消息体最大字节数。
MaxBodyBytes int64
// SessionTTL 为会话空闲超时,超过后会被清理。
SessionTTL time.Duration
// CleanupInterval 为后台扫描并清理闲置会话的周期。
CleanupInterval time.Duration CleanupInterval time.Duration
MaxSessions int // MaxSessions 限制本实例最多持有的活跃会话数。
InstanceID string MaxSessions int
InstanceURL string // InstanceID 是当前实例唯一标识,用于分布式会话归属判断。
EnableRedis bool InstanceID string
RedisAddr string // InstanceURL 是当前实例可被路由到的对外地址。
RedisPassword string InstanceURL string
RedisDB int // EnableRedis 控制是否启用 Redis 粘性路由/会话归属。
RedisKeyPrefix string EnableRedis bool
InstanceTTL time.Duration // RedisAddr 为 Redis 地址,例如 127.0.0.1:6379。
Heartbeat time.Duration RedisAddr string
Servers []completion.LanguageServerSpec // 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() { func main() {
cfg := loadConfig() // 读取环境变量并组装运行配置。
cfg, err := loadConfig()
if err != nil {
log.Fatalf("load config failed: %v", err)
}
var registry completion.SessionRegistry var registry completion.SessionRegistry
if cfg.EnableRedis { if cfg.EnableRedis {
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
var err error var err error
registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{ registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{
Addr: cfg.RedisAddr, Addr: cfg.RedisAddr,
@@ -67,6 +148,31 @@ func main() {
log.Fatalf("create redis registry failed: %v", err) 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{ manager := completion.NewManager(completion.ManagerConfig{
WorkspaceDir: cfg.WorkspaceDir, WorkspaceDir: cfg.WorkspaceDir,
@@ -88,6 +194,7 @@ func main() {
_ = manager.Close() _ = manager.Close()
}() }()
// 注册通用中间件与业务路由。
router := gin.New() router := gin.New()
router.Use(gin.Logger()) router.Use(gin.Logger())
router.Use(gin.Recovery()) router.Use(gin.Recovery())
@@ -109,12 +216,14 @@ func main() {
} }
go func() { go func() {
// HTTP 服务主循环,非正常退出直接终止进程。
log.Printf( 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.Port,
cfg.WorkspaceDir, cfg.WorkspaceDir,
cfg.InstanceID, cfg.InstanceID,
cfg.EnableRedis, cfg.EnableRedis,
cfg.EnableNacosRegister,
) )
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server failed: %v", err) log.Fatalf("http server failed: %v", err)
@@ -125,6 +234,7 @@ func main() {
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh <-sigCh
// 收到退出信号后,按超时窗口优雅关闭。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -134,56 +244,425 @@ func main() {
if err := manager.Close(); err != nil { if err := manager.Close(); err != nil {
log.Printf("lsp manager close failed: %v", err) 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() cwd, err := os.Getwd()
if err != nil { if err != nil {
cwd = "." cwd = "."
} }
return config{ cfg := config{
Port: getenv("PORT", "8080"), Port: "8080",
WorkspaceDir: getenv("WORKSPACE_DIR", cwd), WorkspaceDir: cwd,
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"), AllowOrigin: "*",
APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")), RequestTimeout: 10 * time.Second,
RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second), MaxBodyBytes: 2 << 20,
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20), SessionTTL: 20 * time.Minute,
SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute), CleanupInterval: 2 * time.Minute,
CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute), MaxSessions: 256,
MaxSessions: getenvInt("MAX_SESSIONS", 256), EnableRedis: true,
InstanceID: getenv("INSTANCE_ID", defaultInstanceID()), RedisAddr: "10.0.0.10:6379",
InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")), RedisDB: 1,
EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true), RedisKeyPrefix: "lsp-gateway",
RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"), InstanceTTL: 30 * time.Second,
RedisPassword: getenv("REDIS_PASSWORD", ""), Heartbeat: 10 * time.Second,
RedisDB: getenvInt("REDIS_DB", 1), NacosServerAddr: "10.0.0.10:8848",
RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"), NacosGroup: "DEFAULT_GROUP",
InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second), NacosServiceName: "lsp-gateway",
Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second), NacosEphemeral: true,
Servers: []completion.LanguageServerSpec{ Servers: defaultLanguageServers(),
{ }
Language: "go",
LanguageID: "go", if configPath, ok := resolveConfigFilePath(); ok {
Command: getenv("GO_LSP_COMMAND", "gopls"), fileCfg, err := readConfigFile(configPath)
Args: getenvArgs("GO_LSP_ARGS", ""), if err != nil {
}, return config{}, fmt.Errorf("read config file %q: %w", configPath, err)
{ }
Language: "javascript", if err := applyFileConfig(&cfg, fileCfg); err != nil {
LanguageID: "javascript", return config{}, fmt.Errorf("apply config file %q: %w", configPath, err)
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"), }
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"), log.Printf("config file loaded: %s", configPath)
}, } else {
{ log.Printf("config file not found, using defaults + environment variables")
Language: "typescript", }
LanguageID: "typescript",
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"), applyEnvOverrides(&cfg)
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"), 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 { func defaultInstanceID() string {
host, err := os.Hostname() host, err := os.Hostname()
if err != nil || strings.TrimSpace(host) == "" { if err != nil || strings.TrimSpace(host) == "" {
@@ -196,6 +675,7 @@ func defaultInstanceID() string {
return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid()) return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid())
} }
// getenv 读取字符串环境变量,空值时返回 fallback。
func getenv(key, fallback string) string { func getenv(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
@@ -204,6 +684,7 @@ func getenv(key, fallback string) string {
return v return v
} }
// getenvArgs 按 shell 风格拆分参数列表。
func getenvArgs(key, fallback string) []string { func getenvArgs(key, fallback string) []string {
value := getenv(key, fallback) value := getenv(key, fallback)
if value == "" { if value == "" {
@@ -212,6 +693,7 @@ func getenvArgs(key, fallback string) []string {
return strings.Fields(value) return strings.Fields(value)
} }
// getenvInt 读取整数环境变量,解析失败时返回 fallback。
func getenvInt(key string, fallback int) int { func getenvInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
@@ -224,6 +706,7 @@ func getenvInt(key string, fallback int) int {
return fallback return fallback
} }
// getenvInt64 读取 int64 环境变量,解析失败时返回 fallback。
func getenvInt64(key string, fallback int64) int64 { func getenvInt64(key string, fallback int64) int64 {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
@@ -236,6 +719,7 @@ func getenvInt64(key string, fallback int64) int64 {
return fallback return fallback
} }
// getenvDuration 读取 time.Duration 格式的环境变量。
func getenvDuration(key string, fallback time.Duration) time.Duration { func getenvDuration(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
@@ -248,6 +732,7 @@ func getenvDuration(key string, fallback time.Duration) time.Duration {
return parsed return parsed
} }
// getenvBool 读取布尔环境变量,支持常见开关写法。
func getenvBool(key string, fallback bool) bool { func getenvBool(key string, fallback bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" { if v == "" {
@@ -263,6 +748,7 @@ func getenvBool(key string, fallback bool) bool {
} }
} }
// corsMiddleware 处理跨域响应头与 OPTIONS 预检请求。
func corsMiddleware(allowOrigin string) gin.HandlerFunc { func corsMiddleware(allowOrigin string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", allowOrigin) 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 { func apiTokenMiddleware(token string) gin.HandlerFunc {
required := strings.TrimSpace(token) required := strings.TrimSpace(token)
if required == "" { if required == "" {
@@ -295,6 +782,7 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
} }
} }
// requestIDMiddleware 为请求补齐并回传 X-Request-Id。
func requestIDMiddleware() gin.HandlerFunc { func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
rid := strings.TrimSpace(c.GetHeader("X-Request-Id")) 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 ( require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3 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 github.com/redis/go-redis/v9 v9.17.3
) )
require ( 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 v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.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/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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // 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/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/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/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/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // 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/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/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.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/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.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 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 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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 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 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= 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 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 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-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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 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 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 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 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 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 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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) Complete(ctx context.Context, req completion.Request) (completion.Response, error)
} }
// SessionStatsProvider 暴露会话统计信息,供就绪探针输出。
type SessionStatsProvider interface { type SessionStatsProvider interface {
ActiveSessions() map[string]int ActiveSessions() map[string]int
} }
// RouteOptions 控制 HTTP/WS 接口的超时与请求体上限。
type RouteOptions struct { type RouteOptions struct {
RequestTimeout time.Duration RequestTimeout time.Duration // 单次补全调用超时时间。
MaxBodyBytes int64 MaxBodyBytes int64 // 请求体最大字节数HTTP/WS 共用)。
} }
// RegisterRoutes 注册健康检查、HTTP 补全接口和 WebSocket 补全接口。
func RegisterRoutes(router *gin.Engine, service CompletionService, options ...RouteOptions) { func RegisterRoutes(router *gin.Engine, service CompletionService, options ...RouteOptions) {
opts := RouteOptions{ opts := RouteOptions{
RequestTimeout: 10 * time.Second, RequestTimeout: 10 * time.Second,
@@ -60,6 +63,7 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
registerWSRoutes(router, service, opts) registerWSRoutes(router, service, opts)
handleCompletion := func(c *gin.Context) { handleCompletion := func(c *gin.Context) {
// 为单次请求限制 body 大小,避免异常大包占满内存。
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes)
var req completion.Request var req completion.Request
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -68,10 +72,12 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
} }
routeLang := c.Param("language") routeLang := c.Param("language")
// 若 body 未显式给出 language则使用路由参数。
if req.Language == "" { if req.Language == "" {
req.Language = routeLang req.Language = routeLang
} }
// 对下游补全调用增加超时保护,防止请求长时间悬挂。
ctx, cancel := context.WithTimeout(c.Request.Context(), opts.RequestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), opts.RequestTimeout)
defer cancel() defer cancel()

View File

@@ -28,6 +28,7 @@ func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request
return f.resp, nil return f.resp, nil
} }
// 验证 HTTP 补全接口的成功路径。
func TestRegisterRoutesCompletionSuccess(t *testing.T) { func TestRegisterRoutesCompletionSuccess(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()
@@ -63,6 +64,7 @@ func TestRegisterRoutesCompletionSuccess(t *testing.T) {
} }
} }
// 验证非法 JSON 会返回 400。
func TestRegisterRoutesCompletionBadJSON(t *testing.T) { func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()
@@ -78,6 +80,7 @@ func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
} }
} }
// 验证业务校验错误会映射为 400。
func TestRegisterRoutesCompletionValidationError(t *testing.T) { func TestRegisterRoutesCompletionValidationError(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()
@@ -100,6 +103,7 @@ func TestRegisterRoutesCompletionValidationError(t *testing.T) {
} }
} }
// 验证未知内部错误会映射为 500。
func TestRegisterRoutesCompletionServerError(t *testing.T) { func TestRegisterRoutesCompletionServerError(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()
@@ -121,6 +125,7 @@ func TestRegisterRoutesCompletionServerError(t *testing.T) {
} }
} }
// 验证 WebSocket 补全协议的基础成功流程。
func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) { func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()

View File

@@ -21,6 +21,7 @@ var wsUpgrader = websocket.Upgrader{
}, },
} }
// wsCompletionRequest 是普通 WS 消息的补全请求格式。
type wsCompletionRequest struct { type wsCompletionRequest struct {
ID string `json:"id"` ID string `json:"id"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
@@ -31,6 +32,7 @@ type wsCompletionRequest struct {
Character int `json:"character"` Character int `json:"character"`
} }
// wsCompletionResponse 是普通 WS 消息的补全响应格式。
type wsCompletionResponse struct { type wsCompletionResponse struct {
ID string `json:"id"` ID string `json:"id"`
Items []completion.Item `json:"items,omitempty"` Items []completion.Item `json:"items,omitempty"`
@@ -40,6 +42,7 @@ type wsCompletionResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// wsRPCRequest/wsRPCResponse 用于兼容 JSON-RPC 2.0 客户端。
type wsRPCRequest struct { type wsRPCRequest struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"` ID json.RawMessage `json:"id"`
@@ -54,6 +57,7 @@ type wsRPCResponse struct {
Error any `json:"error,omitempty"` Error any `json:"error,omitempty"`
} }
// registerWSRoutes 注册 WebSocket 补全入口(含可选语言路由)。
func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteOptions) { func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteOptions) {
handler := func(c *gin.Context, defaultLanguage string) { handler := func(c *gin.Context, defaultLanguage string) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) 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 var writeMu sync.Mutex
for { for {
// 单连接串行读取消息,写操作通过 writeMu 保证并发安全。
_, payload, err := conn.ReadMessage() _, payload, err := conn.ReadMessage()
if err != nil { if err != nil {
break break
@@ -83,6 +88,7 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
}) })
} }
// handleWSMessage 先尝试按 JSON-RPC 处理;失败后回退到普通 JSON 协议。
func handleWSMessage( func handleWSMessage(
conn *websocket.Conn, conn *websocket.Conn,
writeMu *sync.Mutex, writeMu *sync.Mutex,
@@ -110,6 +116,7 @@ func handleWSMessage(
processWSCompletion(conn, writeMu, service, req, opts) processWSCompletion(conn, writeMu, service, req, opts)
} }
// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。
func tryHandleRPCMessage( func tryHandleRPCMessage(
conn *websocket.Conn, conn *websocket.Conn,
writeMu *sync.Mutex, writeMu *sync.Mutex,
@@ -127,6 +134,7 @@ func tryHandleRPCMessage(
} }
if rpcReq.Method != "completion/complete" && rpcReq.Method != "completion.complete" { if rpcReq.Method != "completion/complete" && rpcReq.Method != "completion.complete" {
// 非补全方法按 JSON-RPC 规范返回 method not found。
sendWSRPCResponse(conn, writeMu, wsRPCResponse{ sendWSRPCResponse(conn, writeMu, wsRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: rpcReq.ID, ID: rpcReq.ID,
@@ -140,6 +148,7 @@ func tryHandleRPCMessage(
var req wsCompletionRequest var req wsCompletionRequest
if err := json.Unmarshal(rpcReq.Params, &req); err != nil { if err := json.Unmarshal(rpcReq.Params, &req); err != nil {
// 参数反序列化失败按 invalid params 处理。
sendWSRPCResponse(conn, writeMu, wsRPCResponse{ sendWSRPCResponse(conn, writeMu, wsRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: rpcReq.ID, ID: rpcReq.ID,
@@ -154,6 +163,7 @@ func tryHandleRPCMessage(
req.Language = defaultLanguage req.Language = defaultLanguage
} }
if req.ID == "" { if req.ID == "" {
// 兼容未在 params 提供业务 ID 的客户端。
req.ID = string(rpcReq.ID) req.ID = string(rpcReq.ID)
} }
@@ -188,6 +198,7 @@ func tryHandleRPCMessage(
return true return true
} }
// processWSCompletion 处理普通 WS 协议下的补全请求。
func processWSCompletion( func processWSCompletion(
conn *websocket.Conn, conn *websocket.Conn,
writeMu *sync.Mutex, writeMu *sync.Mutex,
@@ -220,6 +231,7 @@ func processWSCompletion(
default: default:
var ownedErr *completion.ErrSessionOwnedByOtherInstance var ownedErr *completion.ErrSessionOwnedByOtherInstance
if errors.As(err, &ownedErr) { if errors.As(err, &ownedErr) {
// 会话在其他实例上时返回路由提示,客户端可重连对应节点。
msg = err.Error() msg = err.Error()
routeTo = ownedErr.OwnerEndpoint routeTo = ownedErr.OwnerEndpoint
ownerID = ownedErr.OwnerID ownerID = ownedErr.OwnerID
@@ -241,12 +253,14 @@ func processWSCompletion(
}) })
} }
// sendWSResponse 统一串行写回普通 WS 响应。
func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) { func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) {
writeMu.Lock() writeMu.Lock()
defer writeMu.Unlock() defer writeMu.Unlock()
_ = conn.WriteJSON(resp) _ = conn.WriteJSON(resp)
} }
// sendWSRPCResponse 统一串行写回 JSON-RPC 响应。
func sendWSRPCResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsRPCResponse) { func sendWSRPCResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsRPCResponse) {
writeMu.Lock() writeMu.Lock()
defer writeMu.Unlock() 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 { type RedisRegistryConfig struct {
Addr string Addr string // Redis 地址,例如 127.0.0.1:6379。
Password string Password string // Redis 密码,可为空。
DB int DB int // Redis 数据库编号。
KeyPrefix string KeyPrefix string // 键前缀,隔离不同环境/业务。
InstanceID string InstanceID string // 当前实例唯一 ID。
InstanceEndpoint string InstanceEndpoint string // 当前实例对外访问地址。
SessionTTL time.Duration SessionTTL time.Duration // 会话键的过期时间。
InstanceTTL time.Duration InstanceTTL time.Duration // 实例元数据过期时间。
HeartbeatInterval time.Duration HeartbeatInterval time.Duration // 实例心跳刷新周期。
} }
// RedisRegistry 负责在 Redis 中维护实例心跳与会话归属。
type RedisRegistry struct { type RedisRegistry struct {
client *redis.Client client *redis.Client
@@ -37,6 +38,7 @@ type RedisRegistry struct {
stopOnce sync.Once stopOnce sync.Once
} }
// claimSessionScript 原子地抢占/续租会话,返回当前 owner。
var claimSessionScript = redis.NewScript(` var claimSessionScript = redis.NewScript(`
local sessionKey = KEYS[1] local sessionKey = KEYS[1]
local owner = ARGV[1] local owner = ARGV[1]
@@ -54,6 +56,7 @@ redis.call('PEXPIRE', sessionKey, ttl)
return existing return existing
`) `)
// releaseSessionScript 仅允许 owner 主动释放自己的会话。
var releaseSessionScript = redis.NewScript(` var releaseSessionScript = redis.NewScript(`
local sessionKey = KEYS[1] local sessionKey = KEYS[1]
local owner = ARGV[1] local owner = ARGV[1]
@@ -65,6 +68,7 @@ end
return 0 return 0
`) `)
// NewRedisRegistry 初始化 Redis 客户端并启动实例心跳。
func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegistry, error) { func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegistry, error) {
if strings.TrimSpace(cfg.Addr) == "" { if strings.TrimSpace(cfg.Addr) == "" {
return nil, errors.New("redis addr is required") return nil, errors.New("redis addr is required")
@@ -113,6 +117,7 @@ func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegis
return registry, nil return registry, nil
} }
// ClaimSession 尝试声明会话归属,并解析 owner 对应的实例地址。
func (r *RedisRegistry) ClaimSession( func (r *RedisRegistry) ClaimSession(
ctx context.Context, ctx context.Context,
language string, language string,
@@ -147,6 +152,7 @@ func (r *RedisRegistry) ClaimSession(
return owner, endpoint, nil return owner, endpoint, nil
} }
// ReleaseSession 释放当前实例持有的会话键。
func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID string) error { func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID string) error {
key := r.sessionKey(language, sessionID) key := r.sessionKey(language, sessionID)
if _, err := releaseSessionScript.Run(ctx, r.client, []string{key}, r.instanceID).Result(); err != nil { 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 return nil
} }
// Close 停止心跳并关闭 Redis 连接。
func (r *RedisRegistry) Close() error { func (r *RedisRegistry) Close() error {
r.stopOnce.Do(func() { r.stopOnce.Do(func() {
close(r.stopCh) close(r.stopCh)
@@ -162,6 +169,7 @@ func (r *RedisRegistry) Close() error {
return r.client.Close() return r.client.Close()
} }
// heartbeatLoop 周期性刷新实例元数据,维持实例在线状态。
func (r *RedisRegistry) heartbeatLoop() { func (r *RedisRegistry) heartbeatLoop() {
ticker := time.NewTicker(r.heartbeatInterval) ticker := time.NewTicker(r.heartbeatInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -178,6 +186,7 @@ func (r *RedisRegistry) heartbeatLoop() {
} }
} }
// refreshInstance 更新实例 endpoint 和 TTL。
func (r *RedisRegistry) refreshInstance(ctx context.Context) error { func (r *RedisRegistry) refreshInstance(ctx context.Context) error {
key := r.instanceKey(r.instanceID) key := r.instanceKey(r.instanceID)
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
@@ -193,6 +202,7 @@ func (r *RedisRegistry) refreshInstance(ctx context.Context) error {
return nil return nil
} }
// resolveInstanceEndpoint 根据实例 ID 读取其对外地址。
func (r *RedisRegistry) resolveInstanceEndpoint(ctx context.Context, ownerID string) (string, error) { func (r *RedisRegistry) resolveInstanceEndpoint(ctx context.Context, ownerID string) (string, error) {
key := r.instanceKey(ownerID) key := r.instanceKey(ownerID)
endpoint, err := r.client.HGet(ctx, key, "endpoint").Result() 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)) return fmt.Sprintf("%s:instances:%s", r.keyPrefix, normalizePart(instanceID))
} }
// normalizePart 规范化 Redis key 片段,避免空字符串破坏 key 结构。
func normalizePart(value string) string { func normalizePart(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { if trimmed == "" {

View File

@@ -13,35 +13,40 @@ import (
var ErrUnsupportedLanguage = errors.New("unsupported language") var ErrUnsupportedLanguage = errors.New("unsupported language")
var ErrTooManySessions = errors.New("too many active lsp sessions") var ErrTooManySessions = errors.New("too many active lsp sessions")
// RuntimeClient 是带生命周期管理能力的补全客户端。
type RuntimeClient interface { type RuntimeClient interface {
Client Client
Close() error Close() error
} }
// SessionRegistry 抽象跨实例会话归属协调能力(如 Redis
type SessionRegistry interface { type SessionRegistry interface {
ClaimSession(ctx context.Context, language, sessionID string) (ownerID string, ownerEndpoint string, err error) ClaimSession(ctx context.Context, language, sessionID string) (ownerID string, ownerEndpoint string, err error)
ReleaseSession(ctx context.Context, language, sessionID string) error ReleaseSession(ctx context.Context, language, sessionID string) error
Close() error Close() error
} }
// LanguageServerSpec 描述某种语言对应的 LSP 启动参数。
type LanguageServerSpec struct { type LanguageServerSpec struct {
Language string Language string // 语言名(如 go/typescript用于路由匹配。
LanguageID string LanguageID string // 传给 LSP 的 languageId。
Command string Command string // LSP 可执行命令。
Args []string Args []string // LSP 启动参数。
} }
type ClientFactory func(ctx context.Context, spec LanguageServerSpec, workspaceDir string) (RuntimeClient, error) type ClientFactory func(ctx context.Context, spec LanguageServerSpec, workspaceDir string) (RuntimeClient, error)
// ManagerConfig 控制会话池容量、TTL 与实例信息。
type ManagerConfig struct { type ManagerConfig struct {
WorkspaceDir string WorkspaceDir string // LSP 进程工作区目录。
MaxSessions int MaxSessions int // 本实例会话上限。
SessionTTL time.Duration SessionTTL time.Duration // 会话空闲超时。
CleanupInterval time.Duration CleanupInterval time.Duration // 会话清理周期。
InstanceID string InstanceID string // 当前实例 ID。
Registry SessionRegistry Registry SessionRegistry // 可选分布式会话注册中心。
} }
// Manager 按 language/session 复用 LSP 会话,并负责清理与淘汰。
type Manager struct { type Manager struct {
mu sync.Mutex mu sync.Mutex
@@ -63,6 +68,7 @@ type managedSession struct {
createdAt time.Time createdAt time.Time
} }
// ErrSessionOwnedByOtherInstance 表示会话已被其他实例持有。
type ErrSessionOwnedByOtherInstance struct { type ErrSessionOwnedByOtherInstance struct {
OwnerID string OwnerID string
OwnerEndpoint string OwnerEndpoint string
@@ -75,6 +81,7 @@ func (e *ErrSessionOwnedByOtherInstance) Error() string {
return fmt.Sprintf("session owned by another instance: %s", e.OwnerID) return fmt.Sprintf("session owned by another instance: %s", e.OwnerID)
} }
// NewManager 构建会话管理器并启动后台清理协程。
func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory ClientFactory) *Manager { func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory ClientFactory) *Manager {
if config.MaxSessions <= 0 { if config.MaxSessions <= 0 {
config.MaxSessions = 256 config.MaxSessions = 256
@@ -109,6 +116,7 @@ func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory Client
return m return m
} }
// Complete 处理补全请求,包含语言匹配、会话归属、会话复用/创建。
func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) { func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
language := normalizeLanguage(req.Language) language := normalizeLanguage(req.Language)
if language == "" { if language == "" {
@@ -124,6 +132,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
sessionID := normalizeSessionID(req.SessionID) sessionID := normalizeSessionID(req.SessionID)
if m.config.Registry != nil { if m.config.Registry != nil {
// 分布式模式下先声明会话归属,避免多实例并发写同一会话。
ownerID, ownerEndpoint, err := m.config.Registry.ClaimSession(ctx, language, sessionID) ownerID, ownerEndpoint, err := m.config.Registry.ClaimSession(ctx, language, sessionID)
if err != nil { if err != nil {
return Response{}, err return Response{}, err
@@ -154,6 +163,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
return resp, nil return resp, nil
} }
// ActiveSessions 统计当前实例内各语言活跃会话数。
func (m *Manager) ActiveSessions() map[string]int { func (m *Manager) ActiveSessions() map[string]int {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -165,6 +175,7 @@ func (m *Manager) ActiveSessions() map[string]int {
return out return out
} }
// Close 停止后台任务并释放所有会话与注册中心资源。
func (m *Manager) Close() error { func (m *Manager) Close() error {
m.stoppedOnce.Do(func() { m.stoppedOnce.Do(func() {
close(m.stopCh) close(m.stopCh)
@@ -186,6 +197,7 @@ func (m *Manager) Close() error {
return nil return nil
} }
// cleanupLoop 周期清理闲置会话。
func (m *Manager) cleanupLoop() { func (m *Manager) cleanupLoop() {
ticker := time.NewTicker(m.config.CleanupInterval) ticker := time.NewTicker(m.config.CleanupInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -200,6 +212,7 @@ func (m *Manager) cleanupLoop() {
} }
} }
// cleanupIdleSessions 关闭超过 TTL 未使用的会话。
func (m *Manager) cleanupIdleSessions() { func (m *Manager) cleanupIdleSessions() {
cutoff := time.Now().Add(-m.config.SessionTTL) cutoff := time.Now().Add(-m.config.SessionTTL)
@@ -218,6 +231,7 @@ func (m *Manager) cleanupIdleSessions() {
} }
} }
// getOrCreateSession 返回已有会话,或按需新建一个会话。
func (m *Manager) getOrCreateSession( func (m *Manager) getOrCreateSession(
ctx context.Context, ctx context.Context,
sessionKey string, sessionKey string,
@@ -258,6 +272,7 @@ func (m *Manager) getOrCreateSession(
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if existing, ok := m.sessions[sessionKey]; ok { if existing, ok := m.sessions[sessionKey]; ok {
// 并发竞争下可能已经被其他协程创建,直接复用并关闭新 client。
_ = client.Close() _ = client.Close()
existing.lastUsed = now existing.lastUsed = now
return existing, nil return existing, nil
@@ -266,6 +281,7 @@ func (m *Manager) getOrCreateSession(
return newSession, nil return newSession, nil
} }
// evictLeastRecentlyUsedLocked 在达到上限时淘汰最久未使用会话。
func (m *Manager) evictLeastRecentlyUsedLocked() bool { func (m *Manager) evictLeastRecentlyUsedLocked() bool {
if len(m.sessions) == 0 { if len(m.sessions) == 0 {
return false return false
@@ -301,6 +317,7 @@ func buildSessionKey(language, sessionID string) string {
return language + ":" + normalizeSessionID(sessionID) return language + ":" + normalizeSessionID(sessionID)
} }
// normalizeSessionID 将空 session 归一为 default便于复用同一会话键。
func normalizeSessionID(sessionID string) string { func normalizeSessionID(sessionID string) string {
sid := strings.TrimSpace(sessionID) sid := strings.TrimSpace(sessionID)
if sid == "" { if sid == "" {
@@ -309,6 +326,7 @@ func normalizeSessionID(sessionID string) string {
return sid return sid
} }
// normalizeLanguage 统一语言名大小写和空白。
func normalizeLanguage(language string) string { func normalizeLanguage(language string) string {
return strings.ToLower(strings.TrimSpace(language)) return strings.ToLower(strings.TrimSpace(language))
} }

View File

@@ -31,6 +31,7 @@ func (f *fakeRuntimeClient) Close() error {
return nil return nil
} }
// 同 language+session 的请求应复用同一个底层 client。
func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) { func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) {
createCount := 0 createCount := 0
factory := func(_ context.Context, spec LanguageServerSpec, _ string) (RuntimeClient, error) { factory := func(_ context.Context, spec LanguageServerSpec, _ string) (RuntimeClient, error) {
@@ -68,6 +69,7 @@ func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) {
} }
} }
// 未配置的语言应返回 ErrUnsupportedLanguage。
func TestManagerCompleteUnsupportedLanguage(t *testing.T) { func TestManagerCompleteUnsupportedLanguage(t *testing.T) {
m := NewManager(ManagerConfig{}, []LanguageServerSpec{ m := NewManager(ManagerConfig{}, []LanguageServerSpec{
{Language: "go", LanguageID: "go", Command: "gopls"}, {Language: "go", LanguageID: "go", Command: "gopls"},
@@ -88,6 +90,7 @@ func TestManagerCompleteUnsupportedLanguage(t *testing.T) {
} }
} }
// 超过空闲 TTL 的会话应被后台清理并关闭 client。
func TestManagerCleanupIdleSession(t *testing.T) { func TestManagerCleanupIdleSession(t *testing.T) {
client := &fakeRuntimeClient{} client := &fakeRuntimeClient{}
m := NewManager(ManagerConfig{ m := NewManager(ManagerConfig{

View File

@@ -9,6 +9,7 @@ import (
var ErrInvalidRequest = errors.New("invalid completion request") var ErrInvalidRequest = errors.New("invalid completion request")
// Request 是统一的补全请求模型。
type Request struct { type Request struct {
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
SessionID string `json:"sessionId,omitempty"` SessionID string `json:"sessionId,omitempty"`
@@ -18,6 +19,7 @@ type Request struct {
Character int `json:"character"` Character int `json:"character"`
} }
// Item 对应一个补全候选项。
type Item struct { type Item struct {
Label string `json:"label"` Label string `json:"label"`
Kind int `json:"kind,omitempty"` Kind int `json:"kind,omitempty"`
@@ -28,11 +30,13 @@ type Item struct {
FilterText string `json:"filterText,omitempty"` FilterText string `json:"filterText,omitempty"`
} }
// Response 是补全结果集合。
type Response struct { type Response struct {
Items []Item `json:"items"` Items []Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"` IsIncomplete bool `json:"isIncomplete"`
} }
// Client 抽象 LSP 客户端所需的最小能力。
type Client interface { type Client interface {
DidOpen(ctx context.Context, uri, text string, version int) error DidOpen(ctx context.Context, uri, text string, version int) error
DidChange(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 version int
} }
// Service 负责文档生命周期同步didOpen/didChange与补全调用。
type Service struct { type Service struct {
client Client 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) { func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
if err := validateRequest(req); err != nil { if err := validateRequest(req); err != nil {
return Response{}, err return Response{}, err
@@ -86,6 +92,7 @@ func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
return resp, nil return resp, nil
} }
// validateRequest 做基础参数校验,避免向 LSP 发送非法请求。
func validateRequest(req Request) error { func validateRequest(req Request) error {
if req.URI == "" { if req.URI == "" {
return ErrInvalidRequest return ErrInvalidRequest

View File

@@ -52,6 +52,7 @@ func (f *fakeLSPClient) Completion(_ context.Context, uri string, line, characte
return f.completionResp, nil return f.completionResp, nil
} }
// 首次请求应发送 didOpen并继续请求 completion。
func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) { func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
fake := &fakeLSPClient{ fake := &fakeLSPClient{
completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true}, completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true},
@@ -84,6 +85,7 @@ func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
} }
} }
// 同一文档第二次请求应发送 didChange且版本号递增。
func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) { func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
fake := &fakeLSPClient{} fake := &fakeLSPClient{}
svc := NewService(fake) svc := NewService(fake)
@@ -119,6 +121,7 @@ func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
} }
} }
// 参数不合法时应直接返回 ErrInvalidRequest。
func TestServiceCompleteValidatesRequest(t *testing.T) { func TestServiceCompleteValidatesRequest(t *testing.T) {
svc := NewService(&fakeLSPClient{}) svc := NewService(&fakeLSPClient{})
@@ -128,6 +131,7 @@ func TestServiceCompleteValidatesRequest(t *testing.T) {
} }
} }
// 底层 client 出错应向上透传错误。
func TestServiceCompleteReturnsClientError(t *testing.T) { func TestServiceCompleteReturnsClientError(t *testing.T) {
fake := &fakeLSPClient{openErr: errors.New("open failed")} fake := &fakeLSPClient{openErr: errors.New("open failed")}
svc := NewService(fake) svc := NewService(fake)

View File

@@ -24,6 +24,7 @@ import (
var errClientClosed = errors.New("lsp client closed") var errClientClosed = errors.New("lsp client closed")
// Client 封装与 Language Server 的 JSON-RPC/LSP 通信。
type Client struct { type Client struct {
cmd *exec.Cmd cmd *exec.Cmd
stdin io.WriteCloser stdin io.WriteCloser
@@ -49,12 +50,14 @@ type rpcResponse struct {
err error err error
} }
// rpcError 对应 JSON-RPC 错误对象。
type rpcError struct { type rpcError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
} }
// incomingEnvelope 表示从服务端读到的响应/通知外层结构。
type incomingEnvelope struct { type incomingEnvelope struct {
ID *json.RawMessage `json:"id,omitempty"` ID *json.RawMessage `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
@@ -76,14 +79,16 @@ type lspCompletionList struct {
Items []lspCompletionItem `json:"items"` Items []lspCompletionItem `json:"items"`
} }
// Config 定义 LSP 子进程启动参数及客户端标识。
type Config struct { type Config struct {
Command string Command string // LSP 可执行命令。
Args []string Args []string // 启动参数(通常包含 --stdio
RootPath string RootPath string // 工作区根路径。
LanguageID string LanguageID string // didOpen/didChange 使用的 languageId。
ClientName string ClientName string // initialize.clientInfo.name。
} }
// NewClient 启动语言服务器进程并完成 initialize 握手。
func NewClient(parent context.Context, cfg Config) (*Client, error) { func NewClient(parent context.Context, cfg Config) (*Client, error) {
if cfg.Command == "" { if cfg.Command == "" {
cfg.Command = "gopls" cfg.Command = "gopls"
@@ -130,6 +135,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) {
go func() { go func() {
client.exitCh <- cmd.Wait() client.exitCh <- cmd.Wait()
}() }()
// 独立协程持续读取 stdout 并分发响应。
go client.readLoop(stdout) go client.readLoop(stdout)
initCtx, cancel := context.WithTimeout(parent, 10*time.Second) initCtx, cancel := context.WithTimeout(parent, 10*time.Second)
@@ -143,6 +149,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) {
return client, nil return client, nil
} }
// initialize 完成 LSP initialize/initialized 流程。
func (c *Client) initialize(ctx context.Context, rootPath string) error { func (c *Client) initialize(ctx context.Context, rootPath string) error {
rootURI, err := pathToURI(rootPath) rootURI, err := pathToURI(rootPath)
if err != nil { if err != nil {
@@ -183,6 +190,7 @@ func (c *Client) initialize(ctx context.Context, rootPath string) error {
return nil return nil
} }
// DidOpen 发送 textDocument/didOpen告知服务端首次打开文档。
func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error { func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error {
normalizedURI, err := c.normalizeURI(uri) normalizedURI, err := c.normalizeURI(uri)
if err != nil { 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) return c.notifyWithContext(ctx, "textDocument/didOpen", params)
} }
// DidChange 发送 textDocument/didChange推送文档全文与版本号。
func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error { func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error {
normalizedURI, err := c.normalizeURI(uri) normalizedURI, err := c.normalizeURI(uri)
if err != nil { 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) 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) { func (c *Client) Completion(ctx context.Context, uri string, line, character int) (completion.Response, error) {
normalizedURI, err := c.normalizeURI(uri) normalizedURI, err := c.normalizeURI(uri)
if err != nil { if err != nil {
@@ -247,6 +257,7 @@ func (c *Client) Completion(ctx context.Context, uri string, line, character int
} }
if body[0] == '[' { if body[0] == '[' {
// 部分 LSP 服务端直接返回 CompletionItem[]。
var items []lspCompletionItem var items []lspCompletionItem
if err := json.Unmarshal(body, &items); err != nil { if err := json.Unmarshal(body, &items); err != nil {
return completion.Response{}, fmt.Errorf("decode completion items: %w", err) 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]:`) var windowsDrivePattern = regexp.MustCompile(`^[A-Za-z]:`)
// normalizeURI 将相对 file URI 重写为工作区绝对路径 URI。
func (c *Client) normalizeURI(rawURI string) (string, error) { func (c *Client) normalizeURI(rawURI string) (string, error) {
if rawURI == "" { if rawURI == "" {
return "", errors.New("empty uri") return "", errors.New("empty uri")
@@ -296,6 +308,7 @@ func (c *Client) normalizeURI(rawURI string) (string, error) {
return pathToURI(localPath) return pathToURI(localPath)
} }
// Close 优雅关闭 LSP 进程并失败通知所有未完成请求。
func (c *Client) Close() error { func (c *Client) Close() error {
c.closeOnce.Do(func() { c.closeOnce.Do(func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -320,6 +333,7 @@ func (c *Client) Close() error {
return nil return nil
} }
// request 发送 JSON-RPC 请求并阻塞等待对应响应。
func (c *Client) request(ctx context.Context, method string, params any, out any) error { func (c *Client) request(ctx context.Context, method string, params any, out any) error {
if c.closed.Load() { if c.closed.Load() {
return errClientClosed return errClientClosed
@@ -365,6 +379,7 @@ func (c *Client) notify(method string, params any) error {
return c.notifyWithContext(context.Background(), method, params) return c.notifyWithContext(context.Background(), method, params)
} }
// notifyWithContext 发送 JSON-RPC 通知(无响应)。
func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error { func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error {
if c.closed.Load() { if c.closed.Load() {
return errClientClosed return errClientClosed
@@ -383,6 +398,7 @@ func (c *Client) notifyWithContext(ctx context.Context, method string, params an
return c.writeMessage(msg) return c.writeMessage(msg)
} }
// writeMessage 按 LSP framing 写入消息头和消息体。
func (c *Client) writeMessage(msg any) error { func (c *Client) writeMessage(msg any) error {
payload, err := json.Marshal(msg) payload, err := json.Marshal(msg)
if err != nil { if err != nil {
@@ -403,6 +419,7 @@ func (c *Client) writeMessage(msg any) error {
return nil return nil
} }
// readLoop 持续读取 LSP 消息并交给 handleIncoming 分发。
func (c *Client) readLoop(stdout io.Reader) { func (c *Client) readLoop(stdout io.Reader) {
reader := bufio.NewReader(stdout) reader := bufio.NewReader(stdout)
for { for {
@@ -415,6 +432,7 @@ func (c *Client) readLoop(stdout io.Reader) {
} }
} }
// readMessage 按 Content-Length 协议边界读取单条 JSON-RPC 消息。
func readMessage(reader *bufio.Reader) ([]byte, error) { func readMessage(reader *bufio.Reader) ([]byte, error) {
contentLength := 0 contentLength := 0
for { for {
@@ -451,6 +469,7 @@ func readMessage(reader *bufio.Reader) ([]byte, error) {
return body, nil return body, nil
} }
// handleIncoming 将响应按 id 投递给对应等待中的请求通道。
func (c *Client) handleIncoming(body []byte) { func (c *Client) handleIncoming(body []byte) {
var envelope incomingEnvelope var envelope incomingEnvelope
if err := json.Unmarshal(body, &envelope); err != nil { if err := json.Unmarshal(body, &envelope); err != nil {
@@ -485,6 +504,7 @@ func (c *Client) handleIncoming(body []byte) {
wait <- rpcResponse{result: envelope.Result} wait <- rpcResponse{result: envelope.Result}
} }
// normalizeID 统一处理数字/字符串两种 JSON-RPC id。
func normalizeID(raw json.RawMessage) string { func normalizeID(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw) raw = bytes.TrimSpace(raw)
if len(raw) == 0 { if len(raw) == 0 {
@@ -506,6 +526,7 @@ func (c *Client) removePending(key string) {
c.pendingMu.Unlock() c.pendingMu.Unlock()
} }
// failPending 在连接异常时让所有挂起请求立即失败返回。
func (c *Client) failPending(err error) { func (c *Client) failPending(err error) {
c.pendingMu.Lock() c.pendingMu.Lock()
defer c.pendingMu.Unlock() defer c.pendingMu.Unlock()
@@ -516,6 +537,7 @@ func (c *Client) failPending(err error) {
} }
} }
// mapCompletionItems 将 LSP 项结构映射为网关统一输出结构。
func mapCompletionItems(items []lspCompletionItem) []completion.Item { func mapCompletionItems(items []lspCompletionItem) []completion.Item {
out := make([]completion.Item, 0, len(items)) out := make([]completion.Item, 0, len(items))
for _, it := range items { for _, it := range items {
@@ -532,6 +554,7 @@ func mapCompletionItems(items []lspCompletionItem) []completion.Item {
return out return out
} }
// decodeDocumentation 兼容 string 和 MarkupContent 两种文档字段。
func decodeDocumentation(raw json.RawMessage) string { func decodeDocumentation(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw) raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
@@ -556,6 +579,7 @@ func decodeDocumentation(raw json.RawMessage) string {
return "" return ""
} }
// pathToURI 将本地路径转换为标准 file URI。
func pathToURI(path string) (string, error) { func pathToURI(path string) (string, error) {
abs, err := filepath.Abs(path) abs, err := filepath.Abs(path)
if err != nil { if err != nil {

View File

@@ -26,6 +26,7 @@ func TestNormalizeURIRebasesRelativeFileURI(t *testing.T) {
} }
} }
// 绝对 file URI 不应被重写。
func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) { func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) {
workspace, err := filepath.Abs(filepath.Join("testdata", "ws")) workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
if err != nil { if err != nil {