Skip to content

refactor(config): SOLID + DRY — config.go 1149-line god file, platform env duplication, BindEnv redundancy, dual propagation path #338

@aaronwong1989

Description

@aaronwong1989

Background

internal/config/ 包含 5 个源文件(~1,716 行),负责 Config 层级定义(22 个 struct)、5 层加载管线(defaults → inheritance → file → env → secrets)、Viper 集成、热重载文件监听(Watcher + 审计 + 回滚)和原子 ConfigStore(观察者模式)。已有 issue 253 跟踪 HotReloadableFields 可变性。本次为 首次从 SOLID + DRY + coupling 角度分析。

Scope: solid, dry, coupling — cycle 5 (模块分析通过 1)
Key files: config.go, watcher.go, store.go


Finding Summary

Category Critical High Medium Low
SOLID/SRP 0 1 1 0
DRY 0 2 1 0
Coupling 0 0 1 0
合计 0 3 3 0

Findings

SOLID: SRP

config-go-six-responsibilities

Severity: High | Confidence: High | ROI: High
Location: config.go (1,149 lines)

Problem: 单文件混合 6+ 职责:(1) 22 个 struct 类型定义 (340 行), (2) 默认值构造 (134 行), (3) 5 层加载管线 (190 行), (4) 反射 env-var 映射 (139 行), (5) 验证逻辑, (6) 工具函数。新增任何配置领域(如新平台、新 worker 类型)都需要修改此文件。

Current Pattern:

// config.go — everything in one file:
type Config struct { ... }           // line 149
type SlackConfig struct { ... }      // line 293
func Default() *Config { ... }       // line 498 (126 lines)
func Load(path string, opts ...LoadOptions) (*Config, error) { ... }  // line 677 (79 lines)
func loadRecursive(...) { ... }      // line 760 (106 lines)
func applyMessagingEnv(...) { ... }  // line 978 (94 lines)

Proposed Fix: 按职责拆分:

config_types.go     — 22 struct 定义 (340 行)
config_defaults.go  — Default() + 子配置默认值 (134 行)
config_loader.go    — Load() + loadRecursive() + path normalization (190 行)
config_env.go       — applyMessagingEnv() + applyPlatformEnv() + env helpers (139 行)
config_validate.go  — Validate() + validation helpers (46 行)
config.go           — 公共入口 + 工具函数 (剩余)

Estimated Impact: 每个文件 <250 行,单一职责,新增配置只改对应文件

Acceptance Criteria:

  • config.go 拆分为 5-6 个文件,每个 <300 行
  • make test 零回归
  • make lint 零新增警告

DRY

applymessagingenv-per-platform-duplication

Severity: High | Confidence: High | ROI: High
Location: config.go:978-1072

Problem: applyMessagingEnv() 中 Slack 和 Feishu 各有 ~40 行几乎相同的 env-var 映射块,仅 env 前缀和字段名不同。新增任何消息平台都需要复制 ~40 行样板代码。

Current Pattern:

// config.go:980-998 (Slack) vs 1001-1019 (Feishu) — nearly identical
applyPlatformEnv(&cfg.Messaging.Slack,
    []envMapping{
        {"HOTPLEX_MESSAGING_SLACK_BOT_TOKEN", "BotToken"},
        {"HOTPLEX_MESSAGING_SLACK_APP_TOKEN", "AppToken"},
        // ... 6 more mappings
    },
    []envMapping{{"HOTPLEX_MESSAGING_SLACK_ENABLED", "Enabled"}},
    []envMapping{{"HOTPLEX_MESSAGING_SLACK_ALLOW_FROM", "AllowFrom"}},
)
// Feishu block: same structure, different prefix + field names

Proposed Fix: 将平台 env 映射注册到平台配置中:

type platformEnvSpec struct {
    config   any // pointer to platform config struct
    prefix   string
    secrets  []envMapping
    bools    []envMapping
    strings  []envMapping
    numbers  []envMapping
}

var platformEnvSpecs = []platformEnvSpec{
    {&cfg.Messaging.Slack, "HOTPLEX_MESSAGING_SLACK_", slackMappings...},
    {&cfg.Messaging.Feishu, "HOTPLEX_MESSAGING_FEISHU_", feishuMappings...},
}

