技术 Web开发

Litestar DTO:传说中的“高级特性”我并不需要?(以及你什么时候真的需要)

曾经我以为不用DTO是错的,后来发现,有时候简单才是王道。本文聊聊什么时候该用DTO,什么时候不用也挺好。

自我怀疑时刻

故事要从我一头扎进 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 后端(PydanticDTOMsgspecDTOSQLAlchemyDTO),用法思想都一样。

但问题来了——我真的需要用 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 好像反而更抽象、更绕?

我的场景:

  • 请求4个字段
  • 响应4个字段
  • 不同接口字段差别很大

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_hashinternal_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 肯定用得飞起,到时候我也知道怎么配,怎么用,胸有成竹。

但现在?简单就是美。

总结一波

下次再看到示例代码里用了“高级特性”,别急着怀疑人生。

  1. 先搞清楚它解决了啥问题——痛点在哪?
  2. 想想你有这痛点吗——这特性对你有没有用?
  3. 权衡利弊——省了啥,麻烦了啥?
  4. 选最适合你的——没有放之四海而皆准的“最佳实践”

有时候,简单就对了。这不是水平低,而是懂得取舍。

DTO 是大规模数据转换的利器。但你不用的“强大”,就是你要维护的复杂。

知其然,知其所以然,选你所需,代码自有美感。


(本文由作者原创,AI协助润色)