13.2 数据模型设计

配套代码:code/chapter13

13.2.1 Web 应用中的数据流转

在构建智能旅行助手时,我们需要解决一个核心问题:如何表示和传递旅行计划数据?

我们需要理解一个完整的 Web 应用中数据是如何流转的。想象一下,当用户在浏览器中点击"开始规划"按钮时,会发生什么?

用户在前端填写的表单数据(目的地、日期、预算等)需要通过 HTTP 请求发送到后端服务器。后端接收到数据后,会调用智能体系统进行处理。智能体又会调用高德地图 API、Unsplash API 等外部服务获取数据。这些外部 API 返回的数据格式各不相同,有的用lng,有的用lon,有的用longitude。最后,后端需要将处理好的数据返回给前端,前端再渲染成用户看到的页面。

在这个过程中,数据经历了多次转换:前端表单 → HTTP 请求 → 后端 Python 对象 → 外部 API 响应 → 后端 Python 对象 → HTTP 响应 → 前端 TypeScript 对象 → 页面展示。如果没有统一的数据格式,每一步转换都可能出错。这就是为什么我们需要数据模型。

13.2.2 从字典到 Pydantic 模型

让我们从第一章的简单原型开始。在那个原型中,我们使用 Python 字典来表示景点数据:

# 第一章的做法:使用字典
attraction = {
    "name": "故宫",
    "location": {"lng": 116.397128,"lat": 39.916527},
    "price": 60
}

# 访问数据
lng = attraction["location"]["lng"]

这种方式在原型阶段很方便,但在实际项目中会遇到很多问题。首先是字段名不统一的问题。高德地图 API 返回的位置数据是"116.397128,39.916527"这样的字符串,需要手动分割成经纬度。而 Unsplash API 可能使用longitude和latitude。如果我们在代码中到处都用字典,就需要在每个地方都处理这些差异。

其次是类型安全的问题。假设我们不小心把price写成了字符串"60",在 Python 中这不会立即报错,但在计算总预算时就会出问题。更糟糕的是,这种错误只能在运行时才能发现,而且错误信息可能很难定位。

最后是维护性的问题。当我们需要给景点添加新字段(比如rating评分)时,需要在代码的多个地方修改。如果遗漏了某个地方,就会导致数据不一致。

Pydantic 提供了一个解决方案。它是 Python 的数据验证库,可以让我们用类来定义数据结构,并自动处理验证、转换和序列化。让我们看一个简单的例子:

from pydantic import BaseModel,Field

class Location(BaseModel):
    longitude: float = Field(...,description="经度")
    latitude: float = Field(...,description="纬度")

class Attraction(BaseModel):
    name: str
    location: Location
    ticket_price: int = 0

# 创建对象
attraction = Attraction(
    name="故宫",
    location=Location(longitude=116.397128,latitude=39.916527),
    ticket_price=60
)

# 类型安全的访问
lng = attraction.location.longitude  # IDE会提供代码补全

这样做有几个好处。首先,如果我们传入了错误的类型(比如把ticket_price设为字符串),Pydantic 会立即抛出异常,告诉我们哪里出错了。其次,IDE 可以根据类型定义提供代码补全和类型检查,大大减少了拼写错误。最后,当我们需要修改数据结构时,只需要修改类定义,所有使用这个类的地方都会自动更新。

13.2.3 Pydantic 的核心概念

在深入设计我们的数据模型之前,让我们先了解 Pydantic 的几个核心概念。Pydantic 的基础是BaseModel类,所有的数据模型都需要继承这个类。每个字段都可以指定类型,Pydantic 会自动进行类型检查和转换。

字段定义使用Field函数,它可以指定默认值、描述、验证规则等。...表示这个字段是必填的,如果创建对象时没有提供这个字段,Pydantic 会抛出异常。我们也可以使用Optional来表示可选字段,或者直接提供默认值。

from pydantic import BaseModel,Field
from typing import Optional,List

class Attraction(BaseModel):
    name: str = Field(...,description="景点名称")  # 必填
    rating: float = Field(default=0.0,ge=0,le=5)  # 默认值,范围验证
    visit_duration: int = Field(default=60,gt=0)  # 大于0
    description: Optional[str] = None  # 可选字段

