前端传了个 null,后端直接炸了——防御性编程原来这么重要!

聊聊 AI 后端那些事儿 · 第 3 篇 | 阅读大约需 11 分钟

那些奇葩的请求参数

小禾的接口定义得很清楚:

python 复制代码
@router.post("/generate")
async def generate(data: dict):
    prompt = data["prompt"]
    width = data["width"]
    height = data["height"]
    # ...

期望的请求体是这样的:

json 复制代码
{
    "prompt": "一个美丽的风景",
    "width": 1024,
    "height": 768
}

但生产环境收到的请求,让小禾大开眼界:

json 复制代码
{"prompt": null}
{"prompt": ""}
{"width": "1024"}
{"width": -100}
{"prompt": "test", "widht": 1024}
{}
{"prompt": "x" * 100000}

分别是:

  • null 值
  • 空字符串
  • 字符串而不是数字
  • 负数
  • 字段名拼写错误
  • 空对象
  • 超长字符串

每一个都能让后端崩溃。

python 复制代码
# 各种崩溃方式
data["prompt"]                    # KeyError: 'prompt'
data["prompt"].strip()            # AttributeError: 'NoneType' object...
int(data["width"])                # ValueError: invalid literal
model.generate(width=-100)        # 模型报错

小禾意识到:永远不要相信前端传来的数据。


Pydantic 救场

FastAPI 原生支持 Pydantic,可以自动验证请求数据。

小禾把 dict 换成了 Pydantic 模型:

python 复制代码
# app/models/schemas.py
from pydantic import BaseModel, Field
from typing import Optional

class GenerateShotImageRequest(BaseModel):
    """分镜图片生成请求"""

    # 必填字段
    prompt: str = Field(
        ...,  # ... 表示必填
        description="生成提示词",
        min_length=1,
        max_length=2000
    )

    # 可选字段(有默认值)
    width: int = Field(
        default=1024,
        description="图片宽度",
        ge=256,   # >= 256
        le=2048   # <= 2048
    )

    height: int = Field(
        default=1024,
        description="图片高度",
        ge=256,
        le=2048
    )

    seed: int = Field(
        default=-1,
        description="随机种子,-1 表示随机"
    )

    style: Optional[str] = Field(
        default=None,
        description="风格预设"
    )

在端点中使用:

python 复制代码
@router.post("/shot-image")
async def generate_shot_image(request: GenerateShotImageRequest):
    # request 已经是验证过的数据
    # 类型正确、范围合法、必填字段存在

    result = await adapter.generate(
        prompt=request.prompt,   # 一定是非空字符串
        width=request.width,     # 一定是 256-2048 的整数
        height=request.height,   # 一定是 256-2048 的整数
        seed=request.seed        # 一定是整数
    )
    return result

现在如果请求数据不合法,FastAPI 会自动返回 422 错误:

json 复制代码
{
    "detail": [
        {
            "loc": ["body", "prompt"],
            "msg": "Field required",
            "type": "missing"
        },
        {
            "loc": ["body", "width"],
            "msg": "Input should be greater than or equal to 256",
            "type": "greater_than_equal"
        }
    ]
}

清清楚楚,哪个字段有问题,什么问题。


自定义验证器

有些验证逻辑比较复杂,需要自定义:

python 复制代码
from pydantic import BaseModel, field_validator, model_validator

