README
¶
多租户配置管理模块
[]
[
]
为所有 JXT 微服务提供共享的租户配置管理,集成 ETCD、自动重连和持久化缓存降级。
目录
快速开始
安装
go get github.com/ChenBigdata421/jxt-core/sdk/pkg/tenant
基本用法
package main
import (
"context"
"log"
"github.com/ChenBigdata421/jxt-core/sdk/pkg/tenant/provider"
"github.com/ChenBigdata421/jxt-core/sdk/pkg/tenant/database"
clientv3 "go.etcd.io/etcd/client/v3"
)
func main() {
// 1. 创建 ETCD 客户端
etcdClient, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer etcdClient.Close()
// 2. 创建 Provider(带重试和缓存)
prov, err := provider.NewProviderWithRetry(etcdClient,
provider.WithNamespace("jxt/"),
provider.WithConfigTypes(
provider.ConfigTypeDatabase,
provider.ConfigTypeFtp,
provider.ConfigTypeStorage,
),
provider.WithCache(provider.NewFileCache()),
)
if err != nil {
log.Fatal(err)
}
// 3. 启动 Watch(带重试)
ctx := context.Background()
if err := prov.StartWatchWithRetry(ctx); err != nil {
log.Fatal(err)
}
defer prov.StopWatch()
// 4. 使用缓存
dbCache := database.NewCache(prov)
cfg, err := dbCache.GetByID(ctx, 1)
if err != nil {
log.Fatal(err)
}
// 使用配置...
_ = cfg
}
核心概念
配置与状态分离
租户模块遵循 CQRS 原则,实现清晰的配置与状态分离:
┌─────────────────────────────────────────────────┐
│ ETCD(由 tenant-service 管理) │
│ - 数据库配置 │
│ - FTP 配置 │
│ - 存储配置 │
│ - 元数据: code, name, status │
└─────────────────────────────────────────────────┘
↓ Watch
┌─────────────────────────────────────────────────┐
│ jxt-core Provider(内存缓存) │
│ - Copy-on-Write 无锁读取 │
│ - 指数退避自动重连 │
│ - 持久化文件缓存降级 │
└─────────────────────────────────────────────────┘
↓ Cache API
┌─────────────────────────────────────────────────┐
│ 微服务(file-storage, evidence 等) │
│ - database.Cache → DatabaseConfig │
│ - ftp.Cache → FtpConfig │
│ - storage.Cache → StorageConfig │
└─────────────────────────────────────────────────┘
租户数据流
- 配置写入:tenant-service → ETCD
- 配置分发:ETCD Watch → Provider → 微服务
- 本地降级:ETCD 不可用 → 本地缓存文件 → 内存
架构设计
分层架构
┌─────────────────────────────────────────────────────┐
│ 中间件层 │
│ - ExtractTenantID(4 种解析方式) │
│ * host: 子域名提取 │
│ * header: 自定义 Header(默认: X-Tenant-ID) │
│ * query: 查询参数(默认: tenant) │
│ * path: URL 路径片段索引 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 缓存层(按需加载) │
│ - database.Cache → DatabaseConfig │
│ - ftp.Cache → FtpConfig │
│ - storage.Cache → StorageConfig │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Provider 层(核心) │
│ - ETCD Watch + 指数退避重连 │
│ - Copy-on-Write 无锁读取 │
│ - 持久化文件缓存降级 │
│ - 初始化重试机制 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 持久化层 │
│ - ETCD(配置源) │
│ - FileCache(本地降级) │
└─────────────────────────────────────────────────────┘
并发安全性
| 操作 | 机制 | 延迟 |
|---|---|---|
| 读取配置 | atomic.Value |
~25ns |
| 更新配置 | Copy-on-Write | ~1μs |
| Watch 重连 | 指数退避 1s→30s | - |
API 参考
Provider 配置选项
// 命名空间配置
WithNamespace(ns string) Option
// 配置类型过滤(按需加载)
WithConfigTypes(types ...ConfigType) Option
// 本地缓存降级
WithCache(cache FileCache) Option
中间件配置选项
// ========== 统一配置(推荐) ==========
// 从 Provider 自动读取全部配置(httpType、httpHostMode 等)
// 这是推荐的方式,所有配置由 ETCD 统一管理
WithProviderConfig(provider ProviderConfigurer) Option
// ========== 解析类型配置 ==========
// 设置解析类型:host/header/query/path
WithResolverType(resolverType string) Option
// Header 模式:设置 Header 名称(默认: X-Tenant-ID)
WithHeaderName(name string) Option
// Query 模式:设置查询参数名(默认: tenant)
WithQueryParam(param string) Option
// Path 模式:设置路径索引(默认: 0)
WithPathIndex(index int) Option
// ========== 行为配置 ==========
// 设置租户缺失时的行为
// - "Abort"(默认):返回 400 错误,中断请求
// - "Continue":继续执行,租户 ID 为 0
WithOnMissingTenant(mode string) Option
核心方法
| 方法 | 描述 | 返回值 |
|---|---|---|
NewProvider(client, opts...) |
创建基础 Provider | *Provider |
NewProviderWithRetry(client, opts...) |
创建并初始化(带重试) | *Provider, error |
LoadAll(ctx) |
从 ETCD 加载所有配置 | error |
StartWatch(ctx) |
启动 ETCD 监听 | error |
StartWatchWithRetry(ctx) |
启动监听(带重试) | error |
StopWatch() |
停止监听 | - |
GetDatabaseConfig(id) |
获取数据库配置 | *DatabaseConfig, bool |
GetFtpConfig(id) |
获取 FTP 配置 | *FtpConfig, bool |
GetStorageConfig(id) |
获取存储配置 | *StorageConfig, bool |
GetTenantMeta(id) |
获取租户元数据 | *TenantMeta, bool |
IsTenantEnabled(id) |
检查租户是否激活 | bool |
NewFileCache() |
创建默认文件缓存 | FileCache |
NewFileCacheWithPath(path) |
创建指定路径的文件缓存 | FileCache |
GetResolverConfig() |
获取租户识别配置 | *ResolverConfig |
GetTenantIDByDomain(domain) |
通过域名查找租户 ID | int, bool |
GetTenantIDByCode(code) |
通过租户代码查找租户 ID | int, bool |
中间件辅助函数
| 函数 | 描述 | 返回值 |
|---|---|---|
GetTenantID(c) |
从 Gin Context 获取租户 ID | int(未找到返回 0) |
GetTenantIDAsInt(c) |
从 Gin Context 获取租户 ID(带错误) | int, error |
MustGetTenantID(c) |
获取租户 ID,不存在则 panic | int |
Cache API
database.Cache
func NewCache(prov *provider.Provider) *Cache
func (c *Cache) GetByID(ctx context.Context, tenantID int) (*TenantDatabaseConfig, error)
// 注意: GetByCode(ctx, code string) 计划在未来版本中实现
ftp.Cache
func NewCache(prov *provider.Provider) *Cache
func (c *Cache) GetByID(ctx context.Context, tenantID int) (*TenantFtpConfig, error)
storage.Cache
func NewCache(prov *provider.Provider) *Cache
func (c *Cache) GetByID(ctx context.Context, tenantID int) (*TenantStorageConfig, error)
使用示例
场景 1:数据库连接
dbCache := database.NewCache(prov)
cfg, err := dbCache.GetByID(ctx, tenantID)
if err != nil {
return err
}
// 创建 GORM 数据库连接
gormDB, err := gorm.Open(mysql.Open(fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True",
cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.DbName,
)), &gorm.Config{
MaxIdleConns: cfg.MaxIdleConns,
MaxOpenConns: cfg.MaxOpenConns,
})
场景 2:配额检查
storageCache := storage.NewCache(prov)
cfg, _ := storageCache.GetByID(ctx, tenantID)
if file.Size > cfg.MaxFileSizeBytes {
return errors.New("文件超过大小限制")
}
// 检查并发上传
if activeUploads >= cfg.MaxConcurrentUploads {
return errors.New("已达到最大并发上传数")
}
场景 3:租户状态验证
if !prov.IsTenantEnabled(tenantID) {
return errors.New("租户未激活")
}
场景 4:中间件集成
租户模块包含一个 Gin 中间件,用于从 HTTP 请求中提取租户 ID。支持四种解析方式(httpType):
| httpType | 说明 | 示例 |
|---|---|---|
header |
从 HTTP Header 提取(默认) | X-Tenant-ID: 123 |
query |
从 URL 查询参数提取 | ?tenant_id=123 |
path |
从 URL 路径片段提取 | /123/users |
host |
从域名/子域名提取 | 123.example.com |
基本用法(无 Provider)
import "github.com/ChenBigdata421/jxt-core/sdk/pkg/tenant/middleware"
// 默认:从 X-Tenant-ID header 提取
router.Use(middleware.ExtractTenantID())
// 自定义 header 名称
router.Use(middleware.ExtractTenantID(
middleware.WithHeaderName("X-Tenant-Id"),
))
// 查询参数提取
router.Use(middleware.ExtractTenantID(
middleware.WithResolverType("query"),
middleware.WithQueryParam("tenant_id"),
))
// 基于路径提取(提取第一个片段)
router.Use(middleware.ExtractTenantID(
middleware.WithResolverType("path"),
middleware.WithPathIndex(0),
))
// 基于域名提取(仅数字子域名,无 Provider)
router.Use(middleware.ExtractTenantID(
middleware.WithResolverType("host"),
))
集成 Provider(推荐)
当传入 WithProviderConfig(provider) 时,中间件会自动从 ETCD 读取全部配置(httpType、httpHeaderName、httpQueryParam、httpPathIndex、httpHostMode),无需硬编码:
// 推荐:从 ETCD 自动读取全部配置
router.Use(middleware.ExtractTenantID(
middleware.WithProviderConfig(provider),
))
ProviderConfigurer 接口:
WithProviderConfig 需要传入实现了 ProviderConfigurer 接口的对象。该接口组合了三个子接口:
type ProviderConfigurer interface {
DomainLookuper // GetTenantIDByDomain(domain string) (int, bool)
CodeLookuper // GetTenantIDByCode(code string) (int, bool)
ResolverConfigProvider // GetResolverConfig() *ResolverConfig
}
provider.Provider 已隐式实现该接口(鸭子类型),可直接使用。
host 模式的三种子模式(httpHostMode)
当 httpType = "host" 时,支持三种互斥的子模式:
| httpHostMode | 说明 | 示例 | 需要 Provider |
|---|---|---|---|
numeric(默认) |
仅数字子域名 | 123.example.com → 123 |
否 |
domain |
精确域名匹配 | tenant1.example.com → 查域名索引 |
是 |
code |
租户代码匹配 | acmecorp.example.com → 查代码索引 |
是 |
配置位置:ETCD common/resolver 路径,由 tenant-service 管理
{
"httpType": "host",
"httpHostMode": "code"
}
在处理器中获取租户 ID
func handler(c *gin.Context) {
// 获取租户 ID(返回 int,未找到返回 0)
tenantID := middleware.GetTenantID(c)
// 或带错误返回
tenantIDInt, err := middleware.GetTenantIDAsInt(c)
if err != nil {
// 处理错误
}
// 或直接 panic(适用于必须要有租户的场景)
tenantID := middleware.MustGetTenantID(c)
}
场景 5:服务级数据库配置与密码解密
从 jxt-core Provider 获取的 ServiceDatabaseConfig 包含加密后的数据库密码。要使用密码建立连接,需要先解密:
import (
"fmt"
"log"
"os"
"github.com/ChenBigdata421/jxt-core/sdk/pkg/tenant/provider"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func ConnectToServiceDatabase(prov *provider.Provider, tenantID int, serviceCode string) (*gorm.DB, error) {
// 从环境变量获取加密密钥
encryptionKey := os.Getenv("ENCRYPTION_KEY")
if encryptionKey == "" {
return nil, fmt.Errorf("ENCRYPTION_KEY environment variable not set")
}
// 获取服务级数据库配置
config, ok := provider.GetServiceDatabaseConfig(tenantID, serviceCode)
if !ok {
return nil, fmt.Errorf("database config not found for tenant %d, service %s", tenantID, serviceCode)
}
// 检查是否有加密密码
if !config.HasEncryptedPassword() {
return nil, fmt.Errorf("no encrypted password in config")
}
// 解密密码
password, err := config.DecryptPassword(encryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
}
// 构建数据库连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.Username, password, config.Host, config.Port, config.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
return db, nil
}
重要提示:
- 加密密钥(
ENCRYPTION_KEY)必须是 32 字节长度 - 所有服务必须使用相同的加密密钥
- 密钥应该通过安全的方式管理(如 Kubernetes Secrets、环境变量等)
- 有关服务级数据库配置的更多详细信息,请参考 数据库配置文档
配置说明
环境变量
| 变量 | 默认值 | 描述 |
|---|---|---|
TENANT_CACHE_PATH |
./cache/ |
本地缓存文件目录 |
ETCD_ENDPOINTS |
localhost:2379 |
ETCD 服务地址 |
TENANT_NAMESPACE |
jxt/ |
ETCD 命名空间前缀 |
ETCD 数据结构
jxt/tenants/{id}/meta → {"id":1,"code":"tenant1","name":"租户1","status":"active"}
jxt/tenants/{id}/database → {"host":"localhost","port":3306,...}
jxt/tenants/{id}/ftp → {"username":"ftp1","passwordHash":"...",...}
jxt/tenants/{id}/storage → {"uploadQuotaGb":100,"maxFileSizeMb":500,...}
可靠性特性
1. 自动重连
- 指数退避:1s → 30s(最大)
- 重置条件:成功处理事件后退避重置为 1s
- 所有重连事件:记录日志供监控
2. 初始化重试
| 操作 | 重试次数 | 间隔 | 总耗时 |
|---|---|---|---|
| LoadAll | 5 | 1s→2s→4s→8s→16s | ~31s |
| StartWatch | 3 | 1s→2s→5s | ~8s |
3. 本地缓存降级
ETCD 可用: ETCD Watch → 内存 → 本地文件(同步)
ETCD 不可用: 本地文件 → 内存(只读模式)
4. 优雅关闭
defer prov.StopWatch() // 停止监听,刷新缓存
故障排查
问题 1:ETCD 连接失败
症状:failed to load tenant data after retries
排查步骤:
# 检查 ETCD 服务
curl http://localhost:2379/health
# 检查租户数据
etcdctl get "" --prefix --keys-only
解决方案:
- 启用本地缓存降级(
WithCache()) - 检查网络连接
- 查看重连日志
问题 2:配置未更新
症状:tenant-service 已更新,但微服务未感知
排查步骤:
// 检查 watch 是否运行
if !prov.running.Load() {
log.Error("Provider 未运行")
}
解决方案:
- 确认
StartWatch()成功 - 检查 ETCD 事件传递
- 查看重连日志
问题 3:本地缓存文件损坏
症状:invalid cache file format
解决方案:
// 删除缓存文件,下次启动会重新生成
os.Remove("./cache/tenant_metadata.json")
扩展指南
添加新的配置类型
- 定义 ConfigType:
const ConfigTypeCustom ConfigType = "custom"
- 定义配置模型:
type CustomConfig struct {
TenantID int `json:"tenantId"`
// ...
}
- 扩展 processKey/handleDeleteKey:
case "custom":
p.processCustomKey(tenantID, parts, value, data)
- 创建 Cache:
// cache/custom/cache.go
type Cache struct { provider *provider.Provider }
func (c *Cache) GetByID(ctx, tenantID int) (*CustomConfig, error) {
// ...
}
性能指标
| 指标 | 数值 | 描述 |
|---|---|---|
| 读取延迟 | ~25ns | atomic.Value 无锁读取 |
| 更新延迟 | ~1μs | Copy-on-Write |
| 内存占用 | ~1KB/租户 | 包含所有配置类型 |
| Watch 同步 | <1s | ETCD 推送延迟 |
相关文档
许可证
Copyright © 2026 JXT-Evidence-System
Directories
¶
| Path | Synopsis |
|---|---|
|
Package cache provides persistent file-based caching for tenant configuration.
|
Package cache provides persistent file-based caching for tenant configuration. |
|
Package middleware provides tenant ID extraction middleware for Gin framework.
|
Package middleware provides tenant ID extraction middleware for Gin framework. |
Click to show internal directories.
Click to hide internal directories.