Pydantic 还支持嵌套模型和列表。我们可以在一个模型中使用另一个模型作为字段类型,这样就可以构建复杂的数据结构。比如,一个景点包含位置信息,一个行程包含多个景点。

class DayPlan(BaseModel):
    date: str
    attractions: List[Attraction]  # 景点列表
    hotel: Optional[Hotel] = None  # 可选的酒店信息

最强大的功能之一是自定义验证器。有时候外部 API 返回的数据格式不符合我们的要求,我们可以使用field_validator装饰器来自定义验证和转换逻辑。比如,高德地图返回的温度是"16°C"这样的字符串,我们需要把它转换成数字:

from pydantic import field_validator

class WeatherInfo(BaseModel):
    temperature: int
    
    @field_validator('temperature',mode='before')
    def parse_temperature(cls,v):
        """解析温度字符串:"16°C" -> 16"""
        if isinstance(v,str):
            v = v.replace('°C','').replace('℃','').strip()
            return int(v)
        return v

这个验证器会在创建对象之前自动执行,将字符串转换成整数。这样我们就不需要在代码的每个地方都手动处理温度格式了。

13.2.4 自底向上的模型设计

现在让我们开始设计智能旅行助手的数据模型。一个好的设计原则是自底向上:先定义最基础的模型,然后逐步组合成复杂的结构。这样做的好处是每个模型都很简单,容易理解和维护。

最基础的模型是位置信息。无论是景点、酒店还是餐厅,都需要位置信息。我们定义一个Location类来表示经纬度坐标:

class Location(BaseModel):
    """位置信息(经纬度坐标)"""
    longitude: float = Field(...,description="经度",ge=-180,le=180)
    latitude: float = Field(...,description="纬度",ge=-90,le=90)

这里我们使用了范围验证(ge表示大于等于,le表示小于等于),确保经纬度的值在合理范围内。

接下来是景点信息。一个景点包含名称、地址、位置、游览时间、描述、评分、图片和门票价格等信息。注意我们使用了Location作为字段类型,这就是嵌套模型:

class Attraction(BaseModel):
    """景点信息"""
    name: str = Field(...,description="景点名称")
    address: str = Field(...,description="地址")
    location: Location = Field(...,description="经纬度坐标")
    visit_duration: int = Field(...,description="建议游览时间(分钟)",gt=0)
    description: str = Field(...,description="景点描述")
    category: Optional[str] = Field(default="景点",description="景点类别")
    rating: Optional[float] = Field(default=None,ge=0,le=5,description="评分")
    image_url: Optional[str] = Field(default=None,description="图片URL")
    ticket_price: int = Field(default=0,ge=0,description="门票价格(元)")

类似地,我们定义餐饮信息和酒店信息。这些模型的结构都很相似,都包含名称、地址、位置和费用等基本信息:

class Meal(BaseModel):
    """餐饮信息"""
    type: str = Field(...,description="餐饮类型:breakfast/lunch/dinner/snack")
    name: str = Field(...,description="餐饮名称")
    address: Optional[str] = Field(default=None,description="地址")
    location: Optional[Location] = Field(default=None,description="经纬度坐标")
    description: Optional[str] = Field(default=None,description="描述")
    estimated_cost: int = Field(default=0,description="预估费用(元)")

class Hotel(BaseModel):
    """酒店信息"""
    name: str = Field(...,description="酒店名称")
    address: str = Field(default="",description="酒店地址")
    location: Optional[Location] = Field(default=None,description="酒店位置")
    price_range: str = Field(default="",description="价格范围")
    rating: str = Field(default="",description="评分")
    distance: str = Field(default="",description="距离景点距离")
    type: str = Field(default="",description="酒店类型")
    estimated_cost: int = Field(default=0,description="预估费用(元/晚)")

预算信息是一个特殊的模型,它不包含位置信息,而是包含各项费用的汇总:

class Budget(BaseModel):
    """预算信息"""
    total_attractions: int = Field(default=0,description="景点门票总费用")
    total_hotels: int = Field(default=0,description="酒店总费用")
    total_meals: int = Field(default=0,description="餐饮总费用")
    total_transportation: int = Field(default=0,description="交通总费用")
    total: int = Field(default=0,description="总费用")

