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:
@@ -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`
|
||||||
|
|||||||
90
backend/cmd/server/config_test.go
Normal file
90
backend/cmd/server/config_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"monica-go-completion-backend/internal/completion"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigFileWithEnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
content := `{
|
||||||
|
"port": "8081",
|
||||||
|
"requestTimeout": "3s",
|
||||||
|
"enableRedis": false,
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"language": "go",
|
||||||
|
"languageId": "go",
|
||||||
|
"command": "gopls-from-file",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write config file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("CONFIG_FILE", path)
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("GO_LSP_COMMAND", "gopls-from-env")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Port != "9090" {
|
||||||
|
t.Fatalf("expected env to override port, got %s", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.RequestTimeout != 3*time.Second {
|
||||||
|
t.Fatalf("expected requestTimeout=3s, got %s", cfg.RequestTimeout)
|
||||||
|
}
|
||||||
|
if cfg.EnableRedis {
|
||||||
|
t.Fatal("expected enableRedis=false from config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
goSpec, ok := findServer(cfg.Servers, "go")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected go server in config")
|
||||||
|
}
|
||||||
|
if goSpec.Command != "gopls-from-env" {
|
||||||
|
t.Fatalf("expected go command overridden by env, got %s", goSpec.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigFileInvalidDuration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
content := `{
|
||||||
|
"requestTimeout": "not-a-duration"
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write config file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("CONFIG_FILE", path)
|
||||||
|
|
||||||
|
_, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected loadConfig() to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requestTimeout") {
|
||||||
|
t.Fatalf("expected requestTimeout parse error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findServer(specs []completion.LanguageServerSpec, language string) (completion.LanguageServerSpec, bool) {
|
||||||
|
target := strings.ToLower(strings.TrimSpace(language))
|
||||||
|
for _, spec := range specs {
|
||||||
|
if strings.ToLower(strings.TrimSpace(spec.Language)) == target {
|
||||||
|
return spec, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completion.LanguageServerSpec{}, false
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
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 是 LSP 进程使用的工作区根目录。
|
||||||
WorkspaceDir string
|
WorkspaceDir string
|
||||||
|
// AllowOrigin 控制 CORS 的 Access-Control-Allow-Origin。
|
||||||
AllowOrigin string
|
AllowOrigin string
|
||||||
|
// APIToken 为可选 API Key;为空时不启用鉴权。
|
||||||
APIToken string
|
APIToken string
|
||||||
|
// RequestTimeout 为单次补全请求的超时时间。
|
||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
|
// MaxBodyBytes 为 HTTP/WS 消息体最大字节数。
|
||||||
MaxBodyBytes int64
|
MaxBodyBytes int64
|
||||||
|
// SessionTTL 为会话空闲超时,超过后会被清理。
|
||||||
SessionTTL time.Duration
|
SessionTTL time.Duration
|
||||||
|
// CleanupInterval 为后台扫描并清理闲置会话的周期。
|
||||||
CleanupInterval time.Duration
|
CleanupInterval time.Duration
|
||||||
|
// MaxSessions 限制本实例最多持有的活跃会话数。
|
||||||
MaxSessions int
|
MaxSessions int
|
||||||
|
// InstanceID 是当前实例唯一标识,用于分布式会话归属判断。
|
||||||
InstanceID string
|
InstanceID string
|
||||||
|
// InstanceURL 是当前实例可被路由到的对外地址。
|
||||||
InstanceURL string
|
InstanceURL string
|
||||||
|
// EnableRedis 控制是否启用 Redis 粘性路由/会话归属。
|
||||||
EnableRedis bool
|
EnableRedis bool
|
||||||
|
// RedisAddr 为 Redis 地址,例如 127.0.0.1:6379。
|
||||||
RedisAddr string
|
RedisAddr string
|
||||||
|
// RedisPassword 为 Redis 密码,可为空。
|
||||||
RedisPassword string
|
RedisPassword string
|
||||||
|
// RedisDB 为 Redis 数据库编号。
|
||||||
RedisDB int
|
RedisDB int
|
||||||
|
// RedisKeyPrefix 为 Redis 键前缀,避免与其他业务冲突。
|
||||||
RedisKeyPrefix string
|
RedisKeyPrefix string
|
||||||
|
// InstanceTTL 为实例元数据在 Redis 中的过期时间。
|
||||||
InstanceTTL time.Duration
|
InstanceTTL time.Duration
|
||||||
|
// Heartbeat 为实例心跳刷新周期。
|
||||||
Heartbeat time.Duration
|
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
|
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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if configPath, ok := resolveConfigFilePath(); ok {
|
||||||
|
fileCfg, err := readConfigFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("read config file %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
if err := applyFileConfig(&cfg, fileCfg); err != nil {
|
||||||
|
return config{}, fmt.Errorf("apply config file %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
log.Printf("config file loaded: %s", configPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("config file not found, using defaults + environment variables")
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEnvOverrides(&cfg)
|
||||||
|
finalizeConfig(&cfg, cwd)
|
||||||
|
log.Printf(
|
||||||
|
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s",
|
||||||
|
cfg.Port,
|
||||||
|
cfg.WorkspaceDir,
|
||||||
|
cfg.EnableRedis,
|
||||||
|
cfg.EnableNacosRegister,
|
||||||
|
cfg.NacosServerAddr,
|
||||||
|
)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfigFilePath() (string, bool) {
|
||||||
|
explicitPath := strings.TrimSpace(os.Getenv("CONFIG_FILE"))
|
||||||
|
if explicitPath != "" {
|
||||||
|
return explicitPath, true
|
||||||
|
}
|
||||||
|
const defaultPath = "config.json"
|
||||||
|
info, err := os.Stat(defaultPath)
|
||||||
|
if err == nil && !info.IsDir() {
|
||||||
|
return defaultPath, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readConfigFile(path string) (fileConfig, error) {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fileConfig{}, err
|
||||||
|
}
|
||||||
|
var cfg fileConfig
|
||||||
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||||
|
return fileConfig{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFileConfig(cfg *config, fc fileConfig) error {
|
||||||
|
if fc.Port != nil {
|
||||||
|
cfg.Port = strings.TrimSpace(*fc.Port)
|
||||||
|
}
|
||||||
|
if fc.WorkspaceDir != nil {
|
||||||
|
cfg.WorkspaceDir = strings.TrimSpace(*fc.WorkspaceDir)
|
||||||
|
}
|
||||||
|
if fc.AllowOrigin != nil {
|
||||||
|
cfg.AllowOrigin = strings.TrimSpace(*fc.AllowOrigin)
|
||||||
|
}
|
||||||
|
if fc.APIToken != nil {
|
||||||
|
cfg.APIToken = strings.TrimSpace(*fc.APIToken)
|
||||||
|
}
|
||||||
|
if fc.RequestTimeout != nil {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(*fc.RequestTimeout))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("requestTimeout: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RequestTimeout = d
|
||||||
|
}
|
||||||
|
if fc.MaxBodyBytes != nil {
|
||||||
|
cfg.MaxBodyBytes = *fc.MaxBodyBytes
|
||||||
|
}
|
||||||
|
if fc.SessionTTL != nil {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(*fc.SessionTTL))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sessionTTL: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SessionTTL = d
|
||||||
|
}
|
||||||
|
if fc.CleanupInterval != nil {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(*fc.CleanupInterval))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleanupInterval: %w", err)
|
||||||
|
}
|
||||||
|
cfg.CleanupInterval = d
|
||||||
|
}
|
||||||
|
if fc.MaxSessions != nil {
|
||||||
|
cfg.MaxSessions = *fc.MaxSessions
|
||||||
|
}
|
||||||
|
if fc.InstanceID != nil {
|
||||||
|
cfg.InstanceID = strings.TrimSpace(*fc.InstanceID)
|
||||||
|
}
|
||||||
|
if fc.InstanceURL != nil {
|
||||||
|
cfg.InstanceURL = strings.TrimSpace(*fc.InstanceURL)
|
||||||
|
}
|
||||||
|
if fc.EnableRedis != nil {
|
||||||
|
cfg.EnableRedis = *fc.EnableRedis
|
||||||
|
}
|
||||||
|
if fc.RedisAddr != nil {
|
||||||
|
cfg.RedisAddr = strings.TrimSpace(*fc.RedisAddr)
|
||||||
|
}
|
||||||
|
if fc.RedisPassword != nil {
|
||||||
|
cfg.RedisPassword = strings.TrimSpace(*fc.RedisPassword)
|
||||||
|
}
|
||||||
|
if fc.RedisDB != nil {
|
||||||
|
cfg.RedisDB = *fc.RedisDB
|
||||||
|
}
|
||||||
|
if fc.RedisKeyPrefix != nil {
|
||||||
|
cfg.RedisKeyPrefix = strings.TrimSpace(*fc.RedisKeyPrefix)
|
||||||
|
}
|
||||||
|
if fc.InstanceTTL != nil {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(*fc.InstanceTTL))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("instanceTTL: %w", err)
|
||||||
|
}
|
||||||
|
cfg.InstanceTTL = d
|
||||||
|
}
|
||||||
|
if fc.Heartbeat != nil {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(*fc.Heartbeat))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("heartbeat: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Heartbeat = d
|
||||||
|
}
|
||||||
|
if fc.EnableNacosRegister != nil {
|
||||||
|
cfg.EnableNacosRegister = *fc.EnableNacosRegister
|
||||||
|
}
|
||||||
|
if fc.NacosServerAddr != nil {
|
||||||
|
cfg.NacosServerAddr = strings.TrimSpace(*fc.NacosServerAddr)
|
||||||
|
}
|
||||||
|
if fc.NacosNamespace != nil {
|
||||||
|
cfg.NacosNamespace = strings.TrimSpace(*fc.NacosNamespace)
|
||||||
|
}
|
||||||
|
if fc.NacosGroup != nil {
|
||||||
|
cfg.NacosGroup = strings.TrimSpace(*fc.NacosGroup)
|
||||||
|
}
|
||||||
|
if fc.NacosServiceName != nil {
|
||||||
|
cfg.NacosServiceName = strings.TrimSpace(*fc.NacosServiceName)
|
||||||
|
}
|
||||||
|
if fc.NacosClusterName != nil {
|
||||||
|
cfg.NacosClusterName = strings.TrimSpace(*fc.NacosClusterName)
|
||||||
|
}
|
||||||
|
if fc.NacosUsername != nil {
|
||||||
|
cfg.NacosUsername = strings.TrimSpace(*fc.NacosUsername)
|
||||||
|
}
|
||||||
|
if fc.NacosPassword != nil {
|
||||||
|
cfg.NacosPassword = strings.TrimSpace(*fc.NacosPassword)
|
||||||
|
}
|
||||||
|
if fc.NacosRegisterIP != nil {
|
||||||
|
cfg.NacosRegisterIP = strings.TrimSpace(*fc.NacosRegisterIP)
|
||||||
|
}
|
||||||
|
if fc.NacosRegisterPort != nil {
|
||||||
|
cfg.NacosRegisterPort = *fc.NacosRegisterPort
|
||||||
|
}
|
||||||
|
if fc.NacosEphemeral != nil {
|
||||||
|
cfg.NacosEphemeral = *fc.NacosEphemeral
|
||||||
|
}
|
||||||
|
if fc.Servers != nil {
|
||||||
|
cfg.Servers = normalizeServerSpecs(fc.Servers)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnvOverrides(cfg *config) {
|
||||||
|
cfg.Port = getenv("PORT", cfg.Port)
|
||||||
|
cfg.WorkspaceDir = getenv("WORKSPACE_DIR", cfg.WorkspaceDir)
|
||||||
|
cfg.AllowOrigin = getenv("CORS_ALLOW_ORIGIN", cfg.AllowOrigin)
|
||||||
|
cfg.APIToken = getenv("LSP_API_TOKEN", cfg.APIToken)
|
||||||
|
cfg.RequestTimeout = getenvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout)
|
||||||
|
cfg.MaxBodyBytes = getenvInt64("MAX_BODY_BYTES", cfg.MaxBodyBytes)
|
||||||
|
cfg.SessionTTL = getenvDuration("SESSION_TTL", cfg.SessionTTL)
|
||||||
|
cfg.CleanupInterval = getenvDuration("SESSION_CLEANUP_INTERVAL", cfg.CleanupInterval)
|
||||||
|
cfg.MaxSessions = getenvInt("MAX_SESSIONS", cfg.MaxSessions)
|
||||||
|
cfg.InstanceID = getenv("INSTANCE_ID", cfg.InstanceID)
|
||||||
|
cfg.InstanceURL = getenv("INSTANCE_URL", cfg.InstanceURL)
|
||||||
|
cfg.EnableRedis = getenvBool("ENABLE_REDIS_STICKY", cfg.EnableRedis)
|
||||||
|
cfg.RedisAddr = getenv("REDIS_ADDR", cfg.RedisAddr)
|
||||||
|
cfg.RedisPassword = getenv("REDIS_PASSWORD", cfg.RedisPassword)
|
||||||
|
cfg.RedisDB = getenvInt("REDIS_DB", cfg.RedisDB)
|
||||||
|
cfg.RedisKeyPrefix = getenv("REDIS_KEY_PREFIX", cfg.RedisKeyPrefix)
|
||||||
|
cfg.InstanceTTL = getenvDuration("INSTANCE_TTL", cfg.InstanceTTL)
|
||||||
|
cfg.Heartbeat = getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", cfg.Heartbeat)
|
||||||
|
cfg.EnableNacosRegister = getenvBool("ENABLE_NACOS_REGISTER", cfg.EnableNacosRegister)
|
||||||
|
cfg.NacosServerAddr = getenv("NACOS_SERVER_ADDR", cfg.NacosServerAddr)
|
||||||
|
cfg.NacosNamespace = getenv("NACOS_NAMESPACE", cfg.NacosNamespace)
|
||||||
|
cfg.NacosGroup = getenv("NACOS_GROUP", cfg.NacosGroup)
|
||||||
|
cfg.NacosServiceName = getenv("NACOS_SERVICE_NAME", cfg.NacosServiceName)
|
||||||
|
cfg.NacosClusterName = getenv("NACOS_CLUSTER_NAME", cfg.NacosClusterName)
|
||||||
|
cfg.NacosUsername = getenv("NACOS_USERNAME", cfg.NacosUsername)
|
||||||
|
cfg.NacosPassword = getenv("NACOS_PASSWORD", cfg.NacosPassword)
|
||||||
|
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
|
||||||
|
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
|
||||||
|
cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral)
|
||||||
|
cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeConfig(cfg *config, cwd string) {
|
||||||
|
if strings.TrimSpace(cfg.Port) == "" {
|
||||||
|
cfg.Port = "8080"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.WorkspaceDir) == "" {
|
||||||
|
cfg.WorkspaceDir = cwd
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.AllowOrigin) == "" {
|
||||||
|
cfg.AllowOrigin = "*"
|
||||||
|
}
|
||||||
|
if cfg.RequestTimeout <= 0 {
|
||||||
|
cfg.RequestTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.MaxBodyBytes <= 0 {
|
||||||
|
cfg.MaxBodyBytes = 2 << 20
|
||||||
|
}
|
||||||
|
if cfg.SessionTTL <= 0 {
|
||||||
|
cfg.SessionTTL = 20 * time.Minute
|
||||||
|
}
|
||||||
|
if cfg.CleanupInterval <= 0 {
|
||||||
|
cfg.CleanupInterval = 2 * time.Minute
|
||||||
|
}
|
||||||
|
if cfg.MaxSessions <= 0 {
|
||||||
|
cfg.MaxSessions = 256
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.InstanceID) == "" {
|
||||||
|
cfg.InstanceID = defaultInstanceID()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.InstanceURL) == "" {
|
||||||
|
cfg.InstanceURL = "http://127.0.0.1:" + cfg.Port
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.RedisAddr) == "" {
|
||||||
|
cfg.RedisAddr = "10.0.0.10:6379"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.RedisKeyPrefix) == "" {
|
||||||
|
cfg.RedisKeyPrefix = "lsp-gateway"
|
||||||
|
}
|
||||||
|
if cfg.InstanceTTL <= 0 {
|
||||||
|
cfg.InstanceTTL = 30 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Heartbeat <= 0 {
|
||||||
|
cfg.Heartbeat = 10 * time.Second
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.NacosServerAddr) == "" {
|
||||||
|
cfg.NacosServerAddr = "10.0.0.10:8848"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.NacosGroup) == "" {
|
||||||
|
cfg.NacosGroup = "DEFAULT_GROUP"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.NacosServiceName) == "" {
|
||||||
|
cfg.NacosServiceName = "lsp-gateway"
|
||||||
|
}
|
||||||
|
if cfg.NacosRegisterPort <= 0 {
|
||||||
|
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
|
||||||
|
}
|
||||||
|
cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL)
|
||||||
|
if len(cfg.Servers) == 0 {
|
||||||
|
cfg.Servers = defaultLanguageServers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePortOrDefault(port string, fallback int) int {
|
||||||
|
trimmed := strings.TrimSpace(port)
|
||||||
|
if trimmed == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(trimmed)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultLanguageServers() []completion.LanguageServerSpec {
|
||||||
|
return []completion.LanguageServerSpec{
|
||||||
{
|
{
|
||||||
Language: "go",
|
Language: "go",
|
||||||
LanguageID: "go",
|
LanguageID: "go",
|
||||||
Command: getenv("GO_LSP_COMMAND", "gopls"),
|
Command: "gopls",
|
||||||
Args: getenvArgs("GO_LSP_ARGS", ""),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "javascript",
|
Language: "javascript",
|
||||||
LanguageID: "javascript",
|
LanguageID: "javascript",
|
||||||
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"),
|
Command: "typescript-language-server",
|
||||||
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"),
|
Args: []string{"--stdio"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "typescript",
|
Language: "typescript",
|
||||||
LanguageID: "typescript",
|
LanguageID: "typescript",
|
||||||
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"),
|
Command: "typescript-language-server",
|
||||||
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"),
|
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"))
|
||||||
|
|||||||
103
backend/cmd/server/main_test.go
Normal file
103
backend/cmd/server/main_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigNacosDefaults(t *testing.T) {
|
||||||
|
withEnv(t, "ENABLE_NACOS_REGISTER", "")
|
||||||
|
withEnv(t, "NACOS_SERVER_ADDR", "")
|
||||||
|
withEnv(t, "NACOS_NAMESPACE", "")
|
||||||
|
withEnv(t, "NACOS_GROUP", "")
|
||||||
|
withEnv(t, "NACOS_SERVICE_NAME", "")
|
||||||
|
withEnv(t, "NACOS_CLUSTER_NAME", "")
|
||||||
|
withEnv(t, "NACOS_USERNAME", "")
|
||||||
|
withEnv(t, "NACOS_PASSWORD", "")
|
||||||
|
withEnv(t, "NACOS_IP", "")
|
||||||
|
withEnv(t, "NACOS_PORT", "")
|
||||||
|
withEnv(t, "PORT", "")
|
||||||
|
withEnv(t, "INSTANCE_URL", "")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.EnableNacosRegister {
|
||||||
|
t.Fatalf("expected EnableNacosRegister default false")
|
||||||
|
}
|
||||||
|
if cfg.NacosServerAddr != "10.0.0.10:8848" {
|
||||||
|
t.Fatalf("expected default NacosServerAddr 10.0.0.10:8848, got %q", cfg.NacosServerAddr)
|
||||||
|
}
|
||||||
|
if cfg.NacosGroup != "DEFAULT_GROUP" {
|
||||||
|
t.Fatalf("expected default NacosGroup DEFAULT_GROUP, got %q", cfg.NacosGroup)
|
||||||
|
}
|
||||||
|
if cfg.NacosServiceName != "lsp-gateway" {
|
||||||
|
t.Fatalf("expected default NacosServiceName lsp-gateway, got %q", cfg.NacosServiceName)
|
||||||
|
}
|
||||||
|
if cfg.NacosRegisterPort != 8080 {
|
||||||
|
t.Fatalf("expected default NacosRegisterPort 8080, got %d", cfg.NacosRegisterPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigNacosFromEnv(t *testing.T) {
|
||||||
|
withEnv(t, "ENABLE_NACOS_REGISTER", "true")
|
||||||
|
withEnv(t, "NACOS_SERVER_ADDR", "10.0.0.10:8848")
|
||||||
|
withEnv(t, "NACOS_NAMESPACE", "prod-ns")
|
||||||
|
withEnv(t, "NACOS_GROUP", "editor")
|
||||||
|
withEnv(t, "NACOS_SERVICE_NAME", "editor-lsp")
|
||||||
|
withEnv(t, "NACOS_CLUSTER_NAME", "hz-a")
|
||||||
|
withEnv(t, "NACOS_USERNAME", "nacos")
|
||||||
|
withEnv(t, "NACOS_PASSWORD", "nacos")
|
||||||
|
withEnv(t, "NACOS_IP", "172.16.10.9")
|
||||||
|
withEnv(t, "NACOS_PORT", "19090")
|
||||||
|
withEnv(t, "PORT", "9999")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.EnableNacosRegister {
|
||||||
|
t.Fatalf("expected EnableNacosRegister true")
|
||||||
|
}
|
||||||
|
if cfg.NacosServerAddr != "10.0.0.10:8848" {
|
||||||
|
t.Fatalf("unexpected Nacos server %s", cfg.NacosServerAddr)
|
||||||
|
}
|
||||||
|
if cfg.NacosNamespace != "prod-ns" {
|
||||||
|
t.Fatalf("unexpected NacosNamespace %q", cfg.NacosNamespace)
|
||||||
|
}
|
||||||
|
if cfg.NacosGroup != "editor" {
|
||||||
|
t.Fatalf("unexpected NacosGroup %q", cfg.NacosGroup)
|
||||||
|
}
|
||||||
|
if cfg.NacosServiceName != "editor-lsp" {
|
||||||
|
t.Fatalf("unexpected NacosServiceName %q", cfg.NacosServiceName)
|
||||||
|
}
|
||||||
|
if cfg.NacosClusterName != "hz-a" {
|
||||||
|
t.Fatalf("unexpected NacosClusterName %q", cfg.NacosClusterName)
|
||||||
|
}
|
||||||
|
if cfg.NacosUsername != "nacos" || cfg.NacosPassword != "nacos" {
|
||||||
|
t.Fatalf("unexpected nacos auth")
|
||||||
|
}
|
||||||
|
if cfg.NacosRegisterIP != "172.16.10.9" || cfg.NacosRegisterPort != 19090 {
|
||||||
|
t.Fatalf("unexpected register endpoint %s:%d", cfg.NacosRegisterIP, cfg.NacosRegisterPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withEnv(t *testing.T, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
old, existed := os.LookupEnv(key)
|
||||||
|
if value == "" {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !existed {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Setenv(key, old)
|
||||||
|
})
|
||||||
|
}
|
||||||
55
backend/config.example.json
Normal file
55
backend/config.example.json
Normal 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
55
backend/config.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
186
backend/docs/java-nacos-integration-guide.md
Normal file
186
backend/docs/java-nacos-integration-guide.md
Normal 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 Alibaba(Nacos)示例
|
||||||
|
|
||||||
|
## 6.1 application.yml(服务发现)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: editor-bff
|
||||||
|
cloud:
|
||||||
|
nacos:
|
||||||
|
discovery:
|
||||||
|
server-addr: 10.0.0.20:8848
|
||||||
|
```
|
||||||
|
|
||||||
|
Go 服务在 Nacos 中注册名假设为:`lsp-gateway`
|
||||||
|
|
||||||
|
## 6.2 Go 侧与 Spring 配置映射(SDK 注册)
|
||||||
|
|
||||||
|
你的 Java 配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cloud:
|
||||||
|
nacos:
|
||||||
|
discovery:
|
||||||
|
enabled: true
|
||||||
|
register-enabled: true
|
||||||
|
server-addr: 10.0.0.10:8848
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
```
|
||||||
|
|
||||||
|
对应 Go 环境变量:
|
||||||
|
|
||||||
|
- `ENABLE_NACOS_REGISTER=true`
|
||||||
|
- `NACOS_SERVER_ADDR=10.0.0.10:8848`
|
||||||
|
- `NACOS_USERNAME=nacos`
|
||||||
|
- `NACOS_PASSWORD=nacos`
|
||||||
|
- `NACOS_SERVICE_NAME=lsp-gateway`
|
||||||
|
- `NACOS_GROUP=DEFAULT_GROUP`
|
||||||
|
- `NACOS_IP=<当前实例可达IP>`
|
||||||
|
- `NACOS_PORT=8080`(或你的服务端口)
|
||||||
|
|
||||||
|
## 6.3 Gateway 路由示例(HTTP + WS)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
cloud:
|
||||||
|
gateway:
|
||||||
|
routes:
|
||||||
|
- id: lsp-http
|
||||||
|
uri: lb://lsp-gateway
|
||||||
|
predicates:
|
||||||
|
- Path=/lsp/api/**
|
||||||
|
filters:
|
||||||
|
- RewritePath=/lsp/api/(?<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 通道可建立,断线重连后会话仍可恢复。
|
||||||
94
backend/docs/why-redis-sticky-routing.md
Normal file
94
backend/docs/why-redis-sticky-routing.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 为什么要做 Redis 会话外置 + 粘性路由
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 LSP 网关的核心能力是:
|
||||||
|
- 把编辑器请求转成 LSP(JSON-RPC over stdio)
|
||||||
|
- 为每个 `language + sessionId` 维护一个长生命周期会话(对应语言服务器进程与文档状态)
|
||||||
|
|
||||||
|
单实例时,这套方案天然稳定。
|
||||||
|
一旦进入微服务部署(多副本 + 负载均衡),如果没有额外机制,会出现严重一致性问题。
|
||||||
|
|
||||||
|
## 不做会发生什么
|
||||||
|
|
||||||
|
### 1. 会话状态丢失
|
||||||
|
|
||||||
|
同一个用户会话的请求可能先到实例 A、再到实例 B。
|
||||||
|
但 B 没有 A 的内存态(didOpen/didChange 版本、LSP 上下文),会导致:
|
||||||
|
- 补全质量波动
|
||||||
|
- 诊断/跳转不一致
|
||||||
|
- 重复初始化语言服务器
|
||||||
|
|
||||||
|
### 2. 成本放大
|
||||||
|
|
||||||
|
会话不稳定会触发频繁建进程,带来:
|
||||||
|
- 更高 CPU/内存
|
||||||
|
- 更高请求尾延迟(P95/P99)
|
||||||
|
- 更多瞬时失败
|
||||||
|
|
||||||
|
### 3. 故障不可控
|
||||||
|
|
||||||
|
无统一会话归属时,问题难定位(到底是哪台实例持有上下文、何时丢失)。
|
||||||
|
|
||||||
|
## 为什么要 Redis 会话外置
|
||||||
|
|
||||||
|
Redis 不是替代 LSP 进程,而是做“会话目录(Session Directory)”:
|
||||||
|
- 记录某个 `language + sessionId` 当前归属哪个实例
|
||||||
|
- 记录实例心跳与可回源地址
|
||||||
|
- 给会话绑定 TTL,支持自动过期与回收
|
||||||
|
|
||||||
|
它带来的价值:
|
||||||
|
- 多副本下会话归属可见、可控
|
||||||
|
- 实例重启/扩容/缩容时路由行为可预测
|
||||||
|
- 可以做统一治理(观测、告警、自动修复)
|
||||||
|
|
||||||
|
## 为什么要粘性路由
|
||||||
|
|
||||||
|
LSP 天然是“有状态协议”:同一会话必须持续命中同一实例才能复用上下文。
|
||||||
|
粘性路由的目标就是保证这一点。
|
||||||
|
|
||||||
|
网关当前策略:
|
||||||
|
1. 请求到达后先在 Redis `claim` 会话归属
|
||||||
|
2. 如果归属自己:本地处理
|
||||||
|
3. 如果归属其他实例:返回 `409 + routeTo`(HTTP 头 `X-LSP-Route-To`)
|
||||||
|
4. 上游(前端或 Java 网关)据此重试到目标实例
|
||||||
|
|
||||||
|
这比纯随机 LB 更符合 LSP 工作方式。
|
||||||
|
|
||||||
|
## 设计取舍
|
||||||
|
|
||||||
|
### 收益
|
||||||
|
- 会话一致性显著提升
|
||||||
|
- 进程复用率提高,资源更稳
|
||||||
|
- 故障域清晰,便于排障
|
||||||
|
|
||||||
|
### 代价
|
||||||
|
- 引入 Redis 依赖(网络与可用性要保障)
|
||||||
|
- 路由逻辑更复杂(冲突重试、TTL 管理)
|
||||||
|
- 需要对上游调用方约束:必须稳定传 `sessionId`
|
||||||
|
|
||||||
|
## 什么时候可以不做
|
||||||
|
|
||||||
|
可以暂不启用 Redis/粘性路由的场景:
|
||||||
|
- 仅单实例部署
|
||||||
|
- 开发/演示环境
|
||||||
|
- 对补全一致性不敏感
|
||||||
|
|
||||||
|
但只要进入生产多副本,建议启用。
|
||||||
|
|
||||||
|
## 适配 Java 微服务体系的意义
|
||||||
|
|
||||||
|
将 LSP 网关作为独立微服务后:
|
||||||
|
- Java 业务服务无需关心各语言 LSP 细节
|
||||||
|
- 可以统一接入鉴权、限流、链路追踪
|
||||||
|
- LSP 能力扩展(Go/JS/TS/Java/Python)不会反复侵入业务服务
|
||||||
|
|
||||||
|
这就是把它做成独立 LSP 服务的核心原因:**把状态复杂性收敛在一个可治理的边界里**。
|
||||||
|
|
||||||
|
## 当前实现对应点
|
||||||
|
|
||||||
|
- Redis 默认配置:`10.0.0.10:6379`, `DB=1`, 无密码
|
||||||
|
- 会话注册与认领:`internal/cluster/redis_registry.go`
|
||||||
|
- 会话管理与归属检查:`internal/completion/manager.go`
|
||||||
|
- 路由冲突返回:`internal/api/handler.go`, `internal/api/ws_handler.go`
|
||||||
|
- 启动配置入口:`cmd/server/main.go`
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
302
backend/go.sum
302
backend/go.sum
@@ -1,20 +1,100 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||||
|
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||||
|
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||||
|
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||||
|
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||||
|
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||||
|
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||||
|
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||||
|
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||||
|
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||||
|
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||||
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.9/go.mod h1:bb+Io8Sn2RuM3/Rpme6ll86jMyFSrD1bxeV/+v61KeU=
|
||||||
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 h1:GEYkMApgpKEVDn6z12DcH1EGYpDYRB8JxsazM4Rywak=
|
||||||
|
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE=
|
||||||
|
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||||
|
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||||
|
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||||
|
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||||
|
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||||
|
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||||
|
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||||
|
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||||
|
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||||
|
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||||
|
github.com/alibabacloud-go/kms-20160120/v3 v3.2.3 h1:vamGcYQFwXVqR6RWcrVTTqlIXZVsYjaA7pZbx+Xw6zw=
|
||||||
|
github.com/alibabacloud-go/kms-20160120/v3 v3.2.3/go.mod h1:3rIyughsFDLie1ut9gQJXkWkMg/NfXBCk+OtXnPu3lw=
|
||||||
|
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||||
|
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||||
|
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||||
|
github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA=
|
||||||
|
github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU=
|
||||||
|
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||||
|
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||||
|
github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o=
|
||||||
|
github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
|
||||||
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ=
|
||||||
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||||
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||||
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
|
||||||
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||||
|
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||||
|
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||||
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw=
|
||||||
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
|
||||||
|
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 h1:nJYyoFP+aqGKgPs9JeZgS1rWQ4NndNR0Zfhh161ZltU=
|
||||||
|
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1/go.mod h1:WzGOmFFTlUzXM03CJnHWMQ85UN6QGpOXZocCjwkiyOg=
|
||||||
|
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 h1:QeUdR7JF7iNCvO/81EhxEr3wDwxk4YBoYZOq6E0AjHI=
|
||||||
|
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8/go.mod h1:xP0KIZry6i7oGPF24vhAPr1Q8vLZRcMcxtft5xDKwCU=
|
||||||
|
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 h1:8S0mtD101RDYa0LXwdoqgN0RxdMmmJYjq8g2mk7/lQ4=
|
||||||
|
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5/go.mod h1:M19fxYz3gpm0ETnoKweYyYtqrtnVtrpKFpwsghbw+cQ=
|
||||||
|
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||||
|
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||||
|
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||||
|
github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||||
|
github.com/aliyun/credentials-go v1.4.3 h1:N3iHyvHRMyOwY1+0qBLSf3hb5JFiOujVSVuEpgeGttY=
|
||||||
|
github.com/aliyun/credentials-go v1.4.3/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||||
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 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=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
186
backend/internal/cluster/nacos_registry.go
Normal file
186
backend/internal/cluster/nacos_registry.go
Normal 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 ""
|
||||||
|
}
|
||||||
36
backend/internal/cluster/nacos_registry_test.go
Normal file
36
backend/internal/cluster/nacos_registry_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user