3.4 按需要拆分环境配置
按需要拆分环境配置
配置也是输入数据,需要有类型、默认值和缺失时的明确错误。把 os.getenv() 散落在路由和服务里,会让同一个变量在不同模块出现不同默认值,也难以知道测试环境到底使用了什么。pydantic-settings 的 BaseSettings 可以把环境变量、.env 文件和默认值集中成可检查的模型。
先保留一个全局配置模型
全局配置通常包括运行环境、数据库地址和日志等级。只有一个领域确实有一组独立的配置语义时,才为它增加领域配置模型;不要给每个目录都创建只有一个字段的 Settings 类。下面的例子保留一个应用配置和一个身份领域配置,前者不读取身份密钥,后者也不负责数据库连接。
<!-- file: ch03_contracts/settings_demo.py -->
from __future__ import annotations
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Literal
from pydantic import Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
environment: Literal["dev", "test", "prod"] = "dev"
database_url: str
log_level: Literal["DEBUG", "INFO", "WARNING"] = "INFO"
class AuthSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="AUTH_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
jwt_secret: SecretStr
token_ttl_minutes: int = Field(default=30, ge=1, le=1440)
def self_check() -> None:
env_names = (
"APP_DATABASE_URL",
"APP_LOG_LEVEL",
"AUTH_JWT_SECRET",
"AUTH_TOKEN_TTL_MINUTES",
)
previous = {name: os.environ.get(name) for name in env_names}
try:
with TemporaryDirectory() as temporary:
env_file = Path(temporary) / ".env"
env_file.write_text(
"APP_DATABASE_URL=postgresql://demo/db
"
"APP_LOG_LEVEL=WARNING
"
"AUTH_JWT_SECRET=from-dotenv
"
"AUTH_TOKEN_TTL_MINUTES=20
",
encoding="utf-8",
)
for name in env_names:
os.environ.pop(name, None)
# 环境变量覆盖 .env。
os.environ["APP_LOG_LEVEL"] = "DEBUG"
app_settings = AppSettings(_env_file=env_file)
assert app_settings.database_url == "postgresql://demo/db"
assert app_settings.log_level == "DEBUG"
assert app_settings.environment == "dev"
# 初始化参数优先级高于环境变量和 .env。
explicit = AppSettings(_env_file=env_file, log_level="INFO")
assert explicit.log_level == "INFO"
auth_settings = AuthSettings(_env_file=env_file)
assert auth_settings.jwt_secret.get_secret_value() == "from-dotenv"
assert auth_settings.token_ttl_minutes == 20
assert "from-dotenv" not in repr(auth_settings)
# 关闭 .env 后又没有环境变量,必填 database_url 会失败。
for name in env_names:
os.environ.pop(name, None)
try:
AppSettings(_env_file=None)
except ValidationError as exc:
assert any(
error["loc"] == ("database_url",) for error in exc.errors()
)
else:
raise AssertionError("缺少 database_url 应该失败")
finally:
for name, value in previous.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
print("settings_demo self-check passed")
if __name__ == "__main__":
self_check()
在保存文件的目录运行:
python ch03_contracts/settings_demo.py
运行前安装本节新增的依赖:
python -m pip install "pydantic-settings" "python-dotenv"
预期输出为 settings_demo self-check passed。自检使用临时 .env,不会修改项目目录,也不会把真实密钥写入文件。
配置来源的覆盖顺序
在没有启用命令行设置源时,pydantic-settings 的默认优先级从高到低是:初始化参数、环境变量、.env 文件、文件密钥目录、字段默认值。示例中 .env 提供 APP_LOG_LEVEL=WARNING,进程环境提供 APP_LOG_LEVEL=DEBUG,所以最终值是 DEBUG;显式传入 log_level="INFO" 后,最终值又变成 INFO。
.env 适合本地开发和测试配置,不能替代生产密钥管理。把 .env 加入 .gitignore,提交不含秘密的 .env.example 作为字段说明;生产环境通过部署系统注入环境变量或受控的 secrets 目录。若项目自定义了 settings sources 或启用了 CLI 参数,覆盖顺序可能改变,应以实际配置和对应版本文档为准。
环境变量名称与类型解析
配置模型使用 env_prefix="APP_" 时,字段 database_url 对应 APP_DATABASE_URL,字段 log_level 对应 APP_LOG_LEVEL。环境变量本质上是字符串,pydantic-settings 会根据字段类型解析枚举、整数、布尔值和列表;解析失败会在配置实例化时抛出 ValidationError,应用应在启动阶段尽早失败,而不是等第一个请求到来才发现。
如果字段是数据库连接字符串,可以根据项目需要改用 Pydantic 的 PostgresDsn 等类型;示例使用 str 是为了把配置来源和覆盖规则单独讲清楚。类型越具体,启动时发现错误越早,但也要确认连接字符串格式和驱动版本一致。
身份配置使用 SecretStr,这样在对象表示和常见日志中不会直接显示密钥。真正调用签名库时才通过 get_secret_value() 取出原始字符串,并限制它的作用域。不要在异常、启动日志、调试接口或健康检查响应中打印这个值。
领域配置何时值得拆分
身份领域同时需要签名密钥、令牌有效期和 Cookie 安全策略时,独立的 AuthSettings 能让依赖它的模块明确声明配置来源。若某个目录只有一个 PAGE_SIZE 常量,直接使用全局配置或普通常量更简单;拆分的理由应是不同的命名空间、生命周期或安全边界,而不是目录数量。
配置模型应该在应用入口或明确的依赖创建一次,再传给需要它的组件。不要在每个请求里重新读取 .env,也不要在模块导入时无条件读取真实生产密钥来做与当前功能无关的初始化。测试时通过初始化参数或临时环境变量注入值,避免修改开发者机器的全局环境。
配置检查清单
- 必填配置是否没有伪造默认值,缺失时是否在启动阶段给出明确错误?
- 生产秘密是否来自环境或受控配置,且不会进入版本库、日志和响应?
.env是否只用于本地或测试,并被.gitignore排除?- 环境变量、
.env和初始化参数的优先级是否符合当前pydantic-settings配置? - 是否只有在确有命名空间或安全边界时才拆分领域配置?
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“拆分 Pydantic BaseSettings”主题。本节保留全局配置与身份领域配置分工的实践方向,并按
pydantic-settings当前配置源和密钥处理方式重写示例。 - 官方文档:pydantic-settings 配置源、环境变量与
.env文件、Pydantic SecretStr。