Python技术迷

还在手写 os.getenv?pydantic-settings 让你配置管理效率翻倍

线上接口刚起来就挂,日志里只有一句:

ValueError: invalid literal for int() with base 10: ''

我第一眼不会去翻业务代码,先看配置。

尤其是 Python 项目,十个里面有七八个会在启动脚本里塞一堆 os.getenv。刚开始看着挺轻,后面配置一多,味道就出来了。

import os

DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", "5432"))
REDIS_TTL = int(os.getenv("REDIS_TTL", "300"))
DEBUG = os.getenv("DEBUG", "false") == "true"

这段代码最麻烦的地方,不是丑。

是它太“相信环境变量”了。

DB_PORT="" 会炸。

DEBUG=True 不生效。

REDIS_TTL=abc 到运行时才报错。

线上容器里少挂了一个变量,你可能要等接口请求打进来才知道。

这种配置,我现在一般不手写了,直接用 pydantic-settings 收掉。

安装:

pip install pydantic-settings

配置文件先放一个 .env,本地开发够用了:

APP_NAME=order-api
APP_ENV=dev

DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=order_rw
DB_PASSWORD=local_pass
DB_NAME=order_center


REDIS_URL=redis://127.0.0.1:6379/0
REQUEST_TIMEOUT=2.5
ENABLE_TRACE=true

然后写一个配置类,不要散在各个模块里到处读环境变量。

from functools import lru_cache
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

classAppSettings(BaseSettings):
    app_name: str = "demo-api"
    app_env: str = Field(default="dev", pattern="^(dev|test|prod)$")

    db_host: str
    db_port: int = Field(default=5432, ge=1, le=65535)
    db_user: str
    db_password: SecretStr
    db_name: str

    redis_url: str
    request_timeout: float = Field(default=3.0, gt=0, le=30)
    enable_trace: bool = False

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )

    @field_validator("db_host")
    @classmethod
defdb_host_not_blank(cls, value: str) -> str:
        value = value.strip()
ifnot value:
raise ValueError("DB_HOST 不能为空")
return value

    @property
defdb_dsn(self) -> str:
        pwd = self.db_password.get_secret_value()
return (
f"postgresql://{self.db_user}:{pwd}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
        )

@lru_cache
defget_settings() -> AppSettings:
return AppSettings()

这段代码的好处很直接。

配置读进来以后,类型已经处理完了。DB_PORT 是 int,ENABLE_TRACE 是 bool,REQUEST_TIMEOUT 是 float。

不是你自己一行一行转。

我比较在意的是启动失败要早。

比如把 .env 里的端口写错:

DB_PORT=abc

启动时会直接报:

db_port
  Input should be a valid integer

这个错误比接口跑到一半再炸舒服太多。

配置这种东西,越早死越好。别等请求进来,别等定时任务跑起来,更别等半夜报警把人叫醒。

在业务里用的时候,也别每个文件都 AppSettings() 一次。

settings = get_settings()

defbuild_db_pool_config() -> dict:
return {
"dsn": settings.db_dsn,
"min_size": 2,
"max_size": 10,
"command_timeout": settings.request_timeout,
    }

defshould_record_trace() -> bool:
return settings.app_env != "dev"and settings.enable_trace

这里用了 lru_cache,配置只会初始化一次。

不要小看这个细节。有些项目喜欢在函数里反复 new 配置类,跑单测时没感觉,线上频繁调用就很烦。配置不是业务数据,没必要每次重新解析一遍。

还有一个坑,密码不要随手打印。

SecretStr 不是摆设。

settings = get_settings()

print(settings.db_password)

输出大概是:

**********

当然,你真要拼接 DSN,还是能通过 get_secret_value() 拿到原值。这个设计比较合适:默认不暴露,需要用的时候你自己明确取。

我见过不少 Python 项目,启动日志里直接把全量配置 dump 出来,数据库密码、Redis 密码、第三方 token 全在里面。排查问题是方便了,安全也基本没了。

还有环境变量命名问题。

业务里一般喜欢大写下划线,比如 DB_HOST,代码里喜欢小写字段,比如 db_host。pydantic-settings 默认能处理这种映射,够用。

如果你们公司变量名已经乱了,比如历史包袱里叫 PG_HOST,新服务又想叫 DB_HOST,可以这样兜一下:

from pydantic import AliasChoices

classDbOnlySettings(BaseSettings):
    host: str = Field(validation_alias=AliasChoices("DB_HOST", "PG_HOST"))
    port: int = Field(default=5432, validation_alias=AliasChoices("DB_PORT", "PG_PORT"))

    model_config = SettingsConfigDict(env_file=".env")

这东西我一般只在迁移期用。

别长期兼容一堆名字。配置名越多,越没人知道线上到底读的是哪个。

最后再看一段入口代码。

defboot_check() -> None:
    settings = get_settings()

    print(
f"boot app={settings.app_name}, "
f"env={settings.app_env}, "
f"db={settings.db_host}:{settings.db_port}, "
f"trace={settings.enable_trace}"
    )

if __name__ == "__main__":
    boot_check()

生产环境里,我会在应用启动阶段就跑一次配置加载。

能启动,说明配置基本过了类型校验和规则校验。

不能启动,就让它直接失败。

比起手写一屏 os.getenv,pydantic-settings 真正省的不是那几行代码,而是后面排查配置问题的时间。

配置管理最怕的不是复杂,是散。

一个模块读 os.getenv("DB_HOST"),另一个模块读 os.environ["DATABASE_HOST"],还有一个地方自己写默认值。等线上出问题,连当前服务到底用了哪份配置都说不清。

把配置收进一个 Settings 类里,类型、默认值、校验、敏感字段,一次性放干净。

这事不花哨,但很值。