Files
gochat/docs/PHASE2_P0_ENV_CONFIG.md
T
2026-06-04 15:44:48 +08:00

522 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GoChat 阶段二 P0-6:环境配置管理改进方案
## 一、现状分析
### 1.1 配置文件清单
| 文件 | 位置 | 说明 |
|------|------|------|
| config.go | `internal/config/config.go` | 配置结构体定义 + Load() 函数 |
| validator.go | `internal/config/validator.go` | 配置校验逻辑 |
| config_test.go | `internal/config/config_test.go` | 配置校验测试 |
| config.yaml | `configs/config.yaml` | 主配置文件(开发默认) |
| config.dev.yaml | `configs/config.dev.yaml` | 开发环境覆盖 |
| config.prod.yaml | `configs/config.prod.yaml` | 生产环境覆盖 |
| .env.example | `.env.example` | 114行,环境变量模板 |
| prometheus_alerts.yml | `configs/prometheus_alerts.yml` | 监控告警规则 |
| fluentd.conf | `configs/fluentd.conf` | 日志收集配置 |
| redis.conf | `configs/redis.conf` | Redis 服务配置 |
### 1.2 当前 Viper 使用方式
`Load()` 函数(`config.go:210-255`)的配置加载流程:
```
1. viper.SetConfigName("config") → 固定文件名 "config"
2. viper.SetConfigType("yaml") → YAML格式
3. viper.AddConfigPath("./configs") → 搜索路径1
4. viper.AddConfigPath("./") → 搜索路径2
5. viper.AddConfigPath("/etc/gochat/") → 搜索路径3
6. viper.SetEnvPrefix("GOCHAT") → 环境变量前缀 GOCHAT_
7. viper.AutomaticEnv() → 自动映射环境变量
8. viper.SetDefault(...) → 设置默认值(rate_limit + saml
9. viper.ReadInConfig() → 读取配置文件
10. viper.Unmarshal(&cfg) → 反序列化到结构体
11. 手动补零值默认 → RateLimit零值兜底
```
### 1.3 配置结构体(13个)
```go
Config {
Server ServerConfig (host, port, mode, cors)
Database DatabaseConfig (host, port, user, password, dbname/Name, sslmode, 连接池参数, migration)
Redis RedisConfig (host, port, password, db, url)
JWT JWTConfig (secret, expiry, refresh, access, audience, issuer)
Log LogConfig (level, format)
Captain CaptainConfig (enabled, llm_provider, llm_model, llm_api_key, llm_base_url, embedding, max_tokens, temperature)
Worker WorkerConfig (concurrency)
OAuth OAuthConfig (google, github OAuthProviderConfig)
RateLimit RateLimitConfig (enabled, requests_per_minute, window_seconds)
SAML SAMLConfig (enabled, idp_metadata_url, idp_metadata_xml, sp_entity_id, acs_url, cert/key paths, attribute_map, clock_drift_tolerance)
}
+ LLMConfig从CaptainConfig派生
```
### 1.4 多环境配置现状
已有 `config.dev.yaml``config.prod.yaml`,但 **未被 Load() 函数使用**Load 只读取单一的 `config.yaml`,没有环境合并逻辑。
docker-compose 文件中设置 `GOCHAT_ENV=development/production/test`,但这个变量仅用于 Docker 容器标识,**不被 Viper 用于选择环境配置文件**。
Helm values 中有 `values-staging.yaml`,设置了 `GOCHAT_ENV: "staging"`,但同样只是 ConfigMap 中的标识变量。
### 1.5 环境变量命名问题
**重大缺陷**`.env.example` 中存在命名不一致:
| .env.example 变量 | Viper期望的变量名(GOCHAT_前缀+自动映射) | 问题 |
|---|---|---|
| `POSTGRES_HOST` | `GOCHAT_DATABASE_HOST` | ❌ 前缀不一致 |
| `POSTGRES_PORT` | `GOCHAT_DATABASE_PORT` | ❌ 前缀不一致 |
| `POSTGRES_USER` | `GOCHAT_DATABASE_USER` | ❌ 前缀不一致 |
| `POSTGRES_PASSWORD` | `GOCHAT_DATABASE_PASSWORD` | ❌ 前缀不一致 |
| `POSTGRES_DB` | `GOCHAT_DATABASE_DBNAME` | ❌ 前缀+键名不一致 |
| `POSTGRES_SSLMODE` | `GOCHAT_DATABASE_SSLMODE` | ❌ 前缀不一致 |
| `REDIS_HOST` | `GOCHAT_REDIS_HOST` | ❌ 前缀不一致 |
| `REDIS_PORT` | `GOCHAT_REDIS_PORT` | ❌ 前缀不一致 |
| `REDIS_PASSWORD` | `GOCHAT_REDIS_PASSWORD` | ❌ 前缀不一致 |
| `REDIS_DB` | `GOCHAT_REDIS_DB` | ❌ 前缀不一致 |
| `SMTP_*` | `GOCHAT_SMTP_*`SMTP未在Config结构体中) | ❌ 缺少SMTP配置 |
| `FRONTEND_URL` | `GOCHAT_FRONTEND_URL`(未在Config结构体中) | ❌ 缺少Frontend配置 |
| `OPENAI_API_KEY` | `GOCHAT_CAPTAIN_LLM_API_KEY` | ❌ 前缀不一致 |
| `GOCHAT_METRICS_*` | `GOCHAT_METRICS_*`Metrics未在Config结构体中) | ❌ 缺少Metrics配置 |
| `GOCHAT_WS_*` | `GOCHAT_WS_*`WebSocket未在Config结构体中) | ❌ 缺少WebSocket配置 |
| `GOCHAT_ANALYTICS_*` | `GOCHAT_ANALYTICS_*`Analytics未在Config结构体中) | ❌ 缺少Analytics配置 |
| `GOCHAT_FEATURE_*` | `GOCHAT_FEATURE_*`FeatureFlags未在Config结构体中) | ❌ 缺少FeatureFlags配置 |
**核心问题**`viper.AutomaticEnv()` + `viper.SetEnvPrefix("GOCHAT")` 只能自动映射 `GOCHAT_` 前缀的环境变量到嵌套键。没有 `EnvKeyReplacer` 设置(`.``_` 的映射),也没有 `BindEnv()` 显式绑定非标准命名的变量。所以大量 `POSTGRES_*``REDIS_*``OPENAI_*` 等环境变量**实际上不会被 Viper 读取**!
### 1.6 其他问题汇总
| 问题 | 说明 |
|------|------|
| 无环境选择逻辑 | config.dev.yaml/config.prod.yaml 存在但未被使用 |
| 无配置热加载 | 没有 WatchConfig/OnConfigChange |
| 无 .env 文件加载 | Viper 不自动读取 .env 文件,需 godotenv 或手动处理 |
| 环境变量映射断裂 | POSTGRES_*/REDIS_* 等前缀与 GOCHAT_ 不匹配,AutomaticEnv无法映射 |
| 无 EnvKeyReplacer | 未设置 viper.RegisterAlias 或 EnvKeyReplacer 来处理嵌套键 |
| 缺少多个配置结构体 | SMTP/Frontend/Metrics/WebSocket/Analytics/FeatureFlags/SMTP 在.env中但不在Config结构体中 |
| DatabaseConfig 双字段 | Name 和 DBName 同时存在,语义混乱 |
| 无配置版本号 | 配置文件没有版本标记,难以追踪兼容性变化 |
| 无 staging 环境文件 | 只有 dev/prod,缺少 config.staging.yaml |
| 配置默认值分散 | 部分在 viper.SetDefault,部分在 YAML,部分在代码中硬编码兜底 |
---
## 二、改进方案
### 2.1 配置加载优先级(从低到高)
```
优先级(低→高,后者覆盖前者):
1. 代码默认值(viper.SetDefault
2. 基础配置文件(configs/config.yaml
3. 环境配置文件(configs/config.{env}.yaml
4. 本地覆盖文件(configs/config.local.yaml,不提交到git
5. 环境变量(GOCHAT_ 前缀)
6. 命令行参数(--server.port 等,可选)
```
### 2.2 多环境配置方案
#### 环境选择逻辑
通过 `GOCHAT_ENV` 环境变量选择环境(默认 `development`):
```
GOCHAT_ENV=development → 加载 config.yaml + config.dev.yaml
GOCHAT_ENV=staging → 加载 config.yaml + config.staging.yaml
GOCHAT_ENV=production → 加载 config.yaml + config.prod.yaml
GOCHAT_ENV=test → 加载 config.yaml(无环境覆盖,测试用单独配置)
```
#### configs/ 目录结构
```
configs/
├── config.yaml # 基础配置(所有环境共享)
├── config.dev.yaml # 开发环境覆盖(已存在,需完善)
├── config.staging.yaml # 预发布环境覆盖(新增)
├── config.prod.yaml # 生产环境覆盖(已存在,需完善)
├── config.local.yaml # 本地开发覆盖(新增,加入.gitignore)
├── config.test.yaml # 测试环境覆盖(新增)
├── prometheus_alerts.yml # 监控告警(保持不变)
├── fluentd.conf # 日志收集(保持不变)
└── redis.conf # Redis配置(保持不变)
```
#### Load() 函数改进伪代码
```go
func Load() (*Config, error) {
// 1. 设置基础配置
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
viper.AddConfigPath("./")
viper.AddConfigPath("/etc/gochat/")
// 2. 环境变量映射
viper.SetEnvPrefix("GOCHAT")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 3. 显式绑定非标准环境变量(兼容旧命名)
bindLegacyEnvVars() // POSTGRES_* → database.*, REDIS_* → redis.*
// 4. 设置默认值(集中管理)
setDefaults()
// 5. 读取基础配置
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("read base config: %w", err)
}
// 6. 合理环境配置
env := viper.GetString("env") // 从 GOCHAT_ENV 或 config.yaml 读取
if env == "" {
env = "development"
}
envFile := fmt.Sprintf("config.%s", env)
viper.SetConfigName(envFile)
if err := viper.MergeInConfig(); err != nil {
// 环境配置可选,不存在时不报错
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("merge env config: %w", err)
}
}
// 7. 合并本地覆盖(可选,开发用)
viper.SetConfigName("config.local")
if err := viper.MergeInConfig(); err != nil {
// local配置可选,忽略任何错误
}
// 8. 反序列化
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
// 9. 设置运行时环境标识
cfg.Env = env
// 10. 校验
if err := Validate(&cfg); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
```
### 2.3 配置热加载方案
#### 设计思路
Go 服务通常不需要像 Rails 那样频繁热加载,但以下场景有价值:
- 开发环境:修改日志级别、限流参数后无需重启
- 生产环境:调整 RateLimit、Worker并发数、Captain开关等
#### 实现方案
```go
// ConfigWatcher 提供配置热加载能力
type ConfigWatcher struct {
cfg *Config
mu sync.RWMutex
onChange func(oldCfg, newCfg *Config)
}
// WatchConfig 启动配置文件监控
func WatchConfig(initial *Config, onChange func(old, new *Config)) (*ConfigWatcher, error) {
watcher := &ConfigWatcher{
cfg: initial,
onChange: onChange,
}
viper.OnConfigChange(func(e fsnotify.Event) {
newCfg, err := Reload()
if err != nil {
log.Printf("config reload failed: %v", err)
return
}
oldCfg := watcher.Get()
watcher.mu.Lock()
watcher.cfg = newCfg
watcher.mu.Unlock()
if watcher.onChange != nil {
watcher.onChange(oldCfg, newCfg)
}
})
viper.WatchConfig()
return watcher, nil
}
// Get 安全获取当前配置(RLock保护)
func (w *ConfigWatcher) Get() *Config {
w.mu.RLock()
defer w.mu.RUnlock()
return w.cfg
}
// Reload 重新加载配置(不启动watch)
func Reload() (*Config, error) {
return Load() // 重新走完整的加载流程
}
```
#### 安全的配置引用方式
需要修改各模块从 `cfg *Config`(一次性指针)改为 `cfgProvider func() *Config`(动态获取):
```go
// 在 server/router 等模块中使用
type ConfigProvider func() *Config
// 初始化时:
// router.Setup(cfgWatcher.Get) // 传入 getter 函数
// 而不是 router.Setup(cfg) // 传入静态指针
```
#### 热加载安全策略
| 配置项 | 可热加载 | 需重启 | 说明 |
|--------|---------|--------|------|
| log.level/format | ✅ | | 日志级别即时生效 |
| rate_limit.* | ✅ | | 限流参数即时生效 |
| worker.concurrency | ✅ | | Worker池大小动态调整 |
| captain.enabled | ✅ | | AI功能开关 |
| server.port | ❌ | ✅ | 端口变更需重启 |
| database.* | ❌ | ✅ | 数据库连接变更需重启 |
| redis.url | ❌ | ✅ | Redis连接变更需重启 |
| jwt.secret | ❌ | ✅ | JWT密钥变更需重启(安全) |
| saml.* | ❌ | ✅ | SAML配置变更需重启 |
### 2.4 环境变量映射修复
#### 方案A:显式 BindEnv(推荐)
为所有非标准命名环境变量显式绑定:
```go
func bindLegacyEnvVars() {
// Database - 兼容 POSTGRES_* 前缀
viper.BindEnv("database.host", "POSTGRES_HOST", "GOCHAT_DATABASE_HOST")
viper.BindEnv("database.port", "POSTGRES_PORT", "GOCHAT_DATABASE_PORT")
viper.BindEnv("database.user", "POSTGRES_USER", "GOCHAT_DATABASE_USER")
viper.BindEnv("database.password", "POSTGRES_PASSWORD", "GOCHAT_DATABASE_PASSWORD")
viper.BindEnv("database.dbname", "POSTGRES_DB", "GOCHAT_DATABASE_DBNAME")
viper.BindEnv("database.sslmode", "POSTGRES_SSLMODE", "GOCHAT_DATABASE_SSLMODE")
// Redis - 兼容 REDIS_* 前缀
viper.BindEnv("redis.host", "REDIS_HOST", "GOCHAT_REDIS_HOST")
viper.BindEnv("redis.port", "REDIS_PORT", "GOCHAT_REDIS_PORT")
viper.BindEnv("redis.password", "REDIS_PASSWORD", "GOCHAT_REDIS_PASSWORD")
viper.BindEnv("redis.db", "REDIS_DB", "GOCHAT_REDIS_DB")
// JWT - 兼容非标准命名
viper.BindEnv("jwt.secret", "GOCHAT_JWT_SECRET", "JWT_SECRET")
viper.BindEnv("jwt.expiry_hours", "GOCHAT_JWT_EXPIRY_HOURS")
// Captain - 兼容 OPENAI_* 遗留命名
viper.BindEnv("captain.llm_api_key", "GOCHAT_CAPTAIN_LLM_API_KEY", "OPENAI_API_KEY")
viper.BindEnv("captain.llm_model", "GOCHAT_CAPTAIN_LLM_MODEL", "OPENAI_MODEL")
}
```
#### 方案BEnvKeyReplacer(辅助)
```go
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 使 GOCHAT_SERVER_HOST → server.host 自动映射生效
```
### 2.5 新增配置结构体
当前 `.env.example` 中有多个配置域未纳入 Config 结构体,需要补充:
```go
type Config struct {
Env string `mapstructure:"env"` // 新增:当前运行环境标识
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
Captain CaptainConfig `mapstructure:"captain"`
Worker WorkerConfig `mapstructure:"worker"`
OAuth OAuthConfig `mapstructure:"oauth"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
SAML SAMLConfig `mapstructure:"saml"`
SMTP SMTPConfig `mapstructure:"smtp"` // 新增
Frontend FrontendConfig `mapstructure:"frontend"` // 新增
Metrics MetricsConfig `mapstructure:"metrics"` // 新增
WebSocket WebSocketConfig `mapstructure:"websocket"` // 新增
Analytics AnalyticsConfig `mapstructure:"analytics"` // 新增
Feature FeatureConfig `mapstructure:"feature"` // 新增
}
// 新增结构体定义
type SMTPConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
FromEmail string `mapstructure:"from_email"`
}
type FrontendConfig struct {
URL string `mapstructure:"url"`
}
type MetricsConfig struct {
Enabled bool `mapstructure:"enabled"`
Port int `mapstructure:"port"`
}
type WebSocketConfig struct {
MaxMessageSize int `mapstructure:"max_message_size"`
ReadBufferSize int `mapstructure:"read_buffer_size"`
WriteBufferSize int `mapstructure:"write_buffer_size"`
PingInterval int `mapstructure:"ping_interval"`
PongTimeout int `mapstructure:"pong_timeout"`
MaxSubscriptions int `mapstructure:"max_subscriptions"`
}
type AnalyticsConfig struct {
Enabled bool `mapstructure:"enabled"`
RetentionDays int `mapstructure:"retention_days"`
FlushInterval int `mapstructure:"flush_interval"`
}
type FeatureConfig struct {
CaptainAI bool `mapstructure:"captain_ai"`
AutoAssignment bool `mapstructure:"auto_assignment"`
CSAT bool `mapstructure:"csat"`
Campaigns bool `mapstructure:"campaigns"`
MFA bool `mapstructure:"mfa"`
}
```
### 2.6 DatabaseConfig 清理
`Name``DBName` 双字段需要统一:
```go
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"` // 统一用 dbname
SSLMode string `mapstructure:"sslmode"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
RunMigrations bool `mapstructure:"run_migrations"`
MigrationsPath string `mapstructure:"migrations_path"`
LogLevel string `mapstructure:"log_level"` // 新增:数据库日志级别
}
// Name 字段废弃 → DSN() 和 MigrateDSN() 只使用 DBName
```
### 2.7 配置版本标记
在 config.yaml 中加入版本号:
```yaml
# config.yaml
version: "2.0" # 配置格式版本,用于兼容性检查
server:
host: "0.0.0.0"
port: 3000
...
```
Load() 中加入版本检查:
```go
cfgVersion := viper.GetString("version")
if cfgVersion != "" && cfgVersion != "2.0" {
return nil, fmt.Errorf("unsupported config version: %s (expected 2.0)", cfgVersion)
}
```
### 2.8 Redis URL 构建优化
当前 RedisConfig 有 Host/Port/Password/DB 和 URL 双模式,建议增加 `BuildURL()` 方法:
```go
func (r RedisConfig) BuildURL() string {
if r.URL != "" {
return r.URL
}
auth := ""
if r.Password != "" {
auth = fmt.Sprintf(":%s@", r.Password)
}
return fmt.Sprintf("redis://%slocalhost:%d/%d", auth, r.Port, r.DB)
}
```
---
## 三、实施计划
### 阶段一:核心改进(1天)
1. **修改 Load() 函数**:加入环境选择逻辑、MergeInConfig 环境合并
2. **加入 EnvKeyReplacer**`strings.NewReplacer(".", "_")`
3. **加入 BindEnv**:显式绑定所有非标准环境变量
4. **创建 config.staging.yaml 和 config.test.yaml**
5. **Config 结构体新增 Env 字段**
### 阶段二:配置补全(0.5天)
1. **新增配置结构体**SMTP/Frontend/Metrics/WebSocket/Analytics/Feature
2. **清理 DatabaseConfig**:统一 DBName,废弃 Name
3. **更新 config.yaml**:加入 version 标记 + 新配置域
4. **更新 config.dev.yaml / config.prod.yaml**:补全新配置域覆盖
### 阶段三:热加载(0.5天)
1. **实现 ConfigWatcher**WatchConfig + OnConfigChange + sync.RWMutex
2. **引入 ConfigProvider 模式**:关键模块使用 `func() *Config` 获取动态配置
3. **热加载安全策略**:标记哪些配置可热加载,哪些需重启
### 阶段四:配置本地覆盖(0.5天)
1. **创建 config.local.yaml 模板**
2. **更新 .gitignore**:加入 config.local.yaml
3. **更新 validator.go**:新增配置域校验
4. **更新 config_test.go**:新增加载逻辑测试
### 总预估工时:2.5天(含测试)
---
## 四、风险与注意事项
| 风险 | 对策 |
|------|------|
| MergeInConfig 环境文件不存在时报错 | 检查 ConfigFileNotFoundError,容错处理 |
| 配置热加载与数据库连接池冲突 | 不可热加载的配置项标记为 "需重启",onChange 回调中只更新安全配置 |
| 环境变量命名兼容性 | BindEnv 支持多别名,同时支持旧命名(POSTGRES_*)和新命名(GOCHAT_DATABASE_* |
| config.local.yaml 误提交 | 加入 .gitignore,并在 Makefile 中添加 check-git-clean 目标 |
| DatabaseConfig Name→DBName 迁移 | DSN() 方法中保留 Name 兜底兼容(`if dbname == "" { dbname = d.Name }` |
| viper.Unmarshal 对零值字段不设置默认 | 继续使用 viper.SetDefault + 代码兜底的双重策略 |
---
## 五、参考
- Chatwoot 配置管理:`config/environments/{development,staging,production}.rb` + `.env` 文件
- Viper 文档:https://github.com/spf13/viper#working-with-environment-variables
- fsnotifyhttps://github.com/fsnotify/fsnotifyViper WatchConfig 底层依赖)
- 12-Factor Apphttps://12factor.net/config(环境变量优先原则)