《Go 语言编程入门》15.1 配置加载(flag/env/文件)

把端口、超时、数据库地址写死在代码里,是项目长大的第一道坎。本节讲清配置的三个来源——命令行 flag、环境变量、JSON 配置文件——各自的读取方式与适用场景,给出「flag > env > 文件 > 默认」的合并优先级实现,用 flag.Visit 区分「显式设置」与「默认值」,并在启动时做 fail-fast 校验。

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 依赖注入与项目分层 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练