现在我们可以组合这些基础模型,构建单日行程。一个单日行程包含日期、描述、交通方式、住宿安排、酒店、景点列表和餐饮列表:

class DayPlan(BaseModel):
    """单日行程"""
    date: str = Field(...,description="日期")
    day_index: int = Field(...,description="第几天(从0开始)")
    description: str = Field(...,description="当日行程描述")
    transportation: str = Field(...,description="交通方式")
    accommodation: str = Field(...,description="住宿安排")
    hotel: Optional[Hotel] = Field(default=None,description="酒店信息")
    attractions: List[Attraction] = Field(default_factory=list,description="景点列表")
    meals: List[Meal] = Field(default_factory=list,description="餐饮安排")

注意这里使用了List[Attraction]来表示景点列表,default_factory=list表示默认值是一个空列表。

天气信息需要特殊处理,因为高德地图返回的温度格式不规范。我们使用自定义验证器来处理:

class WeatherInfo(BaseModel):
    """天气信息"""
    date: str = Field(...,description="日期")
    day_weather: str = Field(...,description="白天天气")
    night_weather: str = Field(...,description="夜间天气")
    day_temp: int = Field(...,description="白天温度(摄氏度)")
    night_temp: int = Field(...,description="夜间温度(摄氏度)")
    wind_direction: str = Field(...,description="风向")
    wind_power: str = Field(...,description="风力")
    
    @field_validator('day_temp','night_temp',mode='before')
    def parse_temperature(cls,v):
        """解析温度字符串:"16°C" -> 16"""
        if isinstance(v,str):
            v = v.replace('°C','').replace('℃','').replace('°','').strip()
            try:
                return int(v)
            except ValueError:
                return 0  # 容错处理
        return v

最后,我们定义完整的旅行计划。这是最顶层的模型,包含了所有的信息:

class TripPlan(BaseModel):
    """旅行计划"""
    city: str = Field(...,description="目的地城市")
    start_date: str = Field(...,description="开始日期")
    end_date: str = Field(...,description="结束日期")
    days: List[DayPlan] = Field(default_factory=list,description="每日行程")
    weather_info: List[WeatherInfo] = Field(default_factory=list,description="天气信息")
    overall_suggestions: str = Field(...,description="总体建议")
    budget: Optional[Budget] = Field(default=None,description="预算信息")

这样,我们就完成了整个数据模型的设计。从最基础的Location,到Attraction、Meal、Hotel,再到DayPlan,最后到TripPlan,形成了一个清晰的层次结构。

13.2.5 数据模型在 Web 应用中的应用

现在让我们看看这些数据模型如何在实际的 Web 应用中使用。在 FastAPI 中,Pydantic 模型可以直接用作请求和响应的类型定义。FastAPI 会自动进行数据验证、序列化和文档生成。

from fastapi import FastAPI
from app.models.schemas import TripPlanRequest,TripPlan

app = FastAPI()

@app.post("/api/trip/plan",response_model=TripPlan)
async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
    """
    创建旅行计划
    
    FastAPI自动:
    1. 验证请求数据(TripPlanRequest)
    2. 验证响应数据(TripPlan)
    3. 生成OpenAPI文档
    """
    trip_plan = await generate_trip_plan(request)
    return trip_plan

当用户发送 POST 请求到/api/trip/plan时,FastAPI 会自动将 JSON 数据转换成TripPlanRequest对象。如果数据格式不正确(比如缺少必填字段,或者类型不匹配),FastAPI 会自动返回 400 错误,并告诉用户哪里出错了。

在前端,我们也需要定义对应的 TypeScript 类型。虽然 TypeScript 和 Python 是不同的语言,但数据结构是一样的:

interface Location {
  longitude: number;
  latitude: number;
}

interface Attraction {
  name: string;
  address: string;
  location: Location;
  visit_duration: number;
  ticket_price: number;
}

interface TripPlan {
  city: string;
  start_date: string;
  end_date: string;
  days: DayPlan[];
}

这样,前后端就使用了统一的数据格式。当后端返回TripPlan对象时,前端可以直接使用,不需要任何转换。TypeScript 的类型检查也能帮助我们避免很多错误。