自我怀疑时刻
故事要从我一头扎进 FastAPI 到 Litestar 的迁移说起。那会儿我刚刚把 msgspec 整明白,写着各种显式的 schema 转换,心情美滋滋,代码也跑得欢。
然后,我犯了一个“程序员都会犯的错误”——去翻了下 Litestar 的全栈示例仓库。
1
2
3
4
5
6
7
8
9
10
|
class UserDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
exclude={"password_hash", "sessions", "oauth_accounts"},
rename_strategy="camel",
max_nested_depth=2,
)
@get("/users/{user_id}", return_dto=UserDTO)
async def get_user(self, user_id: UUID) -> User:
return await user_service.get(user_id)
|
等会儿,这啥?
控制器直接 return 原生 SQLAlchemy 模型?不用手动转换?不用专门写 schema?就……直接 return,剩下全靠 DTO 魔法?
关键是,例子里每个 endpoint 都是这么干的。每!一!个!
熟悉的程序员焦虑又来了:难道我姿势不对?
我的“原始”写法
来看看我当时的做法,完全不觉得自己有什么问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# 明确定义响应 schema
class UserResponse(CamelizedBaseStruct):
id: UUID
email: str
full_name: str | None = None
is_admin: bool = False
# 控制器里手动转换
@get("/profile")
async def profile(self, current_user: AppUser) -> UserResponse:
return UserResponse(
id=current_user.id,
email=current_user.email,
full_name=current_user.full_name,
is_admin=current_user.is_admin,
)
|
代码很清晰,暴露什么数据一目了然。
但看到 DTO 之后,心里就开始嘀咕:“是不是应该用 DTO?不然是不是太业余了?”
开始探案
作为一个自尊心旺盛的开发者,碰到自我怀疑第一件事——找 ChatGPT 背书。
“我是不是该用 Litestar 的 DTO 系统,而不是显式 msgspec schema?”
AI 给我的答案不再是“是”或“否”,而是“得看你实际需求”。
我才发现,原来不是所有“高级特性”都非用不可。关键得搞清楚 DTO 究竟是啥、解决了啥问题。
DTO 到底是啥?
DTO,全称 Data Transfer Object,中文一般叫“数据传输对象”。在 Litestar 里,它其实就是你内外数据模型之间的变形桥梁(比如 SQLAlchemy、Pydantic、msgspec 这些模型,和 API 对外的数据)。
你可以把 DTO 理解成一个智能的“模版系统”:
- “这些字段别暴露”
- “snake_case 自动转 camelCase”
- “只保留以下几个字段”
- “多级嵌套自动帮你处理”
相比每个 endpoint 都手写转换逻辑,DTO 只需配置一次,然后挂到路由上,剩下的活儿都自动搞定。
重点: DTO 是平台无关的。无论你后端用的是 Pydantic、msgspec、dataclasses 还是 SQLAlchemy,都有对应的 DTO 后端(PydanticDTO、MsgspecDTO、SQLAlchemyDTO),用法思想都一样。
但问题来了——我真的需要用 DTO 吗?
正面对比
上实战!比如我的用户认证接口:
我的原汁原味写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
# schemas.py - 明确暴露哪些字段
class LoginRequest(CamelizedBaseStruct):
email: str
password: str
class UserResponse(CamelizedBaseStruct):
id: UUID
email: str
full_name: str | None = None
is_admin: bool = False
class LoginResponse(CamelizedBaseStruct):
access_token: str
token_type: str = "Bearer"
expires_in: int
user: UserResponse
# controller.py
@post("/login")
async def login(
self,
data: LoginRequest,
user_service: UserService,
) -> LoginResponse:
user = await user_service.authenticate(data.email, data.password)
token = create_access_token(user.id)
return LoginResponse(
access_token=token,
expires_in=3600,
user=UserResponse(
id=user.id,
email=user.email,
full_name=user.full_name,
is_admin=user.is_admin,
),
)
|
代码量:清楚、直观,三十几行。
DTO 派的写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# schemas.py - 配置转换规则
class UserResponseDTO(SQLAlchemyDTO[AppUser]):
config = DTOConfig(
exclude={"password_hash", "sessions", "oauth_accounts", "credit_balance"},
rename_strategy="camel",
)
class LoginRequestDTO(MsgspecDTO[LoginRequest]):
config = DTOConfig(rename_strategy="camel")
# controller.py
@post("/login", data=LoginRequestDTO, return_dto=UserResponseDTO)
async def login(
self,
data: DTOData[LoginRequest],
user_service: UserService,
) -> AppUser:
request = data.create_instance()
user = await user_service.authenticate(request.email, request.password)
# ... token 创建
return user # DTO 自动处理转换
|
两个版本一比,DTO 好像反而更抽象、更绕?
我的场景:
DTO 配来配去,反而没省下多少代码,增加的抽象反而让人摸不着头脑。
灵光一闪的时刻
后来 ChatGPT 给我举了个例子,终于让我豁然开朗:
“假设你有个 User 模型有30个字段,10个 endpoint 只略有差别地返回用户信息。”
哦豁。
不用 DTO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
class UserListResponse(CamelizedBaseStruct):
id: UUID
email: str
username: str
full_name: str
# ... 26个字段
created_at: datetime
updated_at: datetime
class UserDetailResponse(CamelizedBaseStruct):
id: UUID
email: str
username: str
full_name: str
# ... 26个字段(和上面一样)
created_at: datetime
updated_at: datetime
last_login_at: datetime # 多了一个
login_count: int # 多了一个
class UserAdminResponse(CamelizedBaseStruct):
id: UUID
email: str
username: str
full_name: str
# ... 26个字段(还一样)
created_at: datetime
updated_at: datetime
last_login_at: datetime
login_count: int
password_hash: str # 这个只有管理员能看
|
你得复制粘贴28个字段到三个 schema。加新字段、改字段名,全得三处同步改,一不小心就出bug,重构的时候简直想哭。
用 DTO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# 只需配置差异,模型写一次
class UserListDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
exclude={"password_hash", "last_login_at", "login_count"},
rename_strategy="camel",
)
class UserDetailDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
exclude={"password_hash"},
rename_strategy="camel",
)
class UserAdminDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
rename_strategy="camel", # 全部字段都要
)
|
这下明白了吧。
DTO 真正适合的是:大模型,少量差异。
不用再玩“大家来找茬”,直接“除了X都要”或者“只要Y”一行搞定。
但我的认证接口?字段又少,结构又各不一样。
DTO 带来的“省事”对我来说等于0。
DTO 适合什么场景?
想明白 DTO 的定位后,我总结了几个典型适用场景:
1. 字段超多、接口高度相似
比如:
- 用户模型20+字段
- 商品有一堆元数据
- 管理后台有N个类似的 CRUD 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# 一个模型,多种“视图”
class ProductListDTO(SQLAlchemyDTO[Product]):
config = DTOConfig(
exclude={"internal_cost", "supplier_details", "inventory_history"},
)
class ProductDetailDTO(SQLAlchemyDTO[Product]):
config = DTOConfig(
exclude={"internal_cost", "supplier_details"}, # 多暴露一点
)
class ProductAdminDTO(SQLAlchemyDTO[Product]):
config = DTOConfig() # 管理员全都能看
|
字段写一次,配置不同。
2. 复杂嵌套关系
比如模型里有好几个关联字段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
# 模型结构
class Case(Base):
id: UUID
name: str
user: Mapped[User]
documents: Mapped[list[Document]]
workflow_task: Mapped[WorkflowTask]
# 不用 DTO,要手动写嵌套
class DocumentResponse(CamelizedBaseStruct):
id: UUID
filename: str
class WorkflowTaskResponse(CamelizedBaseStruct):
id: UUID
stage: str
class UserResponse(CamelizedBaseStruct):
id: UUID
email: str
class CaseResponse(CamelizedBaseStruct):
id: UUID
name: str
user: UserResponse
documents: list[DocumentResponse]
workflow_task: WorkflowTaskResponse
# 控制器里手动组装,累!
@get("/cases/{case_id}")
async def get_case(self, case_id: UUID) -> CaseResponse:
case = await case_service.get(case_id)
return CaseResponse(
id=case.id,
name=case.name,
user=UserResponse(id=case.user.id, email=case.user.email),
documents=[
DocumentResponse(id=doc.id, filename=doc.filename)
for doc in case.documents
],
workflow_task=WorkflowTaskResponse(
id=case.workflow_task.id,
stage=case.workflow_task.stage,
),
)
|
用 DTO 一行解决:
1
2
3
4
5
6
7
8
9
|
class CaseDTO(SQLAlchemyDTO[Case]):
config = DTOConfig(
max_nested_depth=2,
rename_strategy="camel",
)
@get("/cases/{case_id}", return_dto=CaseDTO)
async def get_case(self, case_id: UUID) -> Case:
return await case_service.get(case_id) # DTO 自动递归序列化
|
结果 JSON 结构自动递归搞定。
3. 多端点一致性变换
比如20个接口都要转 camelCase,你不用 DTO 得到处手写转换。
1
2
3
4
5
6
7
8
9
10
11
12
|
# 不用 DTO,每个 schema 都要写
class UserResponse(CamelizedBaseStruct):
id: UUID
email: str
full_name: str
is_admin: bool
# 用 DTO,一行配置全局生效
class UserDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
rename_strategy="camel",
)
|
未来哪天想换 PascalCase,改一行 config 全局生效,不用全项目大搜索。
4. 输入输出双向处理
DTO 还能一套搞定校验和序列化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
class UserCreateDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
include={"email", "password", "full_name"},
rename_strategy="camel",
)
class UserResponseDTO(SQLAlchemyDTO[User]):
config = DTOConfig(
exclude={"password_hash"},
rename_strategy="camel",
)
@post("/users", data=UserCreateDTO, return_dto=UserResponseDTO)
async def create_user(self, data: DTOData[User]) -> User:
user_data = data.as_builtins()
user = await user_service.create(user_data)
return user # 自动转响应
|
同一个模型,不同“视角”。
DTO 能干啥?一览表
了解了 DTO 的适用场景,再来看看它都能玩哪些骚操作(不展开讲语法,只说能力):
字段控制:
- 排除字段:比如
password_hash、internal_notes 等敏感字段
- 只包含指定字段:白名单思路
- 部分模型:所有字段可选(PATCH接口妥妥的)
数据变换:
- 命名风格自动转换:snake_case、camelCase、PascalCase 随意切换
- 单字段自定义重命名
- 计算字段:可以加“虚拟”字段
关系处理:
- 最大嵌套深度:自动递归几层
- 循环引用处理:防止死循环
校验能力:
- 类型安全:DTOData 提供类型安全转换
- 集成模型校验:Pydantic/msgspec 校验都能用
本质上,DTO 就是个“数据转换配置系统”。当你的 API 结构复杂、端点多、变化多时,这套配置能大大提升效率。
老实说,权衡一下
彻底梳理下来,我终于看清楚两种方式的优劣。
DTO 适合什么时候?
典型场景: 电商后台,50+接口,用户/商品/订单模型全是大胖子
- ✅ 字段定义一次,少复制粘贴
- ✅ 转换风格一致,维护方便
- ✅ 模型一变,DTO 自动适配
- ✅ 嵌套对象自动序列化
- ✅ 字段排除统一管理,安全性高
缺点: 多一层抽象,出错时调试难,DTOConfig 上手有门槛
显式 schema 适合什么时候?
典型场景: 认证系统,8个接口,结构分明
- ✅ 公开什么字段一清二楚,安全放心
- ✅ 调试简单,没有魔法
- ✅ 清晰好懂,代码显性
- ✅ 小模型、结构差异大时最合适
- ✅ 每个字段都能精准把控
缺点: 手写转换略繁琐,字段多时易出错,大模型会啰嗦
我的选择:现在继续用显式 schema
针对我的认证系统,结论呼之欲出:
我的 schema:
- 字段少(4-8个)
- 结构各异(登录、注册、资料各不相同)
- 安全要求高(暴露啥必须明明白白)
团队价值观:
DTO 带来的抽象对我没啥实质好处,反而增加复杂度。
但注意,这不是“永远如此”!
如果将来我要做:
- 管理后台,30个类似 CRUD 接口
- 报表系统,数据嵌套很深
- 公共 API,需要各种用户/商品的变体
那 DTO 就会成为我的好帮手。等哪天我真的为“重复写大 schema”头疼了,再引入 DTO 也不迟。
真正的收获:别迷信“最佳实践”
这次折腾,其实给了我更重要的启发:
起初我焦虑,是因为没用社区推荐的“高级特性”。示例代码用 DTO,所以我是不是就落伍了?
其实不然。
“高级特性”不是必须的,它只是为特定问题准备的工具。
Litestar 的例子用 DTO,是为了展示框架能力,适合数据结构复杂、端点多变的全栈场景。但你的项目未必就需要。
最佳代码不是“用得最炫”,而是:
- 真正解决你的问题
- 团队能读懂、能维护
- 适合你的具体场景
有时确实需要强大的抽象(比如 DTO),有时写点显式转换,反而代码最清爽。
高手不是啥都用,而是知道什么时候该用啥。
怎样为你的项目做选择?
我的决策清单如下:
该用显式 schema 的情况
- 字段少(<15)
- 每个接口返回结构都不一样
- 安全要求高(认证、支付、个人数据)
- 团队小,追求代码显性
- 做的就是简单 CRUD
该用 DTO 的情况
- 字段巨多(>20)
- 多个接口只差一点点
- 复杂嵌套需要自动序列化
- 20+接口都要统一转换风格
- 管理后台 or 复杂仪表盘项目
- 字段排除靠人维护容易出错
还没必要决定的情况
项目刚起步,先用显式 schema 写几个接口再说。等你开始痛恨重复 schema、觉得“肯定有更优雅的办法”时——就是考虑 DTO 的时候。
别为了抽象而抽象,等你真遇到痛点再说。
我的现状
我的认证系统,依然是用 msgspec 显式 schema,没引入 DTO,也没用什么自动转换。
而且,我对这个决定很有信心。
不是说 DTO 不好,恰恰相反,它对复杂项目非常优雅。但我明白了它存在的意义,也知道自己啥时候该用。
那种看到“官方示例用 DTO”的自卑感,已经变成学习的动力。我不再焦虑,而是心里有数。
将来真要写个50端点、层层嵌套的 admin panel,DTO 肯定用得飞起,到时候我也知道怎么配,怎么用,胸有成竹。
但现在?简单就是美。
总结一波
下次再看到示例代码里用了“高级特性”,别急着怀疑人生。
- 先搞清楚它解决了啥问题——痛点在哪?
- 想想你有这痛点吗——这特性对你有没有用?
- 权衡利弊——省了啥,麻烦了啥?
- 选最适合你的——没有放之四海而皆准的“最佳实践”
有时候,简单就对了。这不是水平低,而是懂得取舍。
DTO 是大规模数据转换的利器。但你不用的“强大”,就是你要维护的复杂。
知其然,知其所以然,选你所需,代码自有美感。
(本文由作者原创,AI协助润色)