class GenerateShotImageRequest(BaseModel):
    prompt: str
    width: int = 1024
    height: int = 1024

    @field_validator('prompt')
    @classmethod
    def prompt_must_not_be_empty(cls, v: str) -> str:
        """提示词不能只有空白字符"""
        if not v.strip():
            raise ValueError('Prompt cannot be empty or whitespace only')
        return v.strip()  # 返回处理后的值

    @field_validator('width', 'height')
    @classmethod
    def dimensions_must_be_multiple_of_64(cls, v: int) -> int:
        """尺寸必须是 64 的倍数(某些模型要求)"""
        if v % 64 != 0:
            # 自动调整到最近的 64 倍数
            v = (v // 64) * 64
        return v

    @model_validator(mode='after')
    def check_aspect_ratio(self) -> 'GenerateShotImageRequest':
        """宽高比验证"""
        ratio = self.width / self.height
        if ratio > 4 or ratio < 0.25:
            raise ValueError('Aspect ratio must be between 1:4 and 4:1')
        return self

三种验证器:

  • @field_validator:验证单个字段
  • @model_validator:验证字段之间的关系
  • 返回值会替换原值,可以做自动修正

枚举类型限制

有些字段只能是特定的值:

python 复制代码
from enum import Enum

class ShotType(str, Enum):
    WIDE = "wide"
    MEDIUM = "medium"
    CLOSE_UP = "close-up"
    EXTREME_CLOSE_UP = "extreme-close-up"

class ControlType(str, Enum):
    CANNY = "canny"
    DEPTH = "depth"
    POSE = "pose"

class GenerateShotImageRequest(BaseModel):
    prompt: str
    shot_type: ShotType = ShotType.MEDIUM
    control_type: Optional[ControlType] = None

如果传了不在枚举里的值:

json 复制代码
{"shot_type": "super-close"}

会返回:

json 复制代码
{
    "detail": [{
        "loc": ["body", "shot_type"],
        "msg": "Input should be 'wide', 'medium', 'close-up' or 'extreme-close-up'",
        "type": "enum"
    }]
}

嵌套模型

复杂的请求体可以用嵌套模型:

python 复制代码
class AnimationParams(BaseModel):
    """动画参数"""
    duration: float = Field(default=3.0, ge=1.0, le=10.0)
    fps: int = Field(default=24, ge=12, le=60)
    camera_movement: Optional[str] = None

class CharacterRef(BaseModel):
    """角色参考"""
    image_path: str
    weight: float = Field(default=0.8, ge=0.0, le=1.0)

class GenerateVideoRequest(BaseModel):
    """视频生成请求"""
    shots: list[ShotData] = Field(..., min_length=1, max_length=100)
    animation: AnimationParams = Field(default_factory=AnimationParams)
    character_refs: list[CharacterRef] = Field(default_factory=list)

Pydantic 会递归验证嵌套的每一层。


条件验证

有时候字段之间有依赖关系:

python 复制代码
class GenerateShotImageRequest(BaseModel):
    prompt: str
    use_control_net: bool = False
    control_image_path: Optional[str] = None
    control_type: Optional[ControlType] = None

    @model_validator(mode='after')
    def validate_control_net(self) -> 'GenerateShotImageRequest':
        """如果开启 ControlNet,必须提供参考图"""
        if self.use_control_net:
            if not self.control_image_path:
                raise ValueError(
                    'control_image_path is required when use_control_net is True'
                )
            if not self.control_type:
                raise ValueError(
                    'control_type is required when use_control_net is True'
                )
        return self

这样就不会出现"开关打开了但没传图"的情况。


响应模型验证

不只是请求,响应也可以验证:

python 复制代码
class GenerationResult(BaseModel):
    """生成结果"""
    image_url: str
    seed: int
    generation_time: float

class GenerateShotImageResponse(BaseModel):
    """接口响应"""
    success: bool = True
    message: Optional[str] = None
    data: Optional[GenerationResult] = None


@router.post("/shot-image", response_model=GenerateShotImageResponse)
async def generate_shot_image(request: GenerateShotImageRequest):
    result = await generate(...)

    # 响应也会被验证
    return GenerateShotImageResponse(
        success=True,
        data=GenerationResult(
            image_url=result["url"],
            seed=result["seed"],
            generation_time=result["time"]
        )
    )

响应模型的好处:

  1. 自动过滤敏感字段(只返回模型定义的字段)
  2. 自动生成 API 文档
  3. 保证响应格式一致

美化验证错误响应

默认的 422 响应格式有点丑,可以自定义:

python 复制代码
from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError):
    """美化验证错误响应"""

    errors = []
    for error in exc.errors():
        field = ".".join(str(x) for x in error["loc"][1:])  # 跳过 "body"
        errors.append({
            "field": field,
            "message": error["msg"],
            "type": error["type"]
        })

    return JSONResponse(
        status_code=400,  # 用 400 而不是 422
        content={
            "success": False,
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "Request validation failed",
                "details": errors
            }
        }
    )

