← 返回题目列表

FastAPI 的配置怎么管理?pydantic-settings 怎么用?

简单 第 20 / 27 题 更新于 2026/08/03
FastAPI配置管理pydantic-settings环境变量12factor

简化版

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 文件仅本地开发
4Secrets 目录/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 时代 BaseSettingspydantic 主包里,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 historydocker inspect 都能看到——要用运行时注入或 Docker secretssecrets_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 时就需要的用模块级 settingscreate_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列表的环境变量要写 JSONlru_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_fileenv_prefixcase_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 listpydantic-settingslistdictset 这类复杂类型,默认期望环境变量的值是 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 默认 TrueProdSettings 里则故意不给 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 historydocker 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