3. 配置管理:使用 Viper 加载多环境配置(YAML)、配置热更新、敏感信息加密
在支付中间件工程中,配置管理是系统稳定性的基石。支付场景涉及多环境(开发、测试、预发、生产)、多维度参数(数据库、Redis、MQ、对账规则、风控阈值)以及敏感密钥(商户证书、支付密钥、数据库密码)。本章将基于 Go 生态中最流行的配置库 Viper,结合 YAML 格式,讲解如何实现多环境加载、配置热更新以及敏感信息加密。
3.1 多环境配置架构设计
支付中间件通常需要同时支持本地开发、CI 测试、预发布和生产环境。不同环境的配置差异集中在数据库连接串、日志级别、第三方支付网关地址、密钥等。我们采用 “默认配置 + 环境覆盖” 的策略,通过环境变量 APP_ENV 或 PAY_ENV 来区分运行环境。
- 基础配置(config.yaml)包含所有环境的通用默认值。
- 环境特定配置(config-dev.yaml、config-prod.yaml)仅覆盖差异项。
- 敏感信息(密钥、密码)不直接写入 YAML 文件,而是通过外部加密存储或环境变量注入。
以下是一个典型的支付中间件配置目录结构:
config/
├── config.yaml # 基础默认配置
├── config-dev.yaml # 开发环境覆盖
├── config-test.yaml # 测试环境覆盖
├── config-staging.yaml # 预发布环境覆盖
└── config-prod.yaml # 生产环境覆盖
Viper 支持从多个路径、多个文件读取配置,并支持按优先级覆盖。我们通过 viper.AddConfigPath() 和 viper.SetConfigName() 实现分层加载。
3.2 Viper 核心配置加载实现
以下代码展示了如何在 Go 中初始化 Viper,实现多环境配置加载。我们使用 viper.SetConfigType("yaml") 明确指定格式,并通过 viper.MergeConfig() 实现分层覆盖。
package config
import (
"fmt"
"os"
"strings"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)
type PaymentConfig struct {
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
Pay PayConfig `mapstructure:"pay"`
Log LogConfig `mapstructure:"log"`
}
type AppConfig struct {
Name string `mapstructure:"name"`
Env string `mapstructure:"env"`
Debug bool `mapstructure:"debug"`
Port int `mapstructure:"port"`
}
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"` // 加密存储,运行时解密
DBName string `mapstructure:"dbname"`
}
// InitConfig 初始化配置,支持多环境加载
func InitConfig(configPath string) (*PaymentConfig, error) {
v := viper.New()
v.SetConfigType("yaml")
// 1. 加载基础配置
v.SetConfigName("config")
v.AddConfigPath(configPath)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取基础配置失败: %w", err)
}
// 2. 根据环境变量加载环境特定配置
env := strings.ToLower(os.Getenv("APP_ENV"))
if env == "" {
env = "dev" // 默认开发环境
}
envConfigName := fmt.Sprintf("config-%s", env)
v.SetConfigName(envConfigName)
// 注意:MergeConfig 会合并到已有配置,相同 key 会被覆盖
if err := v.MergeInConfig(); err != nil {
// 如果环境配置文件不存在,仅警告,不中断
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("读取环境配置失败: %w", err)
}
fmt.Printf("[WARN] 未找到环境配置文件: %s.yaml\n", envConfigName)
}
// 3. 支持环境变量覆盖(最高优先级)
v.AutomaticEnv()
v.SetEnvPrefix("PAY")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 4. 反序列化到结构体
var cfg PaymentConfig
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("配置反序列化失败: %w", err)
}
// 5. 敏感信息解密(如数据库密码)
if err := decryptSensitive(&cfg); err != nil {
return nil, fmt.Errorf("敏感信息解密失败: %w", err)
}
return &cfg, nil
}
viper.MergeInConfig() 而非 viper.ReadInConfig() 可以保留基础配置中未被环境配置覆盖的字段。环境变量前缀 PAY 可以避免与系统环境变量冲突。
3.3 配置热更新(Hot Reload)
支付中间件通常要求 7x24 小时运行,修改配置后不能重启进程。Viper 原生支持基于 fsnotify 的文件监听,当配置文件发生变化时,自动触发回调。我们可以在回调中更新内存中的配置对象,并通知相关模块(如路由、连接池)刷新。
// WatchConfig 监听配置文件变化,实现热更新
func WatchConfig(v *viper.Viper, cfg **PaymentConfig) {
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
fmt.Printf("[INFO] 配置文件发生变化: %s\n", e.Name)
// 重新反序列化
var newCfg PaymentConfig
if err := v.Unmarshal(&newCfg); err != nil {
fmt.Printf("[ERROR] 热更新反序列化失败: %v\n", err)
return
}
// 敏感信息重新解密
if err := decryptSensitive(&newCfg); err != nil {
fmt.Printf("[ERROR] 热更新解密失败: %v\n", err)
return
}
// 原子替换配置指针(保证并发安全)
*cfg = &newCfg
// 通知其他模块刷新(如数据库连接池、日志级别)
notifyModules(&newCfg)
})
}
// notifyModules 通知各模块刷新配置
func notifyModules(cfg *PaymentConfig) {
// 示例:动态调整日志级别
SetLogLevel(cfg.Log.Level)
// 示例:刷新数据库连接池配置
RefreshDBPool(cfg.Database)
fmt.Println("[INFO] 各模块配置已热更新")
}
3.4 敏感信息加密方案
支付中间件涉及大量敏感数据:商户 API 密钥、数据库密码、支付网关证书密码、JWT 签名密钥等。这些信息绝对不能以明文形式存储在 Git 仓库或配置文件中。我们采用 AES-256-GCM 对称加密 + 环境变量传递密钥的方式,实现“配置文件中存储密文,运行时解密”。
| 敏感字段 | 加密方式 | 密钥来源 | 解密时机 |
|---|---|---|---|
| 数据库密码 | AES-256-GCM | 环境变量 PAY_DB_KEY |
配置加载时 |
| 支付商户密钥 | AES-256-GCM | 环境变量 PAY_MERCHANT_KEY |
配置加载时 |
| JWT 签名密钥 | AES-256-GCM | 环境变量 PAY_JWT_KEY |
配置加载时 |
| 第三方证书密码 | AES-256-GCM | 环境变量 PAY_CERT_KEY |
首次使用时 |
以下是对称加密/解密的工具函数实现:
package crypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// Encrypt AES-256-GCM 加密,返回 base64 编码的密文
func Encrypt(plaintext []byte, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt 解密 base64 编码的密文,返回明文
func Decrypt(cipherBase64 string, key []byte) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(cipherBase64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("密文长度不足")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
在配置加载时,我们调用 decryptSensitive 函数对结构体中的加密字段进行解密:
func decryptSensitive(cfg *PaymentConfig) error {
// 从环境变量获取 AES 密钥(32字节,对应 AES-256)
key := []byte(os.Getenv("PAY_DB_KEY"))
if len(key) == 0 {
return errors.New("环境变量 PAY_DB_KEY 未设置")
}
// 解密数据库密码
if cfg.Database.Password != "" {
decrypted, err := crypt.Decrypt(cfg.Database.Password, key)
if err != nil {
return fmt.Errorf("数据库密码解密失败: %w", err)
}
cfg.Database.Password = string(decrypted)
}
// 解密其他敏感字段(商户密钥、JWT 等)
// ...
return nil
}
3.5 配置管理最佳实践清单
- 分层覆盖优先级: 环境变量 > 环境特定 YAML > 基础 YAML。Viper 的
AutomaticEnv()确保环境变量具有最高优先级。 - 热更新安全: 使用
atomic.Value或指针原子替换,避免在热更新回调中直接修改正在被其他协程读取的配置对象。 - 加密密钥管理: AES 密钥通过环境变量传递,禁止硬编码在代码或配置文件中。生产环境建议使用密钥管理服务(KMS)或 Vault。
- 配置校验: 在配置加载后,执行
Validate()方法检查必填字段(如数据库地址、支付网关 URL)是否为空。 - 配置版本化: 在配置结构体中增加
Version字段,热更新时检查版本号,避免旧配置覆盖新配置。 - 日志记录: 配置加载和热更新时输出关键日志(如加载的文件路径、解密状态),便于排查问题。
- 不要在配置文件中存储任何明文密钥,即使是在开发环境。
- 热更新监听的文件路径必须与加载时一致,避免符号链接导致监听失效。
- 对于数据库连接池等资源,热更新后应逐步替换连接,而非全部断开重建。
- 配置文件的权限应设置为 600,仅允许应用进程读取。
3.6 完整配置结构体示例
以下是一个支付中间件完整的 YAML 配置结构,包含加密字段占位符:
# config-prod.yaml
app:
name: payment-middleware
env: prod
debug: false
port: 8080
database:
driver: mysql
host: 10.0.1.100
port: 3306
user: pay_user
password: "AES256_BASE64_ENCRYPTED_STRING_HERE" # 加密后的密文
dbname: payment_db
max_open: 100
max_idle: 20
redis:
host: 10.0.1.200
port: 6379
password: "AES256_BASE64_ENCRYPTED_STRING_HERE"
db: 0
pay:
alipay:
app_id: "202100xxxx"
private_key: "AES256_BASE64_ENCRYPTED_STRING_HERE"
public_key: "AES256_BASE64_ENCRYPTED_STRING_HERE"
notify_url: "https://pay.example.com/notify"
wechat:
mch_id: "1230000109"
api_key: "AES256_BASE64_ENCRYPTED_STRING_HERE"
cert_path: "/etc/pay/certs/wechat/apiclient_cert.p12"
cert_password: "AES256_BASE64_ENCRYPTED_STRING_HERE"
log:
level: info
output: stdout
file: /var/log/payment/app.log