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:
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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user