如何将上下文传递给 Pydantic 验证器

发布: (2026年2月15日 GMT+8 04:33)
9 分钟阅读
原文: Dev.to

I’m happy to translate the article for you, but I’ll need the text you’d like translated. Could you please paste the content (excluding the source line you already provided) here? Once I have the article text, I’ll translate it into Simplified Chinese while preserving the original formatting, markdown, and code blocks.

我有一个函数,需要根据调用它的人表现不同。

不是它做什么,而是它是否应该记录错误或保持安静。

听起来很简单。但事实并非如此。

设置

我们有一个电话号码规范化函数。它接受来自各种数据源的混乱电话号码,并将其统一格式化。有时这些号码是垃圾数据(缺少数字、格式错误、随机文本)。在这种情况下,函数会返回原始值并继续处理。

该函数被作为 BeforeValidator 接入我们的 Pydantic 模型:

from typing import Annotated
from pydantic import BeforeValidator

PhoneNumber = Annotated[str, BeforeValidator(normalize_phone_number)]

Pydantic 在构建包含电话号码字段的模型时会自动调用它。我们无法控制何时以及如何调用——Pydantic 只会传入该值并期待返回一个值。

问题

此函数在两种截然不同的上下文中运行:

  • API 请求 – 为用户提供数据。坏的电话号码是预期的(源数据可能很混乱)。在这里记录每个无效号码会在每次请求时用噪音淹没我们的日志。
  • 数据验证流水线 – 在部署之前检查新的数据文件。在这里我们想要看到每个无效号码,以便及早发现问题。

相同的函数。相同的 Pydantic 模型。两种不同的需求。

Source:

明显的想法(以及它们为何不可行)

1. 添加参数

def normalize_phone_number(value, log_errors=False):
    ...

BeforeValidator 只会用单个参数 (value) 调用该函数。无法传入额外的参数;函数签名是固定的。

2. 使用全局标志

_log_errors = False

def enable_logging():
    global _log_errors
    _log_errors = True

仅在一次只处理一个请求时有效。在 FastAPI 中,多个请求会并发执行,因此请求 A 设置的标志会被请求 B 看到,即使 B 并未请求日志记录。全局变量在所有并发请求之间共享——这不是我们想要的。

3. 复制函数

def normalize_phone_number(value):
    ...  # 不记录日志

def normalize_phone_number_with_logging(value):
    ...  # 记录日志

现在我们在复制归一化逻辑。因为函数是通过 BeforeValidator 绑定到 Pydantic 模型的,我们需要为验证管道单独创建一个模型。这会为一个简单的日志标志增加大量繁琐的代码。

引入 ContextVar

Python 的 contextvars 模块(自 3.7 起成为标准库)提供了在每个执行上下文中相互隔离的变量。当你在一个异步任务中设置 ContextVar 的值时,其他并发运行的任务看不到它——每个任务都有自己的副本。

下面是解决方案:

from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any
import logging

logger = logging.getLogger(__name__)

_validation_logging_enabled: ContextVar[bool] = ContextVar(
    "validation_logging_enabled", default=False
)

@contextmanager
def enable_validation_logging():
    token = _validation_logging_enabled.set(True)
    try:
        yield
    finally:
        _validation_logging_enabled.reset(token)

def log_validation_error_if_enabled(message: str, **kwargs: Any):
    if _validation_logging_enabled.get():
        logger.error(message, **kwargs)

这就是整个模块——除了标准库之外没有其他依赖。

它如何配合在一起

规范化函数调用条件日志记录器:

def normalize_phone_number(value):
    ...
    try:
        parsed = phonenumbers.parse(value)
    except NumberParseException as e:
        log_validation_error_if_enabled(
            "Invalid phone number format", value=value, error=str(e)
        )
        return value
    ...

数据验证管道在上下文管理器中包装其工作:

def validate_incoming_data(data, schema):
    with enable_validation_logging():
        result = validate_records(data, model=schema)
    return result

def validate_records(data, model):
    """Validate each data record through a Pydantic model."""
    ...

with 块内部,错误会被记录。块外(例如在 API 请求期间),则不会记录。无需在调用链中传递参数,也不必担心全局状态。

令牌模式

值得了解的细节:set() / reset(token) 模式。

token = _validation_logging_enabled.set(True)
# ... do work ...
_validation_logging_enabled.reset(token)

set() 返回一个表示先前值的令牌。reset(token) 会恢复那个先前的值,而不一定是 False。这意味着嵌套的 enable_validation_logging() 调用仍能正常工作——内部调用会重置为外部调用设置的值。

将其放在 try/finally 中可以保证即使抛出异常也能进行清理。

Python 3.14+ 快捷方式

从 Python 3.14 开始,令牌本身就是上下文管理器,因此可以省略包装器:

with _validation_logging_enabled.set(True):
    result = validate_records(data, model=schema)

with 块在退出时会自动调用 reset(token)。如果你使用的是 3.14 及以上版本,这就是最简洁的写法。

权衡

老实说,缺点是这是一种 “远程操作”。 规范化函数读取了一个它没有作为参数接收的标志。如果你第一次阅读该模块,可能不会意识到日志记录是由函数外部的某个东西控制的。

良好的文档字符串会有所帮助。对于我们的使用场景(一个简单的布尔标志,两个明确的上下文),这种权衡是值得的。另一种做法是重构 Pydantic 验证的工作方式,仅仅为了传递日志标志。

何时使用 ContextVar

ContextVar 适用于以下情况:

  • 需要在你无法控制的层级之间传递上下文(例如 Pydantic 验证器、中间件或第三‑方回调)。
  • 你处于异步环境中,全局状态不安全。
  • 上下文很简单(标志、请求 ID、关联令牌)。
  • 上下文用于横切关注点(日志、追踪、监控),而非业务逻辑。

适合作为复杂状态管理的工具。如果你需要将错误收集到列表中或构建丰富的上下文,存在更好的模式。但对于“此代码路径现在是否应稍有不同的行为?”的情况,它正合适。

测试它

这些测试验证行为的两方面。我们使用 pytest 的 caplog fixture,它捕获日志记录,以便我们可以断言是否发出了消息:

class TestLogPhoneNumberErrors:
    def test_no_logging_outside_context(self, caplog):
        normalize_phone_number("not a phone number")
        assert caplog.records == []

    def test_logs_error_inside_context(self, caplog):
        with enable_validation_logging():
            normalize_phone_number("not a phone number")
        assert "Invalid phone number format" in caplog.text

    def test_context_manager_resets_after_exit(self, caplog):
        with enable_validation_logging():
            pass
        normalize_phone_number("another invalid number")
        assert caplog.records == []

默认值为 False(静默)。您需要自行开启日志记录。现有代码的行为不会改变。

总结

整个改动只是一个小的新模块和几个测试用例。没有新增依赖。数据验证管道现在能够提前捕获错误的电话号码,API 请求日志保持干净。

有时正确的解决方案并不是新的库或巧妙的架构。有时它是你之前未使用过的标准库模块。

你在项目中使用过 ContextVar 吗? 我很想在评论中听听你的使用案例。

0 浏览
Back to Blog

相关文章

阅读更多 »

Vonage 开发者讨论

Dev Discussion 我们希望这里成为一个可以休息并讨论软件开发人性化方面的空间。第一话题:音乐 🎶 说到音乐……