《Python编程实战》3.1 分层配置与 pydantic-settings

硬编码配置撑不过多环境。本节用 pydantic-settings 2.15.0 讲清配置来源优先级(环境变量 > .env > 默认值)、类型转换与 field_validator 校验、SecretStr 脱敏、嵌套配置与多 .env 合并,以及 lru_cache 缓存配合 FastAPI 依赖注入的落地方式。

本节目标:用 pydantic-settings 2.15.0 把散落在 os.environ 与硬编码里的配置收敛成有类型、有校验、能分层的 Settings 对象。
适用版本:Python 3.12+(实测 3.14.6);pydantic-settings 2.15.0

3.1 分层配置与 pydantic-settings

前一章我们把依赖锁死、把构建跑进了 CI,接下来要回答「代码跑起来时,配置从哪来」。这一节是整个「工程基建」的最后一环:把配置从 if os.getenv(...) 的散兵游勇,升级为有类型、有默认值、有校验、能按环境分层的单一入口。

3.1.1 硬编码配置的三个坎

很多人一开始用 os.environ 直读:

import os

PORT = int(os.environ.get("PORT", "8000"))
DEBUG = os.environ.get("DEBUG", "false") == "true"
DB_URL = os.environ["DATABASE_URL"]        # 缺失就 KeyError

这段代码有三个问题:

问题具体表现
无类型PORT 是字符串还是整数全靠手写 int(),漏了就在运行时炸
无校验PORT=99999 能通过,直到绑定端口失败才发现
无分层默认值、.env、环境变量各写各的,优先级靠人肉记忆

pydantic-settings 把这三件事一次性解决:声明一个类,字段类型即校验规则,来源优先级由框架统一裁决。

3.1.2 最小可用:BaseSettings

pydantic-settings 是 pydantic 的官方扩展,把 BaseModel 的校验能力接到了环境变量上:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", extra="ignore")

    name: str = "my-service"
    port: int = 9000
    debug: bool = False
    workers: int = 4

print(Settings())
print("name  =", Settings().name, type(Settings().name).__name__)
print("port  =", Settings().port, type(Settings().port).__name__)
name='my-service' port=9000 debug=False workers=4
name  = my-service str
port  = 9000 int

env_prefix="APP_" 表示环境变量名统一加前缀,于是 APP_PORT 映射到字段 port。加前缀是硬性建议:它能避免和 PATH、HOME、LANG 这类系统变量撞名——没有前缀时,Settings 里一个叫 path 的字段会静默读到系统的 PATH。

3.1.3 来源优先级:环境变量 > .env > 默认值

配置来源是分层的,pydantic-settings 的默认优先级从高到低是:

  1. 构造时显式传入的参数(Settings(port=1234))
  2. 环境变量
  3. .env 文件
  4. 字段默认值

先准备一个 .env:

APP_NAME=from_dotenv
APP_PORT=8000
APP_DB__PASSWORD=dotenv_secret

注意 .env 里的键也要带 env_prefix——这是最常见的踩坑点,后面 3.1.7 会专门讲。下面这段代码验证了「环境变量覆盖 .env、.env 覆盖默认值」:

import os
from pydantic import Field, SecretStr, ValidationError, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

class DatabaseSettings(BaseSettings):
    host: str = "localhost"
    port: int = 5432
    password: SecretStr = SecretStr("default_pass")

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="APP_",
        env_nested_delimiter="__",
        extra="ignore",
    )
    name: str = "default_name"
    port: int = 9000
    debug: bool = False
    db: DatabaseSettings = Field(default_factory=DatabaseSettings)

    @field_validator("port")
    @classmethod
    def port_in_range(cls, v: int) -> int:
        if not 1024 <= v <= 65535:
            raise ValueError("端口必须在 1024~65535 之间")
        return v

os.environ["APP_NAME"] = "orders-api"          # 覆盖 .env 里的 from_dotenv
os.environ["APP_DEBUG"] = "yes"                # "yes" -> True
os.environ["APP_DB__PASSWORD"] = "s3cr3t"      # 嵌套字段用 __ 分隔
s = Settings()
print("name          =", s.name)
print("port          =", s.port, "(来自 .env)")
print("debug         =", s.debug)
print("db.password   =", s.db.password)         # SecretStr 默认遮罩
print("真实密码       =", s.db.password.get_secret_value())
print("db.host       =", s.db.host, "(默认值)")
name          = orders-api
port          = 8000 (来自 .env)
debug         = True
db.password   = **********
真实密码       = s3cr3t
db.host       = localhost (默认值)

六个字段恰好演示了三条来源:name 来自环境变量(赢了 .env)、port 来自 .env(赢了默认值)、db.host 来自默认值。一句话记住优先级:越靠近部署环境(进程)的越优先,越靠近代码(默认值)的越兜底。

3.1.4 类型转换与校验

pydantic 会在读取时自动做类型转换,debug: bool 收到字符串 "yes" 会转成 True,port: int 收到 "8000" 会转成 8000。转换失败或业务约束不满足时,抛的是标准 ValidationError:

os.environ["APP_PORT"] = "80"
try:
    Settings()
except ValidationError as e:
    print(e)