for _, spec := range platformEnvSpecs {
    applyPlatformEnv(spec.config, spec.secrets, spec.bools, spec.strings, spec.numbers)
}

Estimated Impact: 新增平台从复制 40 行变为添加 1 个 platformEnvSpec 条目

Acceptance Criteria:

  • applyMessagingEnv() 无平台特定硬编码
  • 平台 env 映射通过数据结构注册
  • 现有 env var 覆盖测试零回归

load-bindenv-redundancy

Severity: High | Confidence: High | ROI: Medium
Location: config.go:691-745 (55 个 BindEnv 调用)

Problem: Load() 中 55 个显式 viper.BindEnv() 调用,部分与 applyMessagingEnv() 功能重叠。例如 messaging.feishu.tts_enabled 在 line 728 通过 BindEnv 绑定,又在 applyMessagingEnv() line 1069 通过反射重新绑定。两套机制同时存在,增加维护负担且容易不一致。

Current Pattern:

// config.go:691-745 — 55 explicit BindEnv calls
_ = v.BindEnv("gateway.addr", "HOTPLEX_GATEWAY_ADDR")
_ = v.BindEnv("messaging.slack.bot_token", "HOTPLEX_MESSAGING_SLACK_BOT_TOKEN")
// ... 53 more
_ = v.BindEnv("messaging.feishu.tts_enabled", "HOTPLEX_MESSAGING_FEISHU_TTS_ENABLED")

// config.go:1069 — same feishu TTS env var processed AGAIN via reflection
applyPlatformEnv(&cfg.Messaging.Feishu, ..., []envMapping{
    {"HOTPLEX_MESSAGING_FEISHU_TTS_ENABLED", "TtsEnabled"},
})

Proposed Fix: 统一为单一 env-var 映射机制。选择 A — 全部使用 BindEnv(移除 applyMessagingEnv 反射),或选择 B — 全部使用反射(移除 55 个 BindEnv 调用)。选择 B 更可维护:

// Single declarative mapping, used by both Viper and applyMessagingEnv
type envBinding struct {
    ConfigPath string   // "messaging.slack.bot_token"
    EnvVars    []string // {"HOTPLEX_MESSAGING_SLACK_BOT_TOKEN"}
}

// Generate both Viper bindings and reflection mappings from one source

Estimated Impact: ~55 行 BindEnv 调用减少为声明式数据结构

Acceptance Criteria:

  • 消除 BindEnv 和 applyMessagingEnv 之间的冗余
  • 每个 env var 只在一处定义
  • 现有 env var 测试零回归

SOLID: SRP (moderate)

watcher-five-responsibilities

Severity: Medium | Confidence: High | ROI: Medium
Location: watcher.go (461 lines), Watcher struct (lines 60-95)

Problem: Watcher 混合 5 种职责:(1) 文件监听 (fsnotify), (2) 防抖 (timer management), (3) 配置 diff 和分类 (hot vs static), (4) 审计日志 (bounded circular buffer), (5) 配置历史 + 回滚。审计日志和历史管理可以从 Watcher 提取。

Current Pattern:

// watcher.go:60-95 — 5 concerns in one struct
type Watcher struct {
    // File watching
    watcher *fsnotify.Watcher
    // Debouncing
    debounceTimer *time.Timer
    // Diffing
    hotFields map[string]bool
    // Audit
    audit []ConfigChange; maxAuditLen int; muAudit sync.Mutex
    // History + Rollback
    history []*Config; maxHistory int; muHistory sync.Mutex
}

Proposed Fix: 提取审计和历史为独立类型:

type ConfigAudit struct {
    entries []ConfigChange
    maxLen  int
    mu      sync.Mutex
}

type ConfigHistory struct {
    versions []*Config
    maxLen   int
    mu       sync.Mutex
}

type Watcher struct {
    watcher  *fsnotify.Watcher
    audit    *ConfigAudit
    history  *ConfigHistory
    // ...
}

Estimated Impact: Watcher 减少 ~80 行,审计/历史可独立测试

Acceptance Criteria:

  • ConfigAuditConfigHistory 独立 struct
  • Watcher 通过组合引用
  • 审计和回滚的现有测试零回归

