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:
@@ -15,15 +15,18 @@ type CompletionService interface {
|
||||
Complete(ctx context.Context, req completion.Request) (completion.Response, error)
|
||||
}
|
||||
|
||||
// SessionStatsProvider 暴露会话统计信息,供就绪探针输出。
|
||||
type SessionStatsProvider interface {
|
||||
ActiveSessions() map[string]int
|
||||
}
|
||||
|
||||
// RouteOptions 控制 HTTP/WS 接口的超时与请求体上限。
|
||||
type RouteOptions struct {
|
||||
RequestTimeout time.Duration
|
||||
MaxBodyBytes int64
|
||||
RequestTimeout time.Duration // 单次补全调用超时时间。
|
||||
MaxBodyBytes int64 // 请求体最大字节数(HTTP/WS 共用)。
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册健康检查、HTTP 补全接口和 WebSocket 补全接口。
|
||||
func RegisterRoutes(router *gin.Engine, service CompletionService, options ...RouteOptions) {
|
||||
opts := RouteOptions{
|
||||
RequestTimeout: 10 * time.Second,
|
||||
@@ -60,6 +63,7 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
||||
registerWSRoutes(router, service, opts)
|
||||
|
||||
handleCompletion := func(c *gin.Context) {
|
||||
// 为单次请求限制 body 大小,避免异常大包占满内存。
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes)
|
||||
var req completion.Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -68,10 +72,12 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
||||
}
|
||||
|
||||
routeLang := c.Param("language")
|
||||
// 若 body 未显式给出 language,则使用路由参数。
|
||||
if req.Language == "" {
|
||||
req.Language = routeLang
|
||||
}
|
||||
|
||||
// 对下游补全调用增加超时保护,防止请求长时间悬挂。
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), opts.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
// 验证 HTTP 补全接口的成功路径。
|
||||
func TestRegisterRoutesCompletionSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -63,6 +64,7 @@ func TestRegisterRoutesCompletionSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证非法 JSON 会返回 400。
|
||||
func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -78,6 +80,7 @@ func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证业务校验错误会映射为 400。
|
||||
func TestRegisterRoutesCompletionValidationError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -100,6 +103,7 @@ func TestRegisterRoutesCompletionValidationError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证未知内部错误会映射为 500。
|
||||
func TestRegisterRoutesCompletionServerError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -121,6 +125,7 @@ func TestRegisterRoutesCompletionServerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 WebSocket 补全协议的基础成功流程。
|
||||
func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
@@ -21,6 +21,7 @@ var wsUpgrader = websocket.Upgrader{
|
||||
},
|
||||
}
|
||||
|
||||
// wsCompletionRequest 是普通 WS 消息的补全请求格式。
|
||||
type wsCompletionRequest struct {
|
||||
ID string `json:"id"`
|
||||
Language string `json:"language,omitempty"`
|
||||
@@ -31,6 +32,7 @@ type wsCompletionRequest struct {
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
// wsCompletionResponse 是普通 WS 消息的补全响应格式。
|
||||
type wsCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Items []completion.Item `json:"items,omitempty"`
|
||||
@@ -40,6 +42,7 @@ type wsCompletionResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// wsRPCRequest/wsRPCResponse 用于兼容 JSON-RPC 2.0 客户端。
|
||||
type wsRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
@@ -54,6 +57,7 @@ type wsRPCResponse struct {
|
||||
Error any `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// registerWSRoutes 注册 WebSocket 补全入口(含可选语言路由)。
|
||||
func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteOptions) {
|
||||
handler := func(c *gin.Context, defaultLanguage string) {
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
@@ -67,6 +71,7 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
|
||||
var writeMu sync.Mutex
|
||||
|
||||
for {
|
||||
// 单连接串行读取消息,写操作通过 writeMu 保证并发安全。
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
@@ -83,6 +88,7 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
|
||||
})
|
||||
}
|
||||
|
||||
// handleWSMessage 先尝试按 JSON-RPC 处理;失败后回退到普通 JSON 协议。
|
||||
func handleWSMessage(
|
||||
conn *websocket.Conn,
|
||||
writeMu *sync.Mutex,
|
||||
@@ -110,6 +116,7 @@ func handleWSMessage(
|
||||
processWSCompletion(conn, writeMu, service, req, opts)
|
||||
}
|
||||
|
||||
// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。
|
||||
func tryHandleRPCMessage(
|
||||
conn *websocket.Conn,
|
||||
writeMu *sync.Mutex,
|
||||
@@ -127,6 +134,7 @@ func tryHandleRPCMessage(
|
||||
}
|
||||
|
||||
if rpcReq.Method != "completion/complete" && rpcReq.Method != "completion.complete" {
|
||||
// 非补全方法按 JSON-RPC 规范返回 method not found。
|
||||
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: rpcReq.ID,
|
||||
@@ -140,6 +148,7 @@ func tryHandleRPCMessage(
|
||||
|
||||
var req wsCompletionRequest
|
||||
if err := json.Unmarshal(rpcReq.Params, &req); err != nil {
|
||||
// 参数反序列化失败按 invalid params 处理。
|
||||
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: rpcReq.ID,
|
||||
@@ -154,6 +163,7 @@ func tryHandleRPCMessage(
|
||||
req.Language = defaultLanguage
|
||||
}
|
||||
if req.ID == "" {
|
||||
// 兼容未在 params 提供业务 ID 的客户端。
|
||||
req.ID = string(rpcReq.ID)
|
||||
}
|
||||
|
||||
@@ -188,6 +198,7 @@ func tryHandleRPCMessage(
|
||||
return true
|
||||
}
|
||||
|
||||
// processWSCompletion 处理普通 WS 协议下的补全请求。
|
||||
func processWSCompletion(
|
||||
conn *websocket.Conn,
|
||||
writeMu *sync.Mutex,
|
||||
@@ -220,6 +231,7 @@ func processWSCompletion(
|
||||
default:
|
||||
var ownedErr *completion.ErrSessionOwnedByOtherInstance
|
||||
if errors.As(err, &ownedErr) {
|
||||
// 会话在其他实例上时返回路由提示,客户端可重连对应节点。
|
||||
msg = err.Error()
|
||||
routeTo = ownedErr.OwnerEndpoint
|
||||
ownerID = ownedErr.OwnerID
|
||||
@@ -241,12 +253,14 @@ func processWSCompletion(
|
||||
})
|
||||
}
|
||||
|
||||
// sendWSResponse 统一串行写回普通 WS 响应。
|
||||
func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
_ = conn.WriteJSON(resp)
|
||||
}
|
||||
|
||||
// sendWSRPCResponse 统一串行写回 JSON-RPC 响应。
|
||||
func sendWSRPCResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsRPCResponse) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
|
||||
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 {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
KeyPrefix string
|
||||
InstanceID string
|
||||
InstanceEndpoint string
|
||||
SessionTTL time.Duration
|
||||
InstanceTTL time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
Addr string // Redis 地址,例如 127.0.0.1:6379。
|
||||
Password string // Redis 密码,可为空。
|
||||
DB int // Redis 数据库编号。
|
||||
KeyPrefix string // 键前缀,隔离不同环境/业务。
|
||||
InstanceID string // 当前实例唯一 ID。
|
||||
InstanceEndpoint string // 当前实例对外访问地址。
|
||||
SessionTTL time.Duration // 会话键的过期时间。
|
||||
InstanceTTL time.Duration // 实例元数据过期时间。
|
||||
HeartbeatInterval time.Duration // 实例心跳刷新周期。
|
||||
}
|
||||
|
||||
// RedisRegistry 负责在 Redis 中维护实例心跳与会话归属。
|
||||
type RedisRegistry struct {
|
||||
client *redis.Client
|
||||
|
||||
@@ -37,6 +38,7 @@ type RedisRegistry struct {
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// claimSessionScript 原子地抢占/续租会话,返回当前 owner。
|
||||
var claimSessionScript = redis.NewScript(`
|
||||
local sessionKey = KEYS[1]
|
||||
local owner = ARGV[1]
|
||||
@@ -54,6 +56,7 @@ redis.call('PEXPIRE', sessionKey, ttl)
|
||||
return existing
|
||||
`)
|
||||
|
||||
// releaseSessionScript 仅允许 owner 主动释放自己的会话。
|
||||
var releaseSessionScript = redis.NewScript(`
|
||||
local sessionKey = KEYS[1]
|
||||
local owner = ARGV[1]
|
||||
@@ -65,6 +68,7 @@ end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// NewRedisRegistry 初始化 Redis 客户端并启动实例心跳。
|
||||
func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegistry, error) {
|
||||
if strings.TrimSpace(cfg.Addr) == "" {
|
||||
return nil, errors.New("redis addr is required")
|
||||
@@ -113,6 +117,7 @@ func NewRedisRegistry(ctx context.Context, cfg RedisRegistryConfig) (*RedisRegis
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// ClaimSession 尝试声明会话归属,并解析 owner 对应的实例地址。
|
||||
func (r *RedisRegistry) ClaimSession(
|
||||
ctx context.Context,
|
||||
language string,
|
||||
@@ -147,6 +152,7 @@ func (r *RedisRegistry) ClaimSession(
|
||||
return owner, endpoint, nil
|
||||
}
|
||||
|
||||
// ReleaseSession 释放当前实例持有的会话键。
|
||||
func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID string) error {
|
||||
key := r.sessionKey(language, sessionID)
|
||||
if _, err := releaseSessionScript.Run(ctx, r.client, []string{key}, r.instanceID).Result(); err != nil {
|
||||
@@ -155,6 +161,7 @@ func (r *RedisRegistry) ReleaseSession(ctx context.Context, language, sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 停止心跳并关闭 Redis 连接。
|
||||
func (r *RedisRegistry) Close() error {
|
||||
r.stopOnce.Do(func() {
|
||||
close(r.stopCh)
|
||||
@@ -162,6 +169,7 @@ func (r *RedisRegistry) Close() error {
|
||||
return r.client.Close()
|
||||
}
|
||||
|
||||
// heartbeatLoop 周期性刷新实例元数据,维持实例在线状态。
|
||||
func (r *RedisRegistry) heartbeatLoop() {
|
||||
ticker := time.NewTicker(r.heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -178,6 +186,7 @@ func (r *RedisRegistry) heartbeatLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// refreshInstance 更新实例 endpoint 和 TTL。
|
||||
func (r *RedisRegistry) refreshInstance(ctx context.Context) error {
|
||||
key := r.instanceKey(r.instanceID)
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
@@ -193,6 +202,7 @@ func (r *RedisRegistry) refreshInstance(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInstanceEndpoint 根据实例 ID 读取其对外地址。
|
||||
func (r *RedisRegistry) resolveInstanceEndpoint(ctx context.Context, ownerID string) (string, error) {
|
||||
key := r.instanceKey(ownerID)
|
||||
endpoint, err := r.client.HGet(ctx, key, "endpoint").Result()
|
||||
@@ -213,6 +223,7 @@ func (r *RedisRegistry) instanceKey(instanceID string) string {
|
||||
return fmt.Sprintf("%s:instances:%s", r.keyPrefix, normalizePart(instanceID))
|
||||
}
|
||||
|
||||
// normalizePart 规范化 Redis key 片段,避免空字符串破坏 key 结构。
|
||||
func normalizePart(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -13,35 +13,40 @@ import (
|
||||
var ErrUnsupportedLanguage = errors.New("unsupported language")
|
||||
var ErrTooManySessions = errors.New("too many active lsp sessions")
|
||||
|
||||
// RuntimeClient 是带生命周期管理能力的补全客户端。
|
||||
type RuntimeClient interface {
|
||||
Client
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SessionRegistry 抽象跨实例会话归属协调能力(如 Redis)。
|
||||
type SessionRegistry interface {
|
||||
ClaimSession(ctx context.Context, language, sessionID string) (ownerID string, ownerEndpoint string, err error)
|
||||
ReleaseSession(ctx context.Context, language, sessionID string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// LanguageServerSpec 描述某种语言对应的 LSP 启动参数。
|
||||
type LanguageServerSpec struct {
|
||||
Language string
|
||||
LanguageID string
|
||||
Command string
|
||||
Args []string
|
||||
Language string // 语言名(如 go/typescript),用于路由匹配。
|
||||
LanguageID string // 传给 LSP 的 languageId。
|
||||
Command string // LSP 可执行命令。
|
||||
Args []string // LSP 启动参数。
|
||||
}
|
||||
|
||||
type ClientFactory func(ctx context.Context, spec LanguageServerSpec, workspaceDir string) (RuntimeClient, error)
|
||||
|
||||
// ManagerConfig 控制会话池容量、TTL 与实例信息。
|
||||
type ManagerConfig struct {
|
||||
WorkspaceDir string
|
||||
MaxSessions int
|
||||
SessionTTL time.Duration
|
||||
CleanupInterval time.Duration
|
||||
InstanceID string
|
||||
Registry SessionRegistry
|
||||
WorkspaceDir string // LSP 进程工作区目录。
|
||||
MaxSessions int // 本实例会话上限。
|
||||
SessionTTL time.Duration // 会话空闲超时。
|
||||
CleanupInterval time.Duration // 会话清理周期。
|
||||
InstanceID string // 当前实例 ID。
|
||||
Registry SessionRegistry // 可选分布式会话注册中心。
|
||||
}
|
||||
|
||||
// Manager 按 language/session 复用 LSP 会话,并负责清理与淘汰。
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -63,6 +68,7 @@ type managedSession struct {
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
// ErrSessionOwnedByOtherInstance 表示会话已被其他实例持有。
|
||||
type ErrSessionOwnedByOtherInstance struct {
|
||||
OwnerID string
|
||||
OwnerEndpoint string
|
||||
@@ -75,6 +81,7 @@ func (e *ErrSessionOwnedByOtherInstance) Error() string {
|
||||
return fmt.Sprintf("session owned by another instance: %s", e.OwnerID)
|
||||
}
|
||||
|
||||
// NewManager 构建会话管理器并启动后台清理协程。
|
||||
func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory ClientFactory) *Manager {
|
||||
if config.MaxSessions <= 0 {
|
||||
config.MaxSessions = 256
|
||||
@@ -109,6 +116,7 @@ func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory Client
|
||||
return m
|
||||
}
|
||||
|
||||
// Complete 处理补全请求,包含语言匹配、会话归属、会话复用/创建。
|
||||
func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
language := normalizeLanguage(req.Language)
|
||||
if language == "" {
|
||||
@@ -124,6 +132,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
sessionID := normalizeSessionID(req.SessionID)
|
||||
|
||||
if m.config.Registry != nil {
|
||||
// 分布式模式下先声明会话归属,避免多实例并发写同一会话。
|
||||
ownerID, ownerEndpoint, err := m.config.Registry.ClaimSession(ctx, language, sessionID)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
@@ -154,6 +163,7 @@ func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ActiveSessions 统计当前实例内各语言活跃会话数。
|
||||
func (m *Manager) ActiveSessions() map[string]int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -165,6 +175,7 @@ func (m *Manager) ActiveSessions() map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 停止后台任务并释放所有会话与注册中心资源。
|
||||
func (m *Manager) Close() error {
|
||||
m.stoppedOnce.Do(func() {
|
||||
close(m.stopCh)
|
||||
@@ -186,6 +197,7 @@ func (m *Manager) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupLoop 周期清理闲置会话。
|
||||
func (m *Manager) cleanupLoop() {
|
||||
ticker := time.NewTicker(m.config.CleanupInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -200,6 +212,7 @@ func (m *Manager) cleanupLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupIdleSessions 关闭超过 TTL 未使用的会话。
|
||||
func (m *Manager) cleanupIdleSessions() {
|
||||
cutoff := time.Now().Add(-m.config.SessionTTL)
|
||||
|
||||
@@ -218,6 +231,7 @@ func (m *Manager) cleanupIdleSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// getOrCreateSession 返回已有会话,或按需新建一个会话。
|
||||
func (m *Manager) getOrCreateSession(
|
||||
ctx context.Context,
|
||||
sessionKey string,
|
||||
@@ -258,6 +272,7 @@ func (m *Manager) getOrCreateSession(
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if existing, ok := m.sessions[sessionKey]; ok {
|
||||
// 并发竞争下可能已经被其他协程创建,直接复用并关闭新 client。
|
||||
_ = client.Close()
|
||||
existing.lastUsed = now
|
||||
return existing, nil
|
||||
@@ -266,6 +281,7 @@ func (m *Manager) getOrCreateSession(
|
||||
return newSession, nil
|
||||
}
|
||||
|
||||
// evictLeastRecentlyUsedLocked 在达到上限时淘汰最久未使用会话。
|
||||
func (m *Manager) evictLeastRecentlyUsedLocked() bool {
|
||||
if len(m.sessions) == 0 {
|
||||
return false
|
||||
@@ -301,6 +317,7 @@ func buildSessionKey(language, sessionID string) string {
|
||||
return language + ":" + normalizeSessionID(sessionID)
|
||||
}
|
||||
|
||||
// normalizeSessionID 将空 session 归一为 default,便于复用同一会话键。
|
||||
func normalizeSessionID(sessionID string) string {
|
||||
sid := strings.TrimSpace(sessionID)
|
||||
if sid == "" {
|
||||
@@ -309,6 +326,7 @@ func normalizeSessionID(sessionID string) string {
|
||||
return sid
|
||||
}
|
||||
|
||||
// normalizeLanguage 统一语言名大小写和空白。
|
||||
func normalizeLanguage(language string) string {
|
||||
return strings.ToLower(strings.TrimSpace(language))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func (f *fakeRuntimeClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 同 language+session 的请求应复用同一个底层 client。
|
||||
func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) {
|
||||
createCount := 0
|
||||
factory := func(_ context.Context, spec LanguageServerSpec, _ string) (RuntimeClient, error) {
|
||||
@@ -68,6 +69,7 @@ func TestManagerCompleteSelectsLanguageAndSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 未配置的语言应返回 ErrUnsupportedLanguage。
|
||||
func TestManagerCompleteUnsupportedLanguage(t *testing.T) {
|
||||
m := NewManager(ManagerConfig{}, []LanguageServerSpec{
|
||||
{Language: "go", LanguageID: "go", Command: "gopls"},
|
||||
@@ -88,6 +90,7 @@ func TestManagerCompleteUnsupportedLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 超过空闲 TTL 的会话应被后台清理并关闭 client。
|
||||
func TestManagerCleanupIdleSession(t *testing.T) {
|
||||
client := &fakeRuntimeClient{}
|
||||
m := NewManager(ManagerConfig{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
var ErrInvalidRequest = errors.New("invalid completion request")
|
||||
|
||||
// Request 是统一的补全请求模型。
|
||||
type Request struct {
|
||||
Language string `json:"language,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
@@ -18,6 +19,7 @@ type Request struct {
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
// Item 对应一个补全候选项。
|
||||
type Item struct {
|
||||
Label string `json:"label"`
|
||||
Kind int `json:"kind,omitempty"`
|
||||
@@ -28,11 +30,13 @@ type Item struct {
|
||||
FilterText string `json:"filterText,omitempty"`
|
||||
}
|
||||
|
||||
// Response 是补全结果集合。
|
||||
type Response struct {
|
||||
Items []Item `json:"items"`
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
}
|
||||
|
||||
// Client 抽象 LSP 客户端所需的最小能力。
|
||||
type Client interface {
|
||||
DidOpen(ctx context.Context, uri, text string, version int) error
|
||||
DidChange(ctx context.Context, uri, text string, version int) error
|
||||
@@ -43,6 +47,7 @@ type documentState struct {
|
||||
version int
|
||||
}
|
||||
|
||||
// Service 负责文档生命周期同步(didOpen/didChange)与补全调用。
|
||||
type Service struct {
|
||||
client Client
|
||||
|
||||
@@ -57,6 +62,7 @@ func NewService(client Client) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete 根据 URI 的历史状态决定发送 didOpen 或 didChange,再请求补全。
|
||||
func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
if err := validateRequest(req); err != nil {
|
||||
return Response{}, err
|
||||
@@ -86,6 +92,7 @@ func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// validateRequest 做基础参数校验,避免向 LSP 发送非法请求。
|
||||
func validateRequest(req Request) error {
|
||||
if req.URI == "" {
|
||||
return ErrInvalidRequest
|
||||
|
||||
@@ -52,6 +52,7 @@ func (f *fakeLSPClient) Completion(_ context.Context, uri string, line, characte
|
||||
return f.completionResp, nil
|
||||
}
|
||||
|
||||
// 首次请求应发送 didOpen,并继续请求 completion。
|
||||
func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
|
||||
fake := &fakeLSPClient{
|
||||
completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true},
|
||||
@@ -84,6 +85,7 @@ func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文档第二次请求应发送 didChange,且版本号递增。
|
||||
func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
|
||||
fake := &fakeLSPClient{}
|
||||
svc := NewService(fake)
|
||||
@@ -119,6 +121,7 @@ func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 参数不合法时应直接返回 ErrInvalidRequest。
|
||||
func TestServiceCompleteValidatesRequest(t *testing.T) {
|
||||
svc := NewService(&fakeLSPClient{})
|
||||
|
||||
@@ -128,6 +131,7 @@ func TestServiceCompleteValidatesRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 底层 client 出错应向上透传错误。
|
||||
func TestServiceCompleteReturnsClientError(t *testing.T) {
|
||||
fake := &fakeLSPClient{openErr: errors.New("open failed")}
|
||||
svc := NewService(fake)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
var errClientClosed = errors.New("lsp client closed")
|
||||
|
||||
// Client 封装与 Language Server 的 JSON-RPC/LSP 通信。
|
||||
type Client struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
@@ -49,12 +50,14 @@ type rpcResponse struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// rpcError 对应 JSON-RPC 错误对象。
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// incomingEnvelope 表示从服务端读到的响应/通知外层结构。
|
||||
type incomingEnvelope struct {
|
||||
ID *json.RawMessage `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
@@ -76,14 +79,16 @@ type lspCompletionList struct {
|
||||
Items []lspCompletionItem `json:"items"`
|
||||
}
|
||||
|
||||
// Config 定义 LSP 子进程启动参数及客户端标识。
|
||||
type Config struct {
|
||||
Command string
|
||||
Args []string
|
||||
RootPath string
|
||||
LanguageID string
|
||||
ClientName string
|
||||
Command string // LSP 可执行命令。
|
||||
Args []string // 启动参数(通常包含 --stdio)。
|
||||
RootPath string // 工作区根路径。
|
||||
LanguageID string // didOpen/didChange 使用的 languageId。
|
||||
ClientName string // initialize.clientInfo.name。
|
||||
}
|
||||
|
||||
// NewClient 启动语言服务器进程并完成 initialize 握手。
|
||||
func NewClient(parent context.Context, cfg Config) (*Client, error) {
|
||||
if cfg.Command == "" {
|
||||
cfg.Command = "gopls"
|
||||
@@ -130,6 +135,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) {
|
||||
go func() {
|
||||
client.exitCh <- cmd.Wait()
|
||||
}()
|
||||
// 独立协程持续读取 stdout 并分发响应。
|
||||
go client.readLoop(stdout)
|
||||
|
||||
initCtx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
@@ -143,6 +149,7 @@ func NewClient(parent context.Context, cfg Config) (*Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// initialize 完成 LSP initialize/initialized 流程。
|
||||
func (c *Client) initialize(ctx context.Context, rootPath string) error {
|
||||
rootURI, err := pathToURI(rootPath)
|
||||
if err != nil {
|
||||
@@ -183,6 +190,7 @@ func (c *Client) initialize(ctx context.Context, rootPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DidOpen 发送 textDocument/didOpen,告知服务端首次打开文档。
|
||||
func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
@@ -200,6 +208,7 @@ func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) err
|
||||
return c.notifyWithContext(ctx, "textDocument/didOpen", params)
|
||||
}
|
||||
|
||||
// DidChange 发送 textDocument/didChange,推送文档全文与版本号。
|
||||
func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
@@ -220,6 +229,7 @@ func (c *Client) DidChange(ctx context.Context, uri, text string, version int) e
|
||||
return c.notifyWithContext(ctx, "textDocument/didChange", params)
|
||||
}
|
||||
|
||||
// Completion 调用 textDocument/completion,并兼容两种返回形态。
|
||||
func (c *Client) Completion(ctx context.Context, uri string, line, character int) (completion.Response, error) {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
@@ -247,6 +257,7 @@ func (c *Client) Completion(ctx context.Context, uri string, line, character int
|
||||
}
|
||||
|
||||
if body[0] == '[' {
|
||||
// 部分 LSP 服务端直接返回 CompletionItem[]。
|
||||
var items []lspCompletionItem
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return completion.Response{}, fmt.Errorf("decode completion items: %w", err)
|
||||
@@ -266,6 +277,7 @@ func (c *Client) Completion(ctx context.Context, uri string, line, character int
|
||||
|
||||
var windowsDrivePattern = regexp.MustCompile(`^[A-Za-z]:`)
|
||||
|
||||
// normalizeURI 将相对 file URI 重写为工作区绝对路径 URI。
|
||||
func (c *Client) normalizeURI(rawURI string) (string, error) {
|
||||
if rawURI == "" {
|
||||
return "", errors.New("empty uri")
|
||||
@@ -296,6 +308,7 @@ func (c *Client) normalizeURI(rawURI string) (string, error) {
|
||||
return pathToURI(localPath)
|
||||
}
|
||||
|
||||
// Close 优雅关闭 LSP 进程并失败通知所有未完成请求。
|
||||
func (c *Client) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
@@ -320,6 +333,7 @@ func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// request 发送 JSON-RPC 请求并阻塞等待对应响应。
|
||||
func (c *Client) request(ctx context.Context, method string, params any, out any) error {
|
||||
if c.closed.Load() {
|
||||
return errClientClosed
|
||||
@@ -365,6 +379,7 @@ func (c *Client) notify(method string, params any) error {
|
||||
return c.notifyWithContext(context.Background(), method, params)
|
||||
}
|
||||
|
||||
// notifyWithContext 发送 JSON-RPC 通知(无响应)。
|
||||
func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error {
|
||||
if c.closed.Load() {
|
||||
return errClientClosed
|
||||
@@ -383,6 +398,7 @@ func (c *Client) notifyWithContext(ctx context.Context, method string, params an
|
||||
return c.writeMessage(msg)
|
||||
}
|
||||
|
||||
// writeMessage 按 LSP framing 写入消息头和消息体。
|
||||
func (c *Client) writeMessage(msg any) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
@@ -403,6 +419,7 @@ func (c *Client) writeMessage(msg any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLoop 持续读取 LSP 消息并交给 handleIncoming 分发。
|
||||
func (c *Client) readLoop(stdout io.Reader) {
|
||||
reader := bufio.NewReader(stdout)
|
||||
for {
|
||||
@@ -415,6 +432,7 @@ func (c *Client) readLoop(stdout io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
// readMessage 按 Content-Length 协议边界读取单条 JSON-RPC 消息。
|
||||
func readMessage(reader *bufio.Reader) ([]byte, error) {
|
||||
contentLength := 0
|
||||
for {
|
||||
@@ -451,6 +469,7 @@ func readMessage(reader *bufio.Reader) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// handleIncoming 将响应按 id 投递给对应等待中的请求通道。
|
||||
func (c *Client) handleIncoming(body []byte) {
|
||||
var envelope incomingEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
@@ -485,6 +504,7 @@ func (c *Client) handleIncoming(body []byte) {
|
||||
wait <- rpcResponse{result: envelope.Result}
|
||||
}
|
||||
|
||||
// normalizeID 统一处理数字/字符串两种 JSON-RPC id。
|
||||
func normalizeID(raw json.RawMessage) string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
@@ -506,6 +526,7 @@ func (c *Client) removePending(key string) {
|
||||
c.pendingMu.Unlock()
|
||||
}
|
||||
|
||||
// failPending 在连接异常时让所有挂起请求立即失败返回。
|
||||
func (c *Client) failPending(err error) {
|
||||
c.pendingMu.Lock()
|
||||
defer c.pendingMu.Unlock()
|
||||
@@ -516,6 +537,7 @@ func (c *Client) failPending(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// mapCompletionItems 将 LSP 项结构映射为网关统一输出结构。
|
||||
func mapCompletionItems(items []lspCompletionItem) []completion.Item {
|
||||
out := make([]completion.Item, 0, len(items))
|
||||
for _, it := range items {
|
||||
@@ -532,6 +554,7 @@ func mapCompletionItems(items []lspCompletionItem) []completion.Item {
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeDocumentation 兼容 string 和 MarkupContent 两种文档字段。
|
||||
func decodeDocumentation(raw json.RawMessage) string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
@@ -556,6 +579,7 @@ func decodeDocumentation(raw json.RawMessage) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pathToURI 将本地路径转换为标准 file URI。
|
||||
func pathToURI(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestNormalizeURIRebasesRelativeFileURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 绝对 file URI 不应被重写。
|
||||
func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) {
|
||||
workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user