15.1 配置加载(flag/env/文件)
TaskAPI 到现在为止,Addr 是硬编码的 ":8080",数据库地址还空着。这在本地跑没问题,一到部署就卡住:测试环境要用别的端口、CI 里数据库地址不同、生产要关掉 debug 日志。把这些值从代码里挪出去、按不同环境注入,就是「配置」这件事。
本节把 TaskAPI 推进到:把监听地址、各类超时、数据库地址、日志级别抽成一个
Config,支持 flag、环境变量、JSON 文件三种来源,并实现清晰的优先级与启动时校验。
15.1.1 三个来源,各有分工
配置的常见来源有三个,它们不是竞争关系,而是分层:
| 来源 | 典型用途 | 优点 | 缺点 |
|---|---|---|---|
| 命令行 flag | 临时覆盖、本地调试 | 显式、一次生效 | 不适合放很多项 |
| 环境变量 | 容器/CI 注入、密钥 | 十二要素应用标准、不落盘 | 无类型、易拼错 |
| 配置文件 | 大量结构化默认值 | 可读、可版本化 | 不宜放密钥 |
一个成熟的优先级是:flag 覆盖 env,env 覆盖文件,文件覆盖内置默认。理由是「越临时、越靠外层」的来源优先级越高——你在命令行敲的东西,理应盖过写死的文件。
15.1.2 定义一个 Config
先把所有可配置项收进一个结构体:
type Config struct {
Addr string `json:"addr"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
ShutdownTimeout time.Duration `json:"shutdown_timeout"`
DatabaseURL string `json:"database_url"`
LogLevel string `json:"log_level"`
}
用 time.Duration 而不是 int 存超时,好处是类型自带单位,读代码时不会纠结「这个 5 是秒还是毫秒」。默认值集中在一个函数里:
func defaults() Config {
return Config{
Addr: ":8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
ShutdownTimeout: 15 * time.Second,
LogLevel: "info",
}
}
15.1.3 flag:标准库的命令行解析
flag 是标准库自带的命令行解析器。定义与解析:
addr := flag.String("addr", "", "listen address")
logLevel := flag.String("log-level", "", "log level: debug|info|warn|error")
cfgPath := flag.String("config", "", "path to json config file")
flag.Parse()
几个要点:flag.String 返回的是指针,用的时候要解引用 *addr;默认值传 "" 而不是真实默认,这样我们才能区分「用户显式给了」和「没给」。flag 自带 -h 帮助,会自动生成用法说明。
不过直接用全局 flag.CommandLine 不利于测试。推荐用 flag.NewFlagSet 建独立的解析器:
fs := flag.NewFlagSet("taskapi", flag.ContinueOnError)
cfgPath := fs.String("config", "", "path to json config file")
addr := fs.String("addr", "", "listen address")
logLevel := fs.String("log-level", "", "log level")
if err := fs.Parse(args); err != nil {
return Config{}, err
}
这样测试时可以把 args 当参数传进去,不必碰全局状态——这正是第 8 章讲的可测试性。
15.1.4 区分「显式设置」与「默认值」
用 "" 当哨兵有个问题:用户可能故意想设成空串。更严谨的做法是用 fs.Visit——它只遍历被显式设置过的 flag:
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "addr":
cfg.Addr = *addr
case "log-level":
cfg.LogLevel = *logLevel
}
})
fs.Visit 访问的是「在命令行里出现过」的 flag,没出现的不进这个回调。于是我们可以先读文件、读 env 填好 cfg,最后只让显式给出的 flag 覆盖,既不破坏默认值,又能识别「用户真的想设这个值」。
15.1.5 环境变量:os.LookupEnv
读环境变量用 os.LookupEnv,它返回 (value, ok) 两值,ok 告诉你变量是否存在——比 os.Getenv(不存在时返回空串,无法区分「没设」和「设成了空」)更精确:
if v, ok := os.LookupEnv("TASKAPI_ADDR"); ok {
cfg.Addr = v
}
if v, ok := os.LookupEnv("TASKAPI_LOG_LEVEL"); ok {
cfg.LogLevel = v
}
命名建议加统一前缀(这里是 TASKAPI_),避免和系统里其他变量撞车。为了可测试,把读取函数做成参数注入,测试时传一个假函数即可:
type envFunc func(string) (string, bool)
15.1.6 环境变量没有类型:自己转
env 里一切都是字符串,5s、5、5000ms 都得自己解析。一个宽松的 duration 解析器:
func parseDurationEnv(s string) (time.Duration, error) {
if s == "" {
return 0, nil
}
if d, err := time.ParseDuration(s); err == nil {
return d, nil // "30s"、"1m30s"
}
if n, err := strconv.Atoi(s); err == nil {
return time.Duration(n) * time.Second, nil // 纯数字按秒
}
return 0, fmt.Errorf("invalid duration %q", s)
}
实测:"30s" 解析成 30s,"45" 按秒解析成 45s。务必对无法解析的值报错,而不是悄悄用 0——一个被解析成 0 的超时,会让服务瞬间超时崩溃,且极难排查。
15.1.7 合并优先级:完整实现
把三个来源按优先级拼起来:
func load(args []string, env envFunc) (Config, error) {
cfg := defaults() // 1. 默认
fs := flag.NewFlagSet("taskapi", flag.ContinueOnError)
cfgPath := fs.String("config", "", "path to json config file")
addr := fs.String("addr", "", "listen address")
logLevel := fs.String("log-level", "", "log level")
if err := fs.Parse(args); err != nil {
return Config{}, err
}
if *cfgPath != "" { // 2. 文件
data, err := os.ReadFile(*cfgPath)
if err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
}
if v, ok := env("TASKAPI_ADDR"); ok { // 3. env
cfg.Addr = v
}
if v, ok := env("TASKAPI_LOG_LEVEL"); ok {
cfg.LogLevel = v
}
fs.Visit(func(f *flag.Flag) { // 4. flag(只覆盖显式给出的)
switch f.Name {
case "addr":
cfg.Addr = *addr
case "log-level":
cfg.LogLevel = *logLevel
}
})
if err := cfg.validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
四步顺序即优先级:默认 → 文件 → env → flag。每一步都可能覆盖上一步的值,最终得到「最外层优先」的结果。
15.1.8 fail-fast:启动时就校验
配置错了,最好在启动那一刻就崩,而不是等某个请求打进来才暴露。给 Config 加一个 validate:
func (c Config) validate() error {
if !strings.Contains(c.Addr, ":") {
return fmt.Errorf("addr %q: 需要 host:port 形式", c.Addr)
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("log_level %q: 只能是 debug|info|warn|error", c.LogLevel)
}
return nil
}
load 的最后一步调用它,任何一项不合法就直接返回错误,main 里 log.Fatal 退出。这就是 fail-fast:把错误扼杀在启动阶段,而不是留给运行中的服务。
15.1.9 实测结果
把上述代码跑起来,依次测试默认、env 覆盖、flag 覆盖 env、校验失败、文件加载:
default: {Addr::8080 ReadTimeout:5s WriteTimeout:10s ShutdownTimeout:15s DatabaseURL: LogLevel:info} err=<nil>
env: addr=:9000 level=debug
flag wins: addr=127.0.0.1:7070 level=debug
validate err: addr "nope": 需要 host:port 形式
file: {Addr::6060 ReadTimeout:5s WriteTimeout:10s ShutdownTimeout:15s DatabaseURL: LogLevel:warn}
dur: 30s 45s
逐条对照:默认值正确;env 把 addr 改成 :9000、level 改成 debug;加 -addr 127.0.0.1:7070 后 flag 盖过 env(addr 变了、level 仍来自 env);非法 addr 被 validate 拦下;配置文件把 addr 设为 :6060、level 设为 warn。
15.1.10 密钥不进配置文件
最后一条经验,比任何 API 都重要:数据库密码、API key 这类敏感信息不要写进会被提交的配置文件。文件适合放「结构化的非敏感默认值」,密钥走环境变量或专门的密钥管理服务。这样 config.json 可以安心提交进仓库,而密钥随部署环境注入,不会随代码泄漏。
15.1.11 小结
- 配置分层:flag 覆盖 env,env 覆盖文件,文件覆盖默认。
flag用NewFlagSet便于测试;fs.Visit只遍历显式设置的项。- 环境变量用
os.LookupEnv区分「没设」与「空值」,并加统一前缀。 - env 无类型,
time.Duration、int要自己解析,解析失败必须报错。 - 启动时
validate做 fail-fast,别把配置错误拖到运行时。 - 密钥走 env,不写进可提交的配置文件。
配置能加载了,但谁来把这些配置组装成运行中的对象?Repo 在哪 new、Service 拿到 Repo 还是 *sql.DB?下一节讲依赖注入与项目分层。
阅读导航:上一节:14.3 schema 迁移与 sqlc · 下一节:15.2 依赖注入与项目分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。