1 validation error for Settings
port
  Value error, 端口必须在 1024~65535 之间 [type=value_error, input_value='80', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/value_error

报错信息里 input_value='80' 明确告诉你原始值是什么,input_type=str 说明它来自环境变量。这个错误应该在进程启动时立刻抛出,而不是等到真正去连数据库才炸——这是「快速失败」在配置层的体现。可以在入口处主动触发一次:

def load_settings() -> Settings:
    try:
        return Settings()
    except ValidationError as e:
        raise SystemExit(f"配置校验失败,进程退出:\n{e}")

3.1.5 SecretStr:让敏感值不落日志

密码、token、API key 绝不能出现在日志或异常堆栈里。SecretStr 是一个「只进不出」的包装:直接打印它只会得到遮罩,想拿真值必须显式调用 get_secret_value()。

from pydantic import SecretStr

class Creds(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", extra="ignore")
    api_key: SecretStr = SecretStr("")

c = Creds(_env_file=".env.prod")
print(c.api_key)                     # 打印/日志里只有遮罩
print(c.model_dump())                # 序列化也遮罩
print(c.api_key.get_secret_value())  # 只有这里能拿到真值
**********
{'api_key': SecretStr('**********')}
prod_key

关键点:repr、model_dump()、model_dump_json() 全都输出 **********,只有显式调用 get_secret_value() 才暴露明文。这意味着你误把 Settings 对象打进日志也不会泄露密钥,把「别忘了脱敏」从纪律问题降级成了默认行为。

3.1.6 嵌套配置与多 .env 合并

配置一多,扁平字段会失控。用嵌套模型分组,并在环境变量里用 env_nested_delimiter 指定的分隔符(默认 __)表达层级:

APP_DB__HOST=db.internal
APP_DB__PORT=5432
APP_REDIS__URL=redis://cache:6379/0

APP_DB__HOST 会映射到 db.host。多个 .env 也能按顺序合并,后面的覆盖前面的,常见做法是「公共基线 + 环境覆盖」:

class LogSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=(".env.base", ".env.local"),   # 元组:后者覆盖前者
        env_prefix="LOG_",
        extra="ignore",
    )
    level: str = "WARNING"
    format: str = "json"

s = LogSettings()
print("level =", s.level, "| format =", s.format)
level = DEBUG | format = text

.env.base 里写 LOG_LEVEL=INFO、LOG_FORMAT=text,.env.local 里写 LOG_LEVEL=DEBUG,最终 level=DEBUG、format=text——每个键独立地取「最后一个出现它的文件」。还可在实例化时用 _env_file 参数覆盖:

Prefixed(_env_file=".env.prod").name   # -> prod_name

3.1.7 两个必踩的坑

坑一:env_prefix 同时作用于 .env 的键。 前缀不是只加在环境变量上的,.env 文件里的键也要带前缀,否则读不到:

无 prefix 时读到 NAME: bare_name
有 prefix 时读到 APP_NAME: prefixed_name

同一个 .env 里 NAME 和 APP_NAME 都存在时,不带前缀的类读到 bare_name,带 env_prefix="APP_" 的类读到 prefixed_name。

坑二:pydantic-settings 的 extra 默认是 forbid,不是 ignore。 这和 pydantic.BaseModel 的行为相反:.env 或环境里多出一个未被声明的键,会直接 ValidationError。所以生产配置几乎总要显式写 extra="ignore",否则引入一个无关的环境变量就能让服务起不来。

pydantic_core._pydantic_core.ValidationError: 1 validation error for Bare
app_name
  Extra inputs are not permitted [type=extra_forbidden, input_value='prefixed_name', input_type=str]

3.1.8 缓存与依赖注入

配置对象只需构造一次:它不该在每个请求里重新解析 .env。用 functools.lru_cache 把工厂函数缓存起来,就能在 FastAPI 里当依赖注入,同时天然支持测试覆盖:

from functools import lru_cache
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient

@lru_cache
def get_settings() -> Settings:
    return Settings()          # 进程内只构造一次

app = FastAPI()

@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {"name": settings.name,
            "is_custom_key": settings.api_key.get_secret_value() != "dev-key"}

client = TestClient(app)
print(client.get("/info").json())

# 测试里覆盖依赖,注入假配置,不碰真实环境变量
app.dependency_overrides[get_settings] = lambda: Settings(
    name="test", api_key=SecretStr("x"))
print(client.get("/info").json())
{'name': 'svc', 'is_custom_key': False}
{'name': 'test', 'is_custom_key': True}

dependency_overrides 让测试无需 monkeypatch 环境变量就能替换整套配置——这是「配置即依赖」最实际的收益。注意 @lru_cache 的缓存键是调用参数,如果要用 Settings(_env_file="...") 这种带参构造,就得保证参数可哈希。

3.1.9 配置分层建议

层放什么存放位置
默认值本地开发可跑的最小集代码里的字段默认值
公共基线所有环境共用的非敏感项.env.base(可入库)
环境覆盖环境专属、含密钥.env.local / 环境变量(不入库)
运行时注入容器/编排平台注入K8s ConfigMap / Secret

.env 文件应写进 .gitignore;需要共享的模板提交为 .env.example。有了这层分工,「配置在哪个环境不对」就能像查代码一样定位。

小结

  • pydantic-settings 用 BaseSettings 把环境变量映射成有类型、有校验的字段,来源优先级是「构造参数 > 环境变量 > .env > 默认值」。
  • env_prefix 同时作用于环境变量与 .env 的键;extra 默认 forbid,生产配置务必显式写 extra="ignore"。
  • field_validator 把业务约束(如端口范围)前移到进程启动时,配合 SystemExit 实现快速失败。
  • SecretStr 让敏感值在 repr、model_dump()、JSON 序列化里全部遮罩,只有 get_secret_value() 能取明文。
  • 嵌套配置用 env_nested_delimiter(__),多 .env 按元组顺序后者覆盖前者。
  • 用 @lru_cache 缓存 get_settings(),在 FastAPI 里作依赖注入,测试用 dependency_overrides 替换。

配置解决了「进程启动时知道什么」,而进程运行起来后「发生了什么」需要另一套设施——下一节我们讲结构化日志,并把请求级的 trace_id 贯穿到每一行输出里。

阅读导航:上一节:多版本 Python 矩阵与 CI 缓存 · 下一节:结构化日志与链路追踪 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时