本节目标:用 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 的默认优先级从高到低是:
- 构造时显式传入的参数(
Settings(port=1234)) - 环境变量
.env文件- 字段默认值
先准备一个 .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 缓存 · 下一节:结构化日志与链路追踪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。