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 的类型检查也能帮助我们避免很多错误。