自我怀疑时刻
故事要从我一头扎进 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协助润色)