FastAPI 的配置怎么管理?pydantic-settings 怎么用?
简化版
FastAPI 生态的标准做法是用 pydantic-settings(注意 Pydantic V2 后它从主包独立出来了,要单独 pip install pydantic-settings)——定义一个继承 BaseSettings 的类,它会自动从「环境变量 → .env 文件 → 字段默认值」按优先级读取并做类型转换和校验。相比手写 os.getenv(),它的三个好处是:① 类型安全(声明 PORT: int 就自动转换,转不了直接启动失败)、② 缺失即失败(没有默认值的字段等于「必须提供」,缺了在启动时就报错而不是运行到一半才崩)、③ 有 IDE 补全和静态检查。配置的核心原则来自十二要素应用:配置存在环境里,不在代码里——所以 .env 文件只用于本地开发且绝不提交(提交 .env.example),生产环境用真正的环境变量(容器 env、k8s Secret、云上的密钥管理服务)。工程上有两个关键实践:① 用 @lru_cache 包一个 get_settings() 函数——不只是为了避免重复解析,更重要的是把它做成依赖后可以在测试里用 dependency_overrides 整体替换;② 多环境用「基类 + 子类」或不同的 .env 文件,但生产环境的密钥永远不要落到文件里。几个容易踩的点:嵌套配置要用 __ 分隔符(FLASK 风格的 APP__DB__HOST)、列表和字典类型的环境变量要写成 JSON、.env 里的值全是字符串(靠 Pydantic 转换)、以及在 Docker 里 ENV 声明的变量会出现在 docker inspect 里(密钥要用 secret 挂载)。核心记忆:pydantic-settings 独立成包了;优先级:环境变量 > .env > 默认值;lru_cache + 依赖注入便于测试;.env 不提交、生产用真环境变量。
详细版
配置来源的优先级(从高到低):
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | 初始化参数 | Settings(debug=True) |
| 2 | 环境变量 | 生产用这个 |
| 3 | .env 文件 | 仅本地开发 |
| 4 | Secrets 目录 | /run/secrets/(Docker) |
| 5 | 字段默认值 | 没有默认值 = 必填 |
# ① ★基本用法★
# pip install pydantic-settings ★★注意要单独装★★
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, computed_field, field_validator
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
★extra="ignore"★, # ★★忽略无关的环境变量(必设)★★
case_sensitive=False, # 默认不区分大小写
env_nested_delimiter="__", # ★嵌套用 __★
)
# ★★没有默认值 = 必须提供,缺了启动就失败★★
SECRET_KEY: str
DATABASE_URL: str
# 有默认值 = 可选
APP_NAME: str = "MyAPI"
DEBUG: bool = False
PORT: int = 8000
LOG_LEVEL: str = "INFO"
REDIS_URL: str = "redis://localhost:6379/0"
# ★★列表/字典:环境变量里要写 JSON★★
CORS_ORIGINS: list[str] = [] # CORS_ORIGINS='["https://a.com"]'
FEATURE_FLAGS: dict[str, bool] = {}
# ★带约束★
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(30, ge=1, le=1440)
POOL_SIZE: int = Field(5, ge=1, le=50)
# ★★派生配置(computed_field)★★
@computed_field
@property
def async_database_url(self) -> str:
return self.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
# ★校验★
@field_validator("SECRET_KEY")
@classmethod
def key_strong_enough(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("SECRET_KEY 至少 32 字符")
return v
# ② ★★lru_cache + 依赖注入(便于测试)★★
@lru_cache
def get_settings() -> Settings:
return Settings() # ★★只解析一次★★
SettingsDep = Annotated[Settings, Depends(get_settings)]
@app.get("/info")
async def info(settings: SettingsDep):
return {"app": settings.APP_NAME}
# ★测试里整体替换★
app.dependency_overrides[get_settings] = lambda: Settings(
DATABASE_URL="sqlite+aiosqlite:///:memory:",
SECRET_KEY="x" * 32, DEBUG=True)
# ③ ★嵌套配置★
class DatabaseSettings(BaseSettings):
host: str = "localhost"
port: int = 5432
user: str = "postgres"
class Settings(BaseSettings):
model_config = SettingsConfigDict(★env_nested_delimiter="__"★)
db: DatabaseSettings = DatabaseSettings()
# ★环境变量:DB__HOST=... DB__PORT=5432★
# ④ ★多环境★
# 方式一:不同的 .env 文件
env = os.getenv("ENV", "development")
model_config = SettingsConfigDict(env_file=(".env", f".env.{env}"))
# ★后面的覆盖前面的★
# 方式二:子类
class Settings(BaseSettings): ... # 基类
class DevSettings(Settings):
DEBUG: bool = True
LOG_LEVEL: str = "DEBUG"
class ProdSettings(Settings):
DEBUG: bool = False
@lru_cache
def get_settings() -> Settings:
return {"dev": DevSettings, "prod": ProdSettings}[os.getenv("ENV", "dev")]()
# ⑤ ★启动自检(★很有价值★)★
def validate_production(s: Settings):
if s.ENV == "production":
assert not s.DEBUG, "生产不能开 DEBUG"
assert len(s.SECRET_KEY) >= 32
assert s.SECRET_KEY != "changeme" # ★★防默认值上线★★
assert s.DATABASE_URL.startswith("postgresql"), "生产不能用 SQLite"
assert s.CORS_ORIGINS and "*" not in s.CORS_ORIGINS
⚠️ 三个必须记住的点:①
pydantic-settings在 Pydantic V2 之后独立成了单独的包——V1 时代BaseSettings在pydantic主包里,V2 把它拆到了pydantic-settings,升级时会遇到ImportError: BaseSettings has been moved to the pydantic-settings package,需要pip install pydantic-settings并改 import。② 没有默认值的字段等于「必须提供」,这是个好特性而不是麻烦。SECRET_KEY: str(不给默认值)意味着启动时如果环境变量和.env里都没有,应用会直接崩溃并明确告诉你缺什么——远好过os.getenv("SECRET_KEY", "dev")那种静默降级到弱密钥、上线几个月后才被发现的写法。所以规则是:安全敏感和环境相关的配置(密钥、数据库地址、外部服务地址)绝不给默认值。③.env文件只用于本地开发,绝不提交,生产用真环境变量。原因有三:.env一旦进了 Git 历史就很难彻底清除(即使后来删了,历史提交里还在);容器化部署时挂载文件比注入环境变量麻烦;十二要素应用的原则就是「配置存在环境里」。正确做法是提交一个.env.example(列出所有需要的变量名和示例值,不含真实密钥),把.env加进.gitignore。
完整版教学
一、为什么不用 os.getenv
★ ★手写 os.getenv 的四个问题★:
# config.py
SECRET_KEY = os.getenv("SECRET_KEY", "dev") # ★★静默降级★★
PORT = int(os.getenv("PORT", "8000")) # ★要手动转换★
DEBUG = os.getenv("DEBUG", "False") == "True" # ★★布尔陷阱★★
CORS = os.getenv("CORS_ORIGINS", "").split(",") # ★列表要手动解析★
★ 问题:
① ★类型转换要手写,容易出错★
DEBUG = bool(os.getenv("DEBUG")) # ★★"False" → True!★★
② ★缺失时静默用默认值★(弱密钥上线)
③ ★没有校验★(PORT="abc" 要等到 int() 才报错)
④ ★没有 IDE 补全和类型检查★
settings.DATABSE_URL # ★拼错了运行时才知道★
★ ★pydantic-settings 的解法★:
class Settings(BaseSettings):
SECRET_KEY: str # ★★缺了就启动失败★★
PORT: int = 8000 # ★★自动转换 + 校验★★
DEBUG: bool = False # ★★"false"/"0"/"no" 都能正确识别★★
CORS_ORIGINS: list[str] = [] # ★JSON 自动解析★
★ ✓ ★启动时一次性校验所有配置,fail fast★
★ ✓ IDE 补全、mypy 检查
★ ★布尔值的处理(★手写最容易错★)★:
Pydantic 认这些为 True:
★"1", "true", "True", "TRUE", "yes", "y", "on", 1, True★
认这些为 False:
★"0", "false", "False", "no", "n", "off", 0, False★
其他值 → ★报错★(而不是静默变成 True)
★ 对比手写:bool("false") == ★True★ ← ★★经典 bug★★
★ ★列表和字典(★环境变量里要写 JSON★)★:
CORS_ORIGINS: list[str] = []
# ★环境变量:CORS_ORIGINS='["https://a.com","https://b.com"]'★
# ✗ CORS_ORIGINS=https://a.com,https://b.com → ★解析失败★
✓ 想支持逗号分隔要自己写 validator:
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def split_str(cls, v):
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
return v
★ ★extra 的处理(★必须设★)★:
model_config = SettingsConfigDict(★extra="ignore"★)
★ 为什么:★环境里有一大堆无关变量★(PATH、HOME、CI 的各种变量)
★ ✗ 不设的话 V2 默认是 "ignore",但★显式写出来更清晰★
★ ✗ 如果设成 "forbid" → ★环境里任何多余变量都会导致启动失败★
★ ★env_prefix(避免命名冲突)★:
model_config = SettingsConfigDict(★env_prefix="MYAPP_"★)
class Settings(BaseSettings):
database_url: str # ★读取 MYAPP_DATABASE_URL★
★ ✓ 多个应用共享环境时避免冲突
手写 os.getenv 有四个问题:类型转换要手写(bool("false") 是 True 这个经典 bug)、缺失时静默用默认值(弱密钥上线)、没有校验、没有 IDE 补全。pydantic-settings 的核心价值是「启动时一次性校验所有配置,fail fast」。布尔值的处理是它明显更好的地方——它认 "1"/"true"/"yes"/"on" 为真、"0"/"false"/"no"/"off" 为假,其他值直接报错而不是静默变成 True。列表和字典在环境变量里必须写 JSON('["https://a.com"]'),想支持逗号分隔要自己写 mode="before" 的 validator。extra="ignore" 要显式设(环境里总有一堆无关变量,设成 forbid 会导致启动失败)。
二、lru_cache 与依赖注入
★ ★为什么要包一层函数★:
# ✗ 模块级全局
settings = Settings()
# 到处 from app.core.config import settings
★ 问题:
① ★import 时就实例化★(测试里想换配置很麻烦)
② ★只能用 monkeypatch 逐个属性打补丁★
③ ★import 顺序敏感★
# ✓ ★函数 + lru_cache + 依赖★
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
★ ★lru_cache 的两个作用★:
① ★性能★:Settings() 每次要读 .env + 扫环境变量 + 校验所有字段
→ ★作为依赖时每个请求都调用,累积起来是浪费★
② ★★可测试性(更重要)★★:
app.dependency_overrides[get_settings] = lambda: TestSettings()
→ ★整体替换配置★(测试库、关限流、关缓存、bcrypt rounds 调低)
★ ★测试里的用法★:
@pytest.fixture
def test_settings():
return Settings(
DATABASE_URL="sqlite+aiosqlite:///:memory:",
SECRET_KEY="test" * 8,
★BCRYPT_ROUNDS=4★, # ★★默认 12,测试调 4 快 200 倍★★
★RATE_LIMIT_ENABLED=False★, # ★关限流★
★CACHE_BACKEND="null"★, # ★关缓存(避免测试间串)★
)
@pytest.fixture
def client(test_settings):
app.dependency_overrides[get_settings] = lambda: test_settings
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
★ ★lru_cache 的坑★:
① ★测试里改了环境变量不生效★
monkeypatch.setenv("DEBUG", "true")
get_settings() # ★★还是缓存的旧值★★
✓ ★get_settings.cache_clear()★
② ★带参数的 lru_cache 要注意★
@lru_cache
def get_settings(env: str): ... # ★不同 env 不同缓存项★
★ ★两种用法并存(★实际项目的常见做法★)★:
# core/config.py
@lru_cache
def get_settings() -> Settings: return Settings()
★settings = get_settings()★ # ★模块级,供 import 时就需要的地方用★
# main.py(import 时就要用)
app = FastAPI(debug=settings.DEBUG)
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS)
# 路由和 service(用依赖,便于测试)
async def handler(s: SettingsDep): ...
★ ★理由:中间件和 app 的构造发生在 import 时,拿不到依赖注入★
★ ★配置也可以分模块★:
class DatabaseSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="DB_")
url: str
pool_size: int = 5
class RedisSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="REDIS_")
url: str = "redis://localhost:6379/0"
@lru_cache
def get_db_settings() -> DatabaseSettings: return DatabaseSettings()
★ ✓ 大项目里避免一个巨大的 Settings 类
包一层 get_settings() 函数比模块级全局变量好——后者在 import 时就实例化,测试里只能用 monkeypatch 逐个属性打补丁。lru_cache 有两个作用:性能(避免每个请求都重新读 .env 和校验)、以及更重要的可测试性(做成依赖后能用 dependency_overrides 整体替换)。测试配置里几个实用项:BCRYPT_ROUNDS=4(默认 12,调到 4 快 200 倍)、关限流、关缓存。lru_cache 有个坑:测试里改了环境变量不生效,要 get_settings.cache_clear()。实际项目常见的做法是两种用法并存——模块级 settings 供 import 时就需要的地方用(中间件、create_app 里的配置),依赖注入的 SettingsDep 供路由和 service 用;理由是中间件和 app 的构造发生在 import 时,拿不到依赖注入。
三、多环境与密钥管理
★ ★十二要素原则:配置存在环境里★:
★ 判断标准:★"这个值在不同部署环境里会不会不同?"★
会 → ★配置★(数据库地址、密钥、外部服务 URL、日志级别)
不会 → ★代码★(路由定义、业务常量、枚举值)
★ ✗ 反例:把「订单超时时间 30 分钟」做成环境变量
→ ★它在所有环境都一样,是业务规则不是配置★
★ ★多环境的三种方式★:
① ★不同的 .env 文件(本地开发)★
env = os.getenv("ENV", "development")
model_config = SettingsConfigDict(
env_file=(".env", f".env.{env}")) # ★后面的覆盖前面的★
★ ✓ 本地切换方便
★ ✗ ★生产不该用文件★
② ★子类(配置差异大时)★
class Settings(BaseSettings): ...
class DevSettings(Settings):
DEBUG: bool = True
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"
class ProdSettings(Settings):
DEBUG: bool = False
# ★DATABASE_URL 没有默认值 = 必须从环境变量提供★
★ ✓ 类型安全、差异一目了然
③ ★★纯环境变量(生产标准)★★
# k8s
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: {name: db-secret, key: url}
★ ✓ ★密钥不落盘、可轮换、有审计★
★ ★★密钥管理的层次★★:
┌────────────────────────────────────────────────────┐
│ ✗ ★硬编码在代码里★ 最差(★进 Git 历史★) │
│ ✗ ★.env 提交到仓库★ 同上 │
│ △ ★.env 不提交 + 手动分发★ 小团队勉强 │
│ ✓ ★容器环境变量★ 常见 │
│ ✓ ★k8s Secret★ ★较好(可 RBAC 控制)★ │
│ ✓✓ ★密钥管理服务★ ★最好(Vault/KMS, │
│ ★支持轮换和审计★) │
└────────────────────────────────────────────────────┘
★ ★Docker 的坑★:
✗ Dockerfile 里 ★ENV SECRET_KEY=xxx★
→ ★★会写进镜像层,docker history 能看到★★
→ ★docker inspect 也能看到★
✓ 运行时注入:docker run -e SECRET_KEY=xxx
✓ ★或用 Docker secrets(/run/secrets/)★:
model_config = SettingsConfigDict(★secrets_dir="/run/secrets"★)
→ ★读文件 /run/secrets/secret_key★
★ ★.env.example(★必须提交★)★:
# .env.example
# 复制为 .env 并填入真实值
SECRET_KEY= # ★用 openssl rand -hex 32 生成★
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
REDIS_URL=redis://localhost:6379/0
CORS_ORIGINS=["http://localhost:3000"]
★ ✓ ★新人 clone 后知道要配什么★
★ ✓ 变量增减时 code review 能看到
★ ★.gitignore 必须有★:
.env
.env.*
!.env.example # ★★例外★★
★ ★密钥轮换★:
★ SECRET_KEY 换了 → ★所有 session 和未过期的令牌失效★
✓ Pydantic 里可以支持多个:
SECRET_KEY: str
SECRET_KEY_FALLBACKS: list[str] = [] # ★旧 key 仍可验证★
★ 平滑轮换:新的用新 key 签,旧的仍能验 → 过渡期后移除
十二要素原则的判断标准是「这个值在不同部署环境里会不会不同」——会就是配置,不会就是代码;把「订单超时 30 分钟」做成环境变量是反模式(它是业务规则不是配置)。多环境有三种方式:不同 .env 文件(本地)、子类(差异大时,类型安全)、纯环境变量(生产标准)。密钥管理有明确的层次,其中一个 Docker 特有的坑:Dockerfile 里用 ENV SECRET_KEY=xxx 会写进镜像层,docker history 和 docker inspect 都能看到——要用运行时注入或 Docker secrets(secrets_dir="/run/secrets")。.env.example 必须提交(新人 clone 后知道要配什么,变量增减在 code review 里可见),同时 .gitignore 里要有 .env 和 !.env.example 的例外。
四、常见配置项与自检
★ ★一个完整项目的配置清单★:
class Settings(BaseSettings):
# ★应用★
APP_NAME: str = "MyAPI"
ENV: Literal["development", "staging", "production"] = "development"
DEBUG: bool = False
VERSION: str = "0.1.0"
# ★★安全(★都不给默认值★)★★
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # ★短★
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
BCRYPT_ROUNDS: int = 12
# ★数据库★
DATABASE_URL: str
DB_POOL_SIZE: int = 5
DB_MAX_OVERFLOW: int = 10
DB_ECHO: bool = False
# ★缓存/队列★
REDIS_URL: str = "redis://localhost:6379/0"
CELERY_BROKER_URL: str | None = None
# ★网络★
CORS_ORIGINS: list[str] = []
ALLOWED_HOSTS: list[str] = ["*"]
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
# ★可观测★
LOG_LEVEL: str = "INFO"
LOG_FORMAT: Literal["json", "text"] = "text"
SENTRY_DSN: str | None = None
OTEL_ENDPOINT: str | None = None
# ★外部服务★
OPENAI_API_KEY: str | None = None
SMTP_HOST: str | None = None
# ★特性开关★
FEATURE_NEW_CHECKOUT: bool = False
★ ★★启动自检(强烈推荐)★★:
def check_production_config(s: Settings) -> None:
if s.ENV != "production":
return
errors = []
if s.DEBUG:
errors.append("生产不能开 DEBUG")
if len(s.SECRET_KEY) < 32:
errors.append("SECRET_KEY 太短")
if s.SECRET_KEY in {"changeme", "secret", "dev"}:
errors.append("★SECRET_KEY 是默认值★")
if "sqlite" in s.DATABASE_URL:
errors.append("生产不能用 SQLite")
if "*" in s.CORS_ORIGINS:
errors.append("★CORS 不能是 *★")
if "*" in s.ALLOWED_HOSTS:
errors.append("★ALLOWED_HOSTS 不能是 *★")
if not s.SENTRY_DSN:
logger.warning("未配置 Sentry")
if errors:
★raise RuntimeError("配置检查失败:\n" + "\n".join(errors))★
# ★在 create_app 或 lifespan 里调用 → 配置错误在启动时就暴露★
★ ★价值:防止"弱密钥/DEBUG 开着/CORS 全开"这类配置上线★
★ ★配置的日志(★小心泄露★)★:
# ✗ logger.info("config=%s", settings.model_dump()) # ★★含密钥★★
# ✓ 脱敏后打印
SENSITIVE = {"SECRET_KEY", "DATABASE_URL", "OPENAI_API_KEY", "SMTP_PASSWORD"}
def safe_dump(s: Settings) -> dict:
return {k: ("***" if k in SENSITIVE else v)
for k, v in s.model_dump().items()}
logger.info("启动配置 %s", safe_dump(settings))
★ ★启动时打印脱敏后的配置很有用★(确认环境变量真的生效了)
★ ★更好:用 SecretStr★
from pydantic import SecretStr
class Settings(BaseSettings):
SECRET_KEY: ★SecretStr★
# str(settings.SECRET_KEY) → ★"**********"★
# 取真实值:settings.SECRET_KEY.★get_secret_value()★
★ ✓ ★打日志、序列化、异常堆栈里都不会泄露★
★ ★特性开关(feature flag)★:
FEATURE_NEW_CHECKOUT: bool = False
if settings.FEATURE_NEW_CHECKOUT: ...
★ ✓ 灰度发布、快速回滚(★改环境变量重启即可,不用发版★)
★ ✗ ★开关太多会变成配置地狱★ → 上线稳定后及时清理
★ 复杂场景用专门的服务(LaunchDarkly、Unleash、Apollo)
一个完整项目的配置清单可以按「应用、安全、数据库、缓存队列、网络、可观测、外部服务、特性开关」八类组织,其中安全相关的都不给默认值。启动自检强烈推荐——它能防止「弱密钥、DEBUG 开着、CORS 全开」这类配置错误上线,在启动时就 fail fast 而不是等到被攻击才发现。打印配置要小心泄露,更好的做法是用 SecretStr 类型——它的 str() 是 **********,打日志、序列化、异常堆栈里都不会泄露,取真实值要显式调 get_secret_value()。特性开关的价值是「改环境变量重启即可,不用发版」,但开关太多会变成配置地狱,上线稳定后要及时清理。
五、实践细节
★ ★配置的注入位置★:
① ★import 时就需要的(用模块级 settings)★
app = FastAPI(debug=settings.DEBUG,
docs_url="/docs" if settings.DEBUG else None)
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS)
engine = create_async_engine(settings.async_database_url)
② ★运行时需要的(用依赖)★
async def handler(s: SettingsDep): ...
★ ★两者并存是正常的★
★ ★配置与 lifespan★:
@asynccontextmanager
async def lifespan(app: FastAPI):
s = get_settings()
★check_production_config(s)★ # ★★启动自检★★
logger.info("启动 env=%s version=%s", s.ENV, s.VERSION)
app.state.redis = await create_redis(s.REDIS_URL)
yield
await app.state.redis.close()
★ ★配置热更新(★通常不需要★)★:
★ ✗ 大多数配置★不该热更新★(重启是最简单可靠的)
✓ 确实需要动态调整的(限流阈值、特性开关):
→ ★放 Redis/配置中心,定期拉取★
→ ★不要混在 Settings 里★
★ ★区分:★
- ★Settings = 启动时确定的★(数据库地址、密钥)
- ★动态配置 = 运行时可变的★(限流值、开关)
★ ★类型注解的技巧★:
from typing import Literal
ENV: ★Literal["development", "staging", "production"]★ = "development"
→ ★非法值启动就报错★
from pydantic import PostgresDsn, RedisDsn, HttpUrl, EmailStr
DATABASE_URL: ★PostgresDsn★ # ★★格式校验★★
REDIS_URL: RedisDsn
WEBHOOK_URL: HttpUrl | None = None
ADMIN_EMAIL: EmailStr
from pathlib import Path
UPLOAD_DIR: Path = Path("/var/data/uploads") # ★自动转 Path 对象★
★ ★配置文件 vs 环境变量★:
┌────────────────────┬──────────────────────────────────┐
│ ★环境变量★ │ ★十二要素标准、容器友好、易注入★ │
│ │ ✗ ★结构扁平(嵌套要用 __)★ │
│ │ ✗ 全是字符串 │
│ ★配置文件(yaml/toml)│ ✓ 结构清晰、有注释 │
│ │ ✗ ★要挂载、要管理版本★ │
│ │ ✗ ★密钥不该放文件★ │
└────────────────────┴──────────────────────────────────┘
✓ ★折中:结构化的非敏感配置放文件,密钥走环境变量★
pydantic-settings 支持自定义 source:
@classmethod
def settings_customise_sources(cls, settings_cls, init_settings,
env_settings, dotenv_settings,
file_secret_settings):
return (init_settings, env_settings,
★YamlConfigSettingsSource(settings_cls)★,
dotenv_settings, file_secret_settings)
★ ★常见报错★:
┌────────────────────────────────────┬──────────────────┐
│ ImportError: BaseSettings has been │ ★装 pydantic-★ │
│ moved to pydantic-settings │ ★settings★ │
│ ValidationError: Field required │ ★环境变量没设★ │
│ Input should be a valid list │ ★列表要写 JSON★ │
│ 改了环境变量不生效 │ ★lru_cache 缓存★ │
│ Extra inputs are not permitted │ ★extra="forbid"★ │
│ 生产读到了本地的值 │ ★.env 被打进镜像★ │
└────────────────────────────────────┴──────────────────┘
配置的注入位置分两种:import 时就需要的用模块级 settings(create_app 里的中间件配置、engine 创建),运行时需要的用依赖——两者并存是正常的。类型注解有几个实用技巧:Literal 让非法值启动就报错、PostgresDsn/RedisDsn/HttpUrl/EmailStr 做格式校验、Path 自动转对象。配置热更新通常不需要——要区分「Settings 是启动时确定的」和「动态配置是运行时可变的」,后者(限流阈值、特性开关)应该放 Redis 或配置中心,别混在 Settings 里。环境变量和配置文件的折中做法是「结构化的非敏感配置放 YAML、密钥走环境变量」,pydantic-settings 支持通过 settings_customise_sources 自定义来源。
六、实践清单
★ 起步模板:
# core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr, computed_field
from functools import lru_cache
from typing import Literal
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", ★extra="ignore"★, case_sensitive=False)
ENV: Literal["development", "staging", "production"] = "development"
DEBUG: bool = False
★SECRET_KEY: SecretStr★ # ★★不给默认值★★
★DATABASE_URL: str★ # ★★不给默认值★★
REDIS_URL: str = "redis://localhost:6379/0"
CORS_ORIGINS: list[str] = []
LOG_LEVEL: str = "INFO"
@computed_field
@property
def async_database_url(self) -> str:
return self.DATABASE_URL.replace("postgresql://",
"postgresql+asyncpg://")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
★ 检查清单:
□ ★装了 pydantic-settings(V2 后独立成包)★
□ ★安全敏感的配置不给默认值★
□ ★密钥用 SecretStr★
□ ★extra="ignore"★
□ ★列表/字典的环境变量写 JSON★
□ ★lru_cache + 依赖注入(便于测试)★
□ ★.env 在 .gitignore 里,提交 .env.example★
□ ★生产用真环境变量,不用 .env 文件★
□ ★Dockerfile 里不写 ENV SECRET_KEY★
□ ★有启动自检(DEBUG/密钥强度/CORS)★
□ ★启动时打印脱敏后的配置★
□ ★用 Literal 约束枚举型配置★
□ ★测试里用 dependency_overrides 换配置★
★ ★配置分类的判断★:
┌──────────────────────────────┬──────────────────┐
│ ★不同环境不同 → 配置★ │ 数据库地址、密钥 │
│ ★所有环境一样 → 代码常量★ │ 业务规则、枚举 │
│ ★运行时要改 → 动态配置中心★ │ 限流值、开关 │
└──────────────────────────────┴──────────────────┘
★ 一句话总结:
★"用 pydantic-settings(V2 后要单独装),
优先级是『环境变量 > .env > 默认值』,
安全敏感的配置不给默认值让它缺失即失败;
用 lru_cache 包一个 get_settings() 做成依赖,
测试里就能整体替换;.env 只用于本地且绝不提交,
生产用真环境变量,密钥用 SecretStr 防日志泄露。"★
检查清单里 FastAPI/Pydantic 特有的四条:装 pydantic-settings(V2 后独立成包)、密钥用 SecretStr、列表的环境变量要写 JSON、lru_cache + 依赖注入便于测试。最后那张配置分类表很实用:不同环境不同的才是配置,所有环境一样的是代码常量,运行时要改的应该放动态配置中心。
记忆钩子:「FastAPI 生态的配置标准是 ★pydantic-settings★——★注意 Pydantic V2 后它从主包独立出来了,要单独 pip install,升级时会遇到 ‘BaseSettings has been moved to the pydantic-settings package’★。它按★『初始化参数 > 环境变量 > .env 文件 > secrets 目录 > 字段默认值』★的优先级读取并做类型转换和校验。相比手写 os.getenv 的三个好处:★① 类型安全★(尤其是布尔——它认 ‘1/true/yes/on’ 为真、‘0/false/no/off’ 为假、其他值报错,★而手写的 bool(‘false’) 是 True 是经典 bug★);★② 缺失即失败——没有默认值的字段等于『必须提供』,启动时就崩并告诉你缺什么★,★远好过 os.getenv(‘SECRET_KEY’, ‘dev’) 那种静默降级到弱密钥、几个月后才发现★;③ IDE 补全和静态检查。★所以规则是:安全敏感和环境相关的配置绝不给默认值★。★两个关键工程实践★:★① 用 @lru_cache 包一个 get_settings() 并做成依赖★——不只是避免重复解析 .env,★更重要的是测试里能用 dependency_overrides 整体替换配置★(换测试库、★把 BCRYPT_ROUNDS 从 12 调到 4 快 200 倍★、关限流关缓存);★注意 lru_cache 会让测试里改环境变量不生效,要 get_settings.cache_clear()★;★实际项目常见两种用法并存★——模块级 settings 供 import 时就需要的地方(中间件、engine 创建),依赖注入供路由和 service。★② 十二要素:配置存在环境里★——★.env 只用于本地开发且绝不提交(提交 .env.example,.gitignore 里加 !.env.example 例外),生产用真环境变量★。密钥管理的坑:★Dockerfile 里写 ENV SECRET_KEY=xxx 会进镜像层,docker history 和 inspect 都能看到★,要运行时注入或用 ★Docker secrets(secrets_dir=‘/run/secrets’)★;★密钥字段用 SecretStr★——str() 显示为掩码星号,★打日志、序列化、异常堆栈里都不泄露★,取值要显式 get_secret_value()。其他细节:★extra=‘ignore’ 要显式设★(环境里总有一堆无关变量)、★列表和字典在环境变量里必须写 JSON★(想支持逗号分隔要写 mode=‘before’ 的 validator)、★嵌套配置用 env_nested_delimiter=’__’★、★用 Literal 约束枚举型配置让非法值启动就报错★、★computed_field 派生配置★(如从 DATABASE_URL 生成 async 版本)。★强烈推荐写启动自检★:生产环境断言 DEBUG 关闭、SECRET_KEY 够长且不是默认值、不用 SQLite、CORS 和 ALLOWED_HOSTS 不是通配符——★让配置错误在启动时暴露而不是被攻击后才发现★。最后区分三类:★不同环境不同的是配置、所有环境一样的是代码常量、运行时要改的(限流值/开关)应该放动态配置中心而不是混进 Settings★。」
七、常见误区与追问
- 误区:从 Pydantic V1 升级到 V2 后,
from pydantic import BaseSettings还能用。 V2 把BaseSettings移到了独立的pydantic-settings包,直接 import 会报ImportError: BaseSettings has been moved to the pydantic-settings package. See https://docs.pydantic.dev/... for more details.。需要pip install pydantic-settings并把 import 改成from pydantic_settings import BaseSettings, SettingsConfigDict。同时配置方式也变了:内部类class Config改成model_config = SettingsConfigDict(...),其中env_file、env_prefix、case_sensitive这些参数名保持不变,但extra的默认行为、以及env_nested_delimiter的用法有调整。这是 Pydantic V2 迁移时最容易撞到的第一个错误——因为几乎所有 FastAPI 项目都有配置模块。 - 误区:给配置字段都加上默认值更保险,避免启动失败。 对安全敏感的配置恰恰相反——「缺失即失败」是特性而不是缺陷。
SECRET_KEY: str = "dev"这种写法的危险在于:部署时忘了设环境变量,应用会静默地用"dev"这个弱密钥启动,所有 session cookie 和 JWT 都能被伪造,而这个问题可能几个月都不会被发现(功能一切正常)。相反,写成SECRET_KEY: str(不给默认值)时,如果环境变量缺失,应用会在启动的第一秒就崩溃并明确告诉你Field required——运维立刻就知道少配了什么。同样的道理适用于DATABASE_URL(默认值指向 SQLite 会让生产静默用错数据库)、外部服务的地址和密钥。判断标准是「这个值配错或没配,后果有多严重」:严重的就别给默认值。非敏感的、有合理缺省的(日志级别、连接池大小、超时时间)则应该给默认值,减少必填项。 - 误区:把
.env文件提交到仓库,方便团队共享配置。 三个问题。① 密钥进了 Git 历史就很难彻底清除——即使你后来git rm并提交,历史提交里依然存在,任何有仓库读权限的人(包括离职员工、CI 系统、fork 的人)都能翻出来;要真正清除得用git filter-repo重写历史并强制推送,还要通知所有人重新 clone,而且最稳妥的做法是直接轮换所有泄露的密钥。② 不同环境的配置会打架——你提交的.env里是本地的数据库地址,同事 clone 下来跑不了,于是各自改,然后不断产生冲突。③ 违反十二要素原则——配置应该在环境里,代码库应该是「无配置」的,同一份代码能跑在任何环境。正确做法是:.env加进.gitignore,提交一份.env.example列出所有需要的变量名和示例值(不含真实密钥)——这样新人 clone 后知道要配什么,变量的增减也能在 code review 里被看到。 - 误区:
get_settings()用不用lru_cache无所谓,反正读文件很快。 性能只是次要理由,主要理由是可测试性。把配置做成一个函数(而不是模块级的settings = Settings()全局变量)并配合Depends(get_settings)使用后,测试里就能用app.dependency_overrides[get_settings] = lambda: TestSettings(...)整体替换配置——把数据库指向内存 SQLite、把BCRYPT_ROUNDS从 12 调到 4(快 200 倍)、关掉限流和缓存(避免测试之间互相串)。如果代码里到处from app.core.config import settings直接用全局变量,就只能用monkeypatch.setattr逐个属性打补丁,既啰嗦又容易漏。至于lru_cache的性能作用:Settings()每次实例化都要读.env文件、扫描环境变量、执行所有字段的校验——作为依赖时每个请求都会调用一次,累积起来是无谓的浪费。注意一个坑:加了lru_cache后,测试里monkeypatch.setenv改环境变量不会生效,需要get_settings.cache_clear()。 - 误区:环境变量里
CORS_ORIGINS=https://a.com,https://b.com就能解析成列表。 会报Input should be a valid list。pydantic-settings对list、dict、set这类复杂类型,默认期望环境变量的值是 JSON 格式——正确写法是CORS_ORIGINS='["https://a.com","https://b.com"]'(注意在 shell 里要用单引号包住,避免双引号被吞掉)。这个设计是为了消除歧义(逗号分隔无法表达嵌套结构、也无法处理值里本身含逗号的情况)。如果你确实想支持更友好的逗号分隔写法,可以加一个mode="before"的 validator:判断输入是字符串且不以[开头时,就按逗号切分。同类容易踩的还有:.env文件里的值不要加引号(KEY="value"可能会把引号也当成值的一部分,取决于解析器版本)、布尔值可以写true/1/yes但不要写True(虽然也能识别)以保持一致性。 - 追问:
SecretStr有什么用? 它是 Pydantic 提供的一个包装类型,用来防止敏感值在非预期的地方泄露。声明SECRET_KEY: SecretStr之后:str(settings.SECRET_KEY)返回的是'**********'而不是真实值,repr()也一样,model_dump()序列化出来是掩码,异常堆栈里打印这个对象时也是掩码。要拿到真实值必须显式调用.get_secret_value()——这个显式动作本身就是一层保护:你不会「不小心」把它打进日志。它解决的是几类很真实的泄露场景:① 启动时打印配置做确认(logger.info("config=%s", settings.model_dump())这行在没有SecretStr时会把数据库密码和 API Key 全部记进日志);② 异常上报(Sentry 会捕获局部变量,配置对象里的密钥会被一并上传);③ 调试时的print(settings)。对应的还有SecretBytes。实践建议:SECRET_KEY、数据库密码、所有第三方 API Key 都用SecretStr;如果 URL 里含密码(postgresql://user:pass@host/db),也应该用SecretStr或者拆成分离的字段。 - 追问:多环境配置该用「不同的 .env 文件」还是「Settings 子类」? 各有适用场景,而且可以并存。不同的
.env文件适合本地开发时快速切换(.env.development、.env.test),实现是env_file=(".env", f".env.{env}")——后面的文件覆盖前面的,这样公共配置放.env、环境特有的放.env.<env>。它的局限是只能改值、不能改结构或行为,而且生产环境不该用文件。Settings 子类适合环境之间差异较大的情况:DevSettings里可以把DATABASE_URL默认成本地 SQLite、DEBUG默认True;ProdSettings里则故意不给DATABASE_URL默认值(强制从环境变量提供),还能覆盖 validator 加上更严格的校验。它的好处是类型安全、差异一目了然、IDE 能补全。生产环境的正确答案始终是「纯环境变量」——k8s 的env+secretKeyRef、或者云上的密钥管理服务,因为这样密钥不落盘、可以轮换、有访问审计。实践组合:本地用.env文件、CI 用环境变量、生产用 Secret 注入,代码里用子类表达行为差异。 - 追问:配置需要支持热更新吗? 绝大多数配置不需要,重启是更简单可靠的做法。数据库地址、密钥、连接池大小这类配置,改了本来就应该重启(连接池已经建好了,改配置也不会重建);而现代部署方式下滚动重启的成本很低(k8s 几秒钟就能完成,还是零停机的)。真正需要「运行时可变」的是另一类东西:限流阈值、特性开关、降级策略、白名单——它们的特点是需要在不重启的情况下快速调整(比如线上出问题时立刻关掉某个功能)。这类应该和
Settings分开:放在 Redis 或配置中心(Apollo、Nacos、Consul),应用定期拉取或订阅变更。混在一起的坏处是:Settings本该是「启动时确定、不可变、有类型保证」的,一旦支持热更新就要处理并发读写、缓存失效、类型校验失败时的回退——复杂度大增。所以边界很清晰:Settings= 启动时确定的基础设施配置;动态配置 = 运行时可变的业务开关。如果只有一两个开关,用环境变量加重启也完全够用。
八、加强记忆
FastAPI 生态的配置标准是 pydantic-settings——注意 Pydantic V2 之后它从主包独立出来了,要单独 pip install,升级时会遇到 BaseSettings has been moved to the pydantic-settings package。它按**「初始化参数 > 环境变量 > .env 文件 > secrets 目录 > 字段默认值」的优先级读取并做类型转换和校验。相比手写 os.getenv 的三个好处:① 类型安全(尤其是布尔——它认 1/true/yes/on 为真、0/false/no/off 为假、其他值报错,而手写的 bool("false") 是 True 这个经典 bug);② 缺失即失败——没有默认值的字段等于「必须提供」,启动时就崩并明确告诉你缺什么,远好过 os.getenv("SECRET_KEY", "dev") 那种静默降级到弱密钥、几个月后才被发现的写法;③ IDE 补全和静态检查。所以规则是:安全敏感和环境相关的配置绝不给默认值。两个关键工程实践:① 用 @lru_cache 包一个 get_settings() 并做成依赖——不只是避免重复解析 .env,更重要的是测试里能用 dependency_overrides 整体替换配置(换测试库、把 BCRYPT_ROUNDS 从 12 调到 4 快 200 倍、关限流关缓存);注意 lru_cache 会让测试里改环境变量不生效,要 get_settings.cache_clear();实际项目里常见两种用法并存——模块级 settings 供 import 时就需要的地方用(中间件配置、engine 创建),依赖注入供路由和 service 用。② 十二要素原则:配置存在环境里——.env 只用于本地开发且绝不提交**(提交 .env.example,.gitignore 里加 !.env.example 例外),生产用真环境变量。密钥管理有个 Docker 特有的坑:Dockerfile 里写 ENV SECRET_KEY=xxx 会写进镜像层,docker history 和 docker inspect 都能看到,要用运行时注入或 Docker secrets(secrets_dir="/run/secrets");密钥字段用 SecretStr——它的 str() 显示为 **********,打日志、序列化、异常堆栈里都不会泄露,取真实值要显式调 get_secret_value()。其他细节:extra="ignore" 要显式设(环境里总有一堆无关变量)、列表和字典在环境变量里必须写 JSON(想支持逗号分隔要写 mode="before" 的 validator)、嵌套配置用 env_nested_delimiter="__"、用 Literal 约束枚举型配置让非法值启动就报错、computed_field 派生配置(比如从 DATABASE_URL 生成 async 版本)。强烈推荐写启动自检:生产环境断言 DEBUG 关闭、SECRET_KEY 够长且不是默认值、不用 SQLite、CORS 和 ALLOWED_HOSTS 不是 *——让配置错误在启动时暴露,而不是被攻击后才发现。最后要区分三类:不同环境不同的是配置、所有环境一样的是代码常量、运行时要改的(限流值、开关)应该放动态配置中心而不是混进 Settings。