现在响应变成了:

json 复制代码
{
    "success": false,
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Request validation failed",
        "details": [
            {
                "field": "prompt",
                "message": "Field required",
                "type": "missing"
            },
            {
                "field": "width",
                "message": "Input should be greater than or equal to 256",
                "type": "greater_than_equal"
            }
        ]
    }
}

跟其他错误响应格式一致了。


验证规则速查表

小禾整理了一份常用验证规则:

验证类型 Pydantic 写法
必填 Field(...)
可选 Optional[T]T = None
默认值 Field(default=xxx)
最小值 Field(ge=xxx)Field(gt=xxx)
最大值 Field(le=xxx)Field(lt=xxx)
字符串长度 Field(min_length=x, max_length=y)
正则匹配 Field(pattern=r"xxx")
枚举值 使用 Enum 类型
列表长度 Field(min_length=x, max_length=y)
自定义逻辑 @field_validator
跨字段验证 @model_validator

验证流程图

flowchart TB A[收到请求] --> B{JSON 格式正确?} B -->|否| C[返回 400 JSON 解析错误] B -->|是| D{字段类型正确?} D -->|否| E[返回 400 类型错误] D -->|是| F{字段约束满足?} F -->|否| G[返回 400 约束错误] F -->|是| H{自定义验证通过?} H -->|否| I[返回 400 验证错误] H -->|是| J[进入业务逻辑]

层层过滤,确保进入业务逻辑的数据都是合法的。


防御性编程原则

小禾总结了几条原则:

  1. 所有接口都用 Pydantic 模型 :不要用 dict
  2. 必填字段用 ...:明确表示必填
  3. 数值类型加范围限制:避免负数、超大数
  4. 字符串加长度限制:防止超长输入
  5. 复杂逻辑用验证器:保持模型定义清晰
  6. 响应也用模型:保证格式一致

小禾的感悟

csharp 复制代码
前端传什么,
你永远猜不到。

null、空串、负数、超长,
每一个都是炸弹。

Pydantic 是防弹衣,
把炸弹挡在业务逻辑之外。

类型注解是第一道防线,
Field 约束是第二道,
验证器是第三道。

三道防线都过了,
才能进入你的代码。

别信任任何输入,
别假设任何数据,
防御性编程,
让你睡得安稳。

小禾把所有接口都加上了 Pydantic 验证。

从此,再也没有因为"前端传了个奇怪的值"而崩溃的情况了。


下一篇预告:用户说"太慢了",但我不知道慢在哪

一行代码,让你看清性能瓶颈。

敬请期待。

相关推荐
西召3 小时前
Spring Kafka 动态消费实现案例
java·后端·kafka
镜花水月linyi3 小时前
ThreadLocal 深度解析(上)
java·后端
镜花水月linyi3 小时前
ThreadLocal 深度解析(下)
java·后端
JavaEdge.3 小时前
Spring数据源配置
java·后端·spring
铭毅天下3 小时前
Spring Boot + Easy-ES 3.0 + Easyearch 实战:从 CRUD 到“避坑”指南
java·spring boot·后端·spring·elasticsearch
李慕婉学姐3 小时前
【开题答辩过程】以《基于Springboot的惠美乡村助农系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·spring boot·后端
无限大63 小时前
为什么计算机要使用二进制?——从算盘到晶体管的数字革命
前端·后端·架构
掘金一周3 小时前
数据标注平台正式上线啦! 标注赚现金,低门槛真收益 | 掘金一周 12.10
前端·人工智能·后端