Coupling

dual-config-propagation-path

Severity: Medium | Confidence: High | ROI: Medium
Location: watcher.go:262-293 (reload method), store.go:52-75 (Swap method)

Problem: 配置变更通过两条路径传播:(1) ConfigStore.Swap() 触发 Observer 回调, (2) Watcher 的 onChange/onStatic legacy 回调。同一消费者如果同时注册 observer 和 callback,会收到两次通知。注释标记 "Legacy callback notifications" 但仍在使用。

Current Pattern:

// watcher.go:268-275 — legacy callbacks
if w.onChange != nil {
    w.callbackSem <- struct{}{}
    go func() { defer func() { <-w.callbackSem }(); w.onChange(newCfg) }()
}

// watcher.go:284-285 — observer pattern
w.store.Swap(newCfg)  // fires Observer.OnConfigReload in goroutines

// Both fire for the same config change!

Proposed Fix: 迁移所有消费者到 Observer 模式,移除 legacy callbacks:

// watcher.go — after migration:
func (w *Watcher) reload() error {
    // ...
    w.store.Swap(newCfg)  // single propagation path
    // remove onChange/onStatic callbacks
}

Estimated Impact: 消除双路径通知,统一为单一 Observer 模式

Acceptance Criteria:

  • onChange/onStatic callback 字段从 Watcher 移除
  • 所有消费者使用 ConfigStore.RegisterFuncObserver
  • 配置变更通知测试零回归

config-god-struct-four-level-nesting

Severity: Medium | Confidence: High | ROI: Low
Location: config.go:149-164 (Config struct), config.go:277-290 (MessagingPlatformConfig)

Problem: Config.Messaging.Slack.MessagingPlatformConfig.STTConfig 是 4 层嵌套路径。消费者必须理解完整的嵌套层级才能访问所需配置。MessagingPlatformConfig 嵌入 STTConfigTTSConfig,而 SlackConfigFeishuConfig 嵌入 MessagingPlatformConfig,形成菱形嵌入结构。

Current Pattern:

// 4-level nesting: Config -> Messaging -> Slack -> MessagingPlatformConfig -> STTConfig
cfg.Messaging.Slack.STT.Enabled  // actually works via embedding
cfg.Messaging.Slack.TTS.Voice   // same pattern

Proposed Fix: 这主要是认知负担问题,不需要立即重构。长期可考虑将 MessagingConfig.Slack/Feishu 改为扁平化结构。

Estimated Impact: 认知负担减轻,但改动面大 — 标记为 Low ROI

Acceptance Criteria:

  • 评估将 MessagingPlatformConfig 字段提升到 SlackConfig/FeishuConfig 的可行性
  • 如果 ROI 不高,记录为已知设计权衡

Implementation Priority

Finding Priority Effort Risk Impact
config-go-six-responsibilities P1 Small Low 文件拆分,每个 <300 行
applymessagingenv-per-platform P1 Medium Low 新平台 0 行改动共享层
load-bindenv-redundancy P2 Medium Medium ~55 行减少,消除双重绑定
dual-config-propagation-path P2 Medium Low 统一为单一 Observer
watcher-five-responsibilities P3 Medium Low 审计/历史独立测试
config-god-struct-nesting P4 Large Medium 低 ROI,长期改进

Recommended starting point: config-go-six-responsibilities — 纯文件拆分,零逻辑变更,最安全的改进


Out of Scope

  • HotReloadableFields 导出可变性 — 已在 issue 253 跟踪
  • Config diffing reflect.DeepEqual — 已在 issue 260 跟踪
  • No Unregister() on ConfigStore — Low ROI
  • warnedEnvEntries sync.Map 无界增长 — Low severity,内存影响可忽略
  • Default() 126 行 — 随 config.go 拆分自然解决

Verification

  • make test 通过,无回归
  • make lint 不产生新警告
  • go test -race ./internal/config/... 通过

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Medium: tech debt, refactoring, improvementsarchitectureDomain: design patterns, coupling, separation of concernsarea/configScope: Viper config, hot reload, agent configsrefactorRefactor: DRY, SOLID, code quality improvements

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions