第 12 章 配置与环境:pydantic 实战

这一章把你从「用 os.getenv 到处取配置、取出来还是字符串、类型全靠自己转」的原始状态,拽进「配置即类型、加载即校验」的工程化正轨。Go 工程师最爱的结构体 tag 校验,在 Python 里 pydantic 替你干了,而且干得更狠。

墨叔:老哥,你先在脑子里搜一下——你现在的 Python 项目里,是不是到处都是 os.getenv("DB_HOST")?然后取出来是个字符串,要用端口号还得 int(os.getenv("DB_PORT")),忘了转就半夜线上 TypeError?

老哥:……被你说中了。而且最怕的是某个环境变量没设,取出来是 None,一路传下去到真正用的时候才炸,排查半天才发现是配置漏了。

墨叔:这就是「配置散养」的代价。你 Go 那边多舒服,viper 或自己写个 Config 结构体,启动那一刻就把环境变量读进来、转好类型、校验完,缺一个字段直接 panic 在起跑线上,绝不会让一个错误的配置溜进业务逻辑跑到一半才爆。Python 这边,过去确实没这习惯,但 2018 年之后 pydantic 出现了,它干的事跟你 Go 的结构体 tag 校验几乎一模一样——而且因为 Python 是动态的,它还能顺手做类型转换、默认值、甚至嵌套结构。今天咱们就把它用透。

一、12-factor 与「配置从哪来」:先把哲学摆正

先说清楚一个原则,不然你工具用得再溜也是歪的。12-factor 应用(那套云原生十二要素)里有一条铁律:配置(随部署环境变化的东西)必须来自环境变量,绝不写死在代码或配置文件里跟着代码走。数据库地址、密钥、端口、开关——这些是「环境」的差异,不是「代码」的差异。

生活化类比:你家的菜谱(代码)不会因为搬去北京就改;但「灶台火力多大、当地买的盐咸不咸」(配置)是环境给的。你不会把「用 2 克盐」刻进菜谱然后每搬一次家就改菜谱,你会在做菜前看一眼手边的盐罐(环境变量)决定放多少。代码和配置分离,项目才能在 dev / test / prod 之间无痛迁移。

那 Python 怎么落地?最朴素是 os.getenv,但它有三个致命短板:返回值永远是 str(或不设时是 None)、完全不校验、取用点分散在代码各处。老哥你刚才说的「半夜 TypeError」「漏设溜进逻辑」,根子就在这两个短板上。解法就是 pydantic 的 BaseSettings:它在程序启动时集中读取环境变量,统一做类型转换和校验,输出一个强类型的配置对象。

二、pydantic BaseSettings 怎么用:配置即类型

先看最小可用版本。注意 pydantic 现在主流是 v2,下面全是 v2 写法(Python 3.11+ 跑得通):

# settings.py
from pydantic import BaseSettings, Field
from pydantic_settings import SettingsConfigDict  # v2 后 BaseSettings 拆到 pydantic-settings
 
class AppSettings(BaseSettings):
    # 默认从环境变量 APP_NAME 读,转成 str;没设就用默认值
    app_name: str = "order-service"
 
    # 从环境变量 DB_PORT 读,pydantic 自动把 "5432" 转成 int
    db_port: int = 5432
 
    # 布尔环境变量 "true"/"1"/"yes" 都能被认成 True
    debug: bool = False
 
    # Field 能写更细的约束,比如端口范围
    worker_count: int = Field(default=4, ge=1, le=32)
 
    # 嵌套也行:pydantic 会把 DB_ 前缀的变量归到这个类
    model_config = SettingsConfigDict(env_prefix="")
 
settings = AppSettings()   # 实例化即加载 + 校验
print(settings.db_port)    # 如果设了 DB_PORT=6543,这里就是 6543(int)

关键点我挨个说:

  • 类型自动转换。环境变量天生是字符串,db_port: int 让 pydantic 在加载时帮你把 "6543" 转成 6543。你不用再满代码 int(...)。Go 里你要么自己 strconv.Atoi,要么靠 viper 这种库,pydantic 把这件事内建了。
  • 校验内建。worker_count 设成 0 或者 99,pydantic 直接抛 ValidationError,启动即死,不会让一个非法 worker 数溜进去跑。这正是 12-factor 要的「配置错误死在起跑线」。
  • 配置即类型。你写的 AppSettings 类本身就是配置的「契约」:字段名、类型、约束一目了然,新同事看这个类就知道这服务要哪些环境变量、各自啥类型。对照 Go:这就是你的 type Config struct { DBPort int \env:“DB_PORT”` }` 加上 mapstructure 校验的合体,而且因为 Python 动态,它还免去了你写 tag 和注册解码器的样板。

老哥:那 .env 文件算不算违反了「配置来自环境变量」?

墨叔:问得刁,但答案是否定的——.env 不违反,它是「本地开发时给环境变量批量填值」的便利手段。生产上你照样从真实环境变量读,.env 只在本地用,而且绝不提交进 git(放 .gitignore)。pydantic 默认就会读项目根目录的 .env:

# .env(本地开发用,不进 git)
DB_PORT=6543
DEBUG=true
REDIS_URL=redis://localhost:6379
from pydantic_settings import BaseSettings, SettingsConfigDict
 
class AppSettings(BaseSettings):
    db_port: int = 5432
    debug: bool = False
    redis_url: str = "redis://localhost:6379"
 
    model_config = SettingsConfigDict(
        env_file=".env",        # 读 .env
        env_file_encoding="utf-8",
        extra="ignore",         # 环境变量多出没声明的字段就忽略,不报错
    )

这样本地 python main.py 自动吃 .env,上线时容器注入真实环境变量,代码一行不改。这就是 12-factor 说的「同一份代码,不同环境靠环境变量区分」,.env 只是本地那一份环境变量的「替身」,不是把配置写死进仓库。

三、什么场景该用它,什么场景别碰

我先泼盆冷水:pydantic 不是万金油,你得知道边界。

该用 BaseSettings 的场景:几乎所有「应用服务」的启动配置——数据库连接、端口、密钥、功能开关、外部服务地址。凡是「随环境变、且影响程序行为、且错了要尽早死」的,都该集中进配置类。这是收益最大的地方:错误前移、类型安全、单一事实来源。

别用它管的场景:

  • 真正运行时才变的业务数据(比如用户自己改的个人设置),那该进数据库,不是环境变量。
  • 超高频读取、且不需要校验的常量——为它起一个 pydantic 模型、每次实例化都走校验,纯属浪费。常量直接写模块级变量更轻。
  • 极简脚本:你写个 30 行一次性脚本,os.getenv 两下搞定,上 pydantic 是杀鸡用牛刀,还得多装个依赖。

对照 Go:Go 这边你也不会给每个常量都写 Config 结构体,viper 也是用在「需要集中管理的环境相关配置」上。工具边界,两边逻辑一致。

还有个坑要提醒:pydantic v1 和 v2 差异很大,BaseSettings 在 v2 被拆到了独立的 pydantic-settings 包(上面代码就是 v2 写法)。你 pip install pydantic 拿到的是 v2,但如果你同事代码里写的是 from pydantic import BaseSettings,那是 v1 老写法,混用会踩坑。新项目直接 v2 + pydantic-settings,别回头。

四、贴近项目的多环境配置例子

给你一个真实服务常见的「dev / test / prod」三环境布局。核心思路:AppSettings 是公共基类,不同环境继承它覆盖差异;再加一个 ENVIRONMENT 变量做分派。

# config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
 
 
class BaseAppSettings(BaseSettings):
    app_name: str = "order-service"
    db_host: str = "localhost"
    db_port: int = 5432
    db_name: str = "orders"
    debug: bool = False
    log_level: str = "INFO"
 
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
 
 
class DevSettings(BaseAppSettings):
    debug: bool = True
    log_level: str = "DEBUG"
    db_name: str = "orders_dev"
 
 
class TestSettings(BaseAppSettings):
    db_name: str = "orders_test"
    log_level: str = "WARNING"
 
 
class ProdSettings(BaseAppSettings):
    # 生产环境强制要求这些关键变量必须显式设置,不允许默认值
    db_host: str
    db_port: int
    log_level: str = "INFO"
    debug: bool = False
 
 
def load_settings() -> BaseAppSettings:
    env = __import__("os").getenv("ENVIRONMENT", "dev").lower()
    mapping = {
        "dev": DevSettings,
        "test": TestSettings,
        "prod": ProdSettings,
    }
    return mapping[env]()

注意 ProdSettings 里 db_host: str 后面没写默认值——这意味着生产环境如果没设 DB_HOST 环境变量,pydantic 直接 ValidationError 启动失败,逼着你把配置补齐。这就是「生产严格、开发宽松」的工程化精髓:开发时给你默认值图省事,生产时把关键项变成必填,杜绝「漏配上线」。对照 Go,你通常会用不同 viper.SetDefault 或不同配置文件实现类似分层,思路完全相通。

# main.py
from config import load_settings
 
settings = load_settings()
print(f"启动 {settings.app_name},环境日志级别 {settings.log_level}")
# 业务代码里统一用 settings.xxx,再也不用 os.getenv 到处飞

五、收益与代价:为什么值得多装一个依赖

把账算清楚,你才不会被「又是个花哨库」的直觉带偏。

收益:

  1. 错误前移。配置错误在启动即暴露,而不是跑到业务逻辑深处才 TypeError/None 传播。对线上服务,这等于把一类「最难查」的 bug 直接消灭在起跑线。
  2. 类型安全 + 自动转换。环境变量字符串自动变 int/bool/复杂结构,代码里拿到的就是正确类型,零手工转换、零「忘了转」事故。
  3. 单一事实来源(SSOT)。所有配置集中在 AppSettings,新人看一个类就懂全局配置面,不像 os.getenv 散落十几个文件。
  4. IDE 友好。因为配置是强类型对象,. 出来有补全、有类型提示,你 Go 习惯的「结构体字段补全」体验终于回来了。

代价:

  1. 多一个依赖 + 一层抽象。小脚本上它纯属负担;团队还得统一 pydantic 版本(v1/v2 不兼容是大坑)。
  2. 校验有微小启动开销。模型实例化要跑校验,对「秒级启动的微服务」可忽略,对「Serverless 冷启动极敏感」的场景要权衡——但通常远小于它省下的排错时间。
  3. 动态性被收束。你放弃了「随手 os.getenv 取个新变量」的自由,换来的是约束。对工程化这是赚的,对个人玩具脚本是亏的——所以判断标准永远是「这是生产项目还是一次性脚本」。

老哥:听起来就是 Go 的 viper + 结构体校验,但 Python 因为动态,连转换都包了。

墨叔:总结得准。pydantic 本质是「用 Python 的动态特性,把 Go 里需要 viper + mapstructure + 手写校验三部分干的事,合成一个强类型声明」。你愿意把配置当类型来写,项目健壮性立刻上一个台阶——这和你 Go 里坚持「配置集中、启动校验」是一个工程师本能,只是换了个更省样板的语言实现。

六、测试时怎么注入配置:别让测试碰真环境

墨叔:老哥,你写完配置类,测试怎么写?总不能让单元测试去读你本地的 .env、甚至连真数据库吧?

老哥:我一般测试里直接 os.environ["DB_HOST"] = "test" 塞一个。

墨叔:能用,但有个坑——你这么改的是进程级环境变量,测试之间会互相污染,上一个测试设的 DB_HOST 漏清理,下一个测试就拿到脏值。pydantic 的 BaseSettings 实例化时把环境变量「快照」进来,所以正确做法是「在实例化前设好环境,或显式传入覆盖值」,并且每个测试用 monkeypatch 或 fixture 隔离:

# test_config.py
import os
from config import BaseAppSettings
 
 
def test_prod_requires_db_host(monkeypatch):
    # monkeypatch 自动在测试结束后还原环境变量,干净隔离
    monkeypatch.delenv("DB_HOST", raising=False)
    monkeypatch.setenv("ENVIRONMENT", "prod")
    try:
        BaseAppSettings()          # 注意这里用的是基类,prod 子类必填项缺失
    except Exception as e:
        assert "db_host" in str(e)   # 确认启动时因缺配置而失败

关键认知:配置对象应该「可注入、可覆盖、可隔离」。生产代码里 settings = load_settings() 在模块加载时创建一次;测试里你要么用 fixture 重建一个带测试值的实例,要么用 monkeypatch 在导入前改环境。绝不要让测试依赖「我本地恰好设了某个变量」——CI 上那台机器可没设,于是你本地绿、CI 红,又是经典事故。对照 Go:你 viper 测试时也用 t.Setenv 或临时配置文件隔离,思路完全一样,只是 pydantic 因为「实例化即加载」,隔离的时机要卡在实例化之前。

七、你现在该带走的四句话

  1. 配置从环境变量来(12-factor),.env 只是本地替身,绝不进 git。
  2. 用 pydantic BaseSettings 做「加载即校验、字符串即类型」,把配置错误死在起跑线,别让它溜进业务逻辑。
  3. 配置类就是契约:字段、类型、约束一眼看清,新人看一个类懂全局,这就是「配置即类型」。
  4. 多环境用「基类 + 子类覆盖」分层,生产环境把关键项设成必填(无默认值),开发宽松、生产严格。

八、把这一章变成肌肉记忆

  1. 把你现有项目里所有 os.getenv 调用,收拢进一个 AppSettings(BaseSettings) 类,跑一次启动,故意漏设一个必填变量,看 pydantic 怎么在起跑线报错——体会「错误前移」的爽感。
  2. 写一个 prod 配置子类,把所有敏感项(数据库密码、API key)设成必填无默认,再写一个 .env 只填 dev 的值,对比两种环境加载出来的对象差异,把「环境分层」变成你的默认习惯。

【思考题】

  1. 你现在的项目用 os.getenv("PORT", "8080") 取端口,默认值是字符串 "8080"。如果某处代码写了 PORT + 1(想算下一个端口),在 Go 和在这种 Python 写法下分别会怎样?换成 pydantic port: int = 8080 后又会怎样?这背后反映了什么工程差异?
  2. 12-factor 说「配置来自环境变量」。那密码、API key 这类敏感配置,应该放进 .env 文件提交到仓库吗?为什么?如果不进仓库,团队怎么共享这些密钥又不泄露?
  3. 你在 pydantic 里声明 db_host: str(无默认值),生产环境却忘了设 DB_HOST。程序会在哪一步、以什么形式失败?对比一下,如果用 os.getenv("DB_HOST") 且没设,程序会在哪一步、以什么形式失败?两种失败模式对线上排障的难度差在哪?

【参考答案】

  1. Go 里 PORT 通过 strconv.Atoi 或 viper 取,取出来是 int,PORT + 1 正常得 8081;若你没转类型直接当 string 拼,Go 编译期就报「string 不能 + int」,根本跑不起来。原始 Python 写法 os.getenv(..., "8080") 返回字符串,"8080" + 1 在运行时抛 TypeError——而且只有跑到那一行才炸,可能藏在很深的调用里。换成 pydantic port: int = 8080:加载时 "8080" 已被转成 int,port + 1 得 8081 正常;即便环境变量设成了非数字如 "abc",pydantic 在启动加载这一步就抛 ValidationError,绝不让你带着错误配置启动。差异本质:Go 靠编译期 + 显式转换把类型问题死在出厂前;原始 Python 把类型问题推到运行时深处;pydantic 则在「启动加载」这个固定节点集中转换+校验,是工程化的折中——既不靠编译期,也不让错误流窜。
  2. 绝对不该把含真实密码/key 的 .env 提交进仓库——.env 进 git 是严重安全事故,历史记录里会永久留痕,被人扒库就完蛋。正确做法:仓库里只提交 .env.example(全是占位符和说明,如 DB_PASSWORD=xxx),告诉后来者「需要哪些变量」;真实密钥通过环境注入(容器编排的 secret、CI 的 encrypted variables、云厂商的 secret manager)在运行时进入环境变量,.env` 本地用、不进 git。团队共享则用密码管理器或云 secret 服务,而非明文文件互传。
  3. pydantic 无默认值字段 db_host: str 漏设 DB_HOST:实例化 AppSettings() 的那一刻就抛 ValidationError,明确指出「db_host 字段缺失」,程序根本没进入 main 逻辑——失败点固定、信息明确、排障秒级。原始 os.getenv("DB_HOST") 漏设:返回 None,程序照样启动,可能一路把 None 当 host 传给连接池,直到某次真正建连时才抛「host 不能为 None」之类的错,而且报错栈在连接逻辑深处,你还得逆推「哦原来是配置没设」。两种失败模式的核心差距:前者是「配置错误,启动即死、指名道姓」;后者是「配置缺失,带病启动、深处才爆、溯源费劲」。这正是 pydantic 把「错误前移」价值说得最清楚的例子。