You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

22 KiB

Controller 出入参对象规范调研(DTO / VO / Param / Entity)

调研日期:2026-08-10 缘起:RoleController.saveOrUpdate 用 6 个 @RequestParam 平铺接参,希望封装成对象;进而需要为全项目确立 DTO / VO / Param 的统一职责与命名。 存放位置说明:仓库此前没有调研笔记的约定(docs/ 下是 ADR、前端对接指南、部署文档)。本文新建 docs/research/ 目录存放此类「先查证、再定规范」的过程性文档,与结论性的 docs/adr/ 区分开。


一、一手结论

本节每条都追到拥有该说法的源头,不引用二手转述。中文技术社区关于 DO/DTO/BO/VO 的文章绝大多数是对阿里手册的转述,本文一律回到手册本身。

1.1 DTO 的原始定义(Fowler, PoEAA)

"An object that carries data between processes in order to reduce the number of method calls." "Usually an assembler is used on the server side to transfer data between the DTO and any domain objects."

—— Martin Fowler, Data Transfer Object, PoEAA catalog

两点对本项目有直接约束力:

  1. DTO 的原始动机是跨进程减少调用次数,不是「Controller 接参专用类」。用 DTO 命名 HTTP 入参属于语义扩张,不是错,但要知道它不是原意。
  2. 转换由 assembler 在服务端完成,即 DTO 与领域对象之间的映射有明确归属方,不散落在调用点。这与本项目「转换放 Service 层」的决定同源。

同页 2013 年补注还澄清了一个长期混淆:

"At the time I wrote this book the Sun/Java community used the term 'value object' for data transfer objects. This caused considerable confusion since others used that term to mean a Value Object. Fortunately after a few years the Sun/Java community switched to using 'Transfer Object' as the name for this pattern."

所以「VO = View Object(视图对象)」不是 Fowler 的定义,Fowler 语境里 Value Object 是「按值相等的不可变对象」。VO 作为「展示层对象」是中文 Java 生态(阿里手册)的约定。项目内用 VO 表示出参没问题,但不要引用 Fowler 为其背书。

1.2 领域模型分层与命名(阿里巴巴 Java 开发手册)

引自第一方英文版(alibaba.github.io,非社区转述):

Layers of Domain Model [For Reference]

  • DO (Data Object): Corresponding to the database table structure, the data source object is transferred upward through DAO Layer.
  • DTO (Data Transfer Object): Objects which are transferred upward by Service Layer and Manager Layer.
  • BO (Business Object): Objects that encapsulate business logic, which can be outputted by Service Layer.
  • Query: Data query objects that carry query request from upper layers. Note: Prohibit the use of Map if there are more than 2 query conditions.
  • VO (View Object): Objects that are used in Display Layer, which is normally transferred from Web Layer.

Naming conventions for Domain models [Mandatory]

  1. Data Object: *DO, where * is the table name.
  2. Data Transfer Object: *DTO, where * is a domain-related name.
  3. Value Object: *VO, where * is a website name in most cases.
  4. POJO generally point to DO/DTO/BO/VO but cannot be used in naming as *POJO.

—— Alibaba Java Coding Guidelines(第一方英文版)

对本项目的三点含义:

  1. 手册里查询对象叫 Query,不叫 Param 本项目用 XxxParam 是自有约定,与手册不一致但内部自洽,无需改(改名成本大于收益)。此处记录清楚,避免以后有人拿手册来「纠正」。
  2. 手册的 DTO 指「Service/Manager 层向上传输的对象」,即跨层传输,本身并不指 HTTP 入参。这一条对「写入参该叫 SaveDTO 还是 SaveParam」的取舍有实质影响,见 §四。
  3. 注意手册对 VO 的命名解释("* is a website name")是早期电商语境的遗留措辞,与其分层表里的「Display Layer 对象」并不严格一致。**手册自身在此处措辞不严谨**,不必逐字遵从,取「展示层出参」这一层意思即可。

另有一条与本项目现有代码直接冲突的强制条款:

[Mandatory] While defining POJO classes like DO, DTO, VO, etc., do not assign any default values to the members.

本项目 BaseParam 赋了默认值(current = 1size = 10asc = Boolean.FALSE),BaseDTO 赋了 deleted = StatusEnum.NORMAL.getValue()BaseEntity 同样赋了 deleted。这是有意偏离:分页参数的默认值让前端可省略传参,收益明确。本文记录为「已知偏离」,不作为待修问题。

1.3 Spring 官方对「拿什么对象接 HTTP 参数」的安全建议

Spring Framework 参考文档在 @ModelAttribute 一节明确写道:

"By default, both constructor and property data binding are applied. However, model object design requires careful consideration, and for security reasons it is recommended either to use an object tailored specifically for web binding, or to apply constructor binding only. If property binding must still be used, then allowedFields patterns should be set to limit which properties can be set."

—— Spring Framework Reference, Web MVC → @ModelAttribute

这是「禁止用 Entity 接参」最权威的依据:框架自己说要用「专门为 web 绑定裁剪过的对象」。本项目现有的 saveOrUpdate(DictGroup group) 正是文档劝阻的写法。

同页还解释了本项目表单绑定为何「不写注解也能生效」:

"Using @ModelAttribute is optional. By default, any parameter that is not a simple value type as determined by BeanUtils#isSimpleProperty AND that is not resolved by any other argument resolver is treated as an implicit @ModelAttribute."

page(GroupPageParam param) 这种签名走的是隐式 @ModelAttribute,从 application/x-www-form-urlencoded 表单字段与 query 参数绑定。这与本项目「POST 用表单、不用 JSON body」的 API 契约天然契合——对象封装不需要改成 @RequestBody

文档另有一条提示(本项目未使用 GraalVM native image,故仅备录):

"When compiling to a native image with GraalVM, the implicit @ModelAttribute support described above does not allow proper ahead-of-time inference of related data binding reflection hints. As a consequence, it is recommended to explicitly annotate method parameters with @ModelAttribute."

1.4 Entity 直接接参是一个有名字的漏洞(OWASP)

"Software frameworks sometimes allow developers to automatically bind HTTP request parameters into program code variables or objects... Attackers can sometimes use this methodology to create new parameters that the developer never intended. This is called a Mass Assignment vulnerability." Alternative Names: "Autobinding: Spring MVC, ASP NET MVC."

OWASP 给出的示例与本项目的情形高度同构——表单只有三个字段,绑定的对象却多一个 isAdmin

POST /addUser
userid=bobbytables&password=hashedpass&email=...&isAdmin=true

其推荐的架构级解法:

"An architectural approach is to create Data Transfer Objects and avoid binding input directly to domain objects. Only the fields that are meant to be editable by the user are included in the DTO."

public class UserRegistrationFormDTO {
    private String userid;
    private String password;
    private String email;
    //NOTE: isAdmin field is not present
}

Spring MVC 的兜底手段(非首选):

@InitBinder
public void initBinder(WebDataBinder binder, WebRequest request) {
    binder.setAllowedFields(["userid","password","email"]);
}

—— OWASP Cheat Sheet Series, Mass Assignment

本项目 SysRole.builtinDictGroup.builtinBaseEntity.deleted 就是 isAdmin 的等价物。 这不是理论风险,见 §二。


二、本项目现状(事实)

以下事实分两类标注:[已核实] 为本次直接读源码确认;[盘查] 来自子代理全量扫描、未逐条复核。子代理报告有两处错误已修正(它把 service/impl/ 写成 service/implement/,并在摘要里误称 RoleController 有 Entity 直收——其明细部分自身是正确的)。

2.1 已经为「Entity 直收」打过的补丁

[已核实] crm-dict/src/main/java/com/crm/dict/service/impl/DictGroupServiceImpl.java L72-77:

// 审计字段由框架 strictInsertFill/strictUpdateFill 自动填充(仅 null 时填充),
// 客户端传入的审计值不可信且会被保留,统一清空防止伪造
group.setCreateTime(null);
group.setUpdateTime(null);
group.setCreatorId(null);
group.setUpdaterId(null);

同文件 L99:

group.setBuiltin(false); // 用户不能创建内置分组

DictItemServiceImpl.saveItem 有同构逻辑 [盘查]

这段代码就是 §1.4 那个漏洞的手工补丁:因为 DictGroupController.saveOrUpdate(DictGroup group) 把实体交给了 Spring 绑定,客户端可以传 createTimecreatorIdbuiltin=true,而 MyBatis-Plus 的填充策略是「仅 null 时填充」,伪造值会被原样保留。补丁有效,但它是每个新接口都要重写一遍的纪律要求,而不是结构性防御——一旦漏写就是漏洞。这正是 OWASP 与 Spring 文档都建议改用专用绑定对象的原因。

[盘查] 缺同类防护的位置:ResourceController.saveOrUpdateResourceNode.toEntity() 未处理审计字段)。RoleController.saveOrUpdateSystemController.saveDept 因为是逐个 @RequestParam 手工装配实体,反而不受此漏洞影响——平铺参数虽然丑,但恰好只允许列出的字段进来。这是个值得记住的反直觉点:封装成对象若做不对,安全性反而不如平铺。

2.2 出参侧泄露内部字段

[已核实] RoleController.page 返回 Result<PageResult<SysRole>>,直接把实体投给前端,creatorId/updaterId/createTime/updateTime/deleted/builtin 全部裸奔。[盘查] 同类还有 SystemController.menuTreeList<SysMenu>)、SystemController.deptTreeList<SysDept>)、DictGroupController.enabledListList<DictGroup>)、DictItemController.enabledListList<DictItem>)。

读方向不构成越权,但会让前端对内部字段形成依赖,日后改表即破坏前端契约。

2.3 命名与包结构已经分裂

[已核实] 全部「非实体」类都堆在 domain/dto/ 里,没有任何模块存在 domain/vo/ 目录,但类名却 VO/DTO 混用:

所在包 类名后缀 实际角色
RoleDetailVO dto/ VO 出参
UserStatsVO dto/ VO 出参
DictGroupVO dto/ VO 出参
DictItemVO dto/ VO 出参
UserListDTO dto/ DTO 出参(反范式,含 deptName/roleName)
UserInfoDTO dto/ DTO 出参
LoginResultDTO dto/ DTO 出参
ResourceNode dto/ 无后缀 双向(入参 + 出参)

同一个角色(HTTP 出参)在同一个包里有三种命名。[已核实] 更能说明混乱程度的是:DictGroupVO 是通过 BaseEntity 上名为 toDTO 的方法生成的——g.toDTO(DictGroupVO::new)DictGroupServiceImpl L61)。

2.4 ResourceNode:双向复用的实际代价

[已核实] ResourceNode 同时是 saveOrUpdate 的入参和 listAll 的出参,代价写在它自己的注解里:

@Schema(description = "节点 ID,新增时不传、编辑时必传")   // 同一字段两种契约
@Schema(description = "子节点列表(仅 listAll 返回时填充)") // 入参侧永远无意义

需要靠文字描述解释「这个字段何时有效」,说明类型本身表达不了契约。且该类同时持有 fromEntity()toEntity(),一个类知道两个方向。

2.5 入参形态统计 [盘查]

形态 数量 代表
RAW_PARAMS(≥3 个 @RequestParam 平铺) 6 RoleController.saveOrUpdate(6)、SystemController.saveDept(5)、assignUserDepts(3)、RoleController.page(3)、FileController.multipart/init(4)、multipart/upload(3)
ENTITY_BIND(实体直收) 2 DictGroupController.saveOrUpdateDictItemController.saveOrUpdate
DTO_BIND(对象表单绑定) 3 DictGroupController.pageDictItemController.pageResourceController.saveOrUpdate
JSON_BIND@RequestBody 1 SystemController.userPage

JSON_BIND 那一处违反项目 API 契约(契约规定 POST 用 application/x-www-form-urlencoded,不用 JSON body)。

2.6 分页安全 [已核实 + 盘查]

BaseParam 的 javadoc 要求经 PageConverter#toMpPage 转换,「其内部做了 size 上限与排序字段防注入处理」。[盘查] RoleController.page 自行声明 current/size/keyword 并直接 new Page<>(current, size),绕过了 size 上限与排序防注入。

2.7 校验注解使用率为零 [盘查]

全项目无 @Valid/@Validated,入参类上无 @NotNull/@NotBlank/@Size。校验全靠 Service 层手写 StrUtil.isBlank 判断(如 DictGroupServiceImpl L78-83 [已核实])。

2.8 现有成文规范自相矛盾 [已核实]

README.md §3 工程结构已经声明了四层包划分:

├── domain/
│   ├── entity/             # 实体 Xxx extends BaseEntity(与表一一对应)
│   ├── dto/                # XxxDTO extends BaseDTO(对外传输对象)
│   ├── vo/                 # XxxVO(仅用于视图展示的聚合对象,可选)
│   ├── param/              # XxxParam extends BaseParam(查询入参)/ XxxSaveParam(写入参)

README.md §4 类命名规范却给出不同口径:

类型 命名 继承/实现 说明
传输对象 CustomerDTO BaseDTO 接口出入参主要载体
视图对象 CustomerVO - 多表聚合展示时使用
查询参数 CustomerParam BaseParam 分页/条件查询入参

三处冲突:

  1. §3 说写入参是 XxxSaveParam(放 param/),§4 完全没提写入参,只说 CustomerDTO extends BaseDTO 是「出入参主要载体」。
  2. §4 让入参载体继承 BaseDTO,而 BaseDTO 携带 creatorId/createTime/updaterId/updateTime/deleted —— 照 §4 写就等于主动制造 §1.4 的漏洞
  3. §3 规定的 domain/vo/ 目录在四个模块中一个都不存在,VO 类全在 dto/ 下。

结论:本项目不是「没有规范」,而是「成文规范内部矛盾、且与代码脱节」。 这决定了本次工作的性质是修正与收敛,不是从零制定。


三、推荐方案

设计目标按优先级:① 结构性消除 mass assignment,② 不增加过多类,③ 与既有 API 契约(表单提交)零冲突,④ 尽量复用 README §3 已写下的划分以减少文档变更。

3.1 四类对象的职责

用途 命名 基类 硬约束
表结构映射 domain/entity/ Xxx BaseEntity 禁止出现在任何 Controller 方法签名中(入参、出参都禁)
写入参 domain/param/ XxxSaveParam 无,纯 POJO 只声明前端可写字段;禁止出现审计字段与服务端裁定字段(builtin/deleted/status 视业务而定)
查询入参 domain/param/ XxxPageParam BaseParam 分页查询必须继承 BaseParam 并经 PageConverter.toMpPage
出参 domain/vo/ XxxVO 列表与详情优先共用一个 VO
跨层/跨模块传输 domain/dto/ XxxDTO 视情况 不面向 HTTP 的传输对象;若已是跨模块 API 返回类型,可直接复用为 HTTP 出参,不再包 VO

要点说明:

  • 写入参用 XxxSaveParamparam/,不用 XxxSaveDTOdto/ 依据有三:README §3 已经这么写了(零文档变更);param/ 这个包边界从此有了统一含义——「客户端送进来的,一律不可信」,读写都在里面,边界清晰;且按 §1.2,阿里手册的 DTO 本指「Service 层向上传输的对象」,用它命名 HTTP 入参偏离手册原意,而 Param 是本项目自有约定,不与任何外部定义冲突。
  • 纯 POJO,不继承 BaseDTO 继承即把审计字段暴露给绑定器,等于把 §1.4 的漏洞请回来。这是结构性防御与纪律性防御的区别:字段不存在,就没有「忘记置空」的可能。
  • domain/dto/ 保留给真正的跨层对象,如 UploadSession(Redis 会话对象,非 API 出参)、ButtonSeedPermissionModuleDescriptorFileInfoDTO 既是 crm-file 对外 API 的返回类型又是 HTTP 出参,属允许的双重身份,不必再造 FileInfoVO

3.2 控制类数量膨胀的四条规则

用户明确关切「不想建太多类」。以下规则把每个业务对象的类数压到 通常 2 个、最多 3 个

  1. 出参只建一个 XxxVO,列表与详情共用。 不要 XxxListVO + XxxDetailVO 各一份。详情比列表多出的重字段(如 RoleDetailVO.resourceIds 需额外查询)在列表场景留 null 即可——但必须在 @Schema 注明,且这类字段不超过 2 个;超了才拆。
  2. 没有分页查询的对象不建 XxxPageParam 单参数查询继续用 @RequestParam
  3. VO 的构造成本已经接近零BaseEntity.toDTO(XxxVO::new) 一行生成(现成机制,DictGroupVO 即如此)。所以「多一个 VO 类」的真实成本是一份字段声明,不是一套映射代码。
  4. 2 个及以下参数的写操作不必封装。 status(id, status)setDefault(groupId, itemId)delete(id) 保持 @RequestParam。阈值定在 3 个参数,与 §1.2 手册「查询条件超过 2 个禁用 Map」的精神一致。

诚实的成本披露:这套规范落地会净增约 6 个类RoleSaveParamRolePageParamRoleVODeptSaveParamDictGroupSaveParamDictItemSaveParamResourceSaveParamResourceVO,减去可由 ResourceNode 拆分复用的部分),并需要把现有 7 个 VO 类从 dto/ 移到 vo/。换来的是消除 2 处实体直收、5 处实体出参泄露、1 处分页防注入绕过。

3.3 转换位置

DTO/Param → Entity 的转换放 Service 层。Controller 只做接参、包 Result。理由:Controller 是薄适配层;含业务语义的字段映射属于 service;同一转换可能被多个入口复用。

Entity → VO 走 BaseEntity.toDTO(XxxVO::new);字段名不一致时(如 SysMenu.menuNameResourceVO.name)在 VO 上写静态 fromEntity,沿用 RoleDetailVO.fromEntity 的现有写法。

3.4 绑定方式

沿用隐式表单绑定saveOrUpdate(RoleSaveParam param),不加 @RequestBody),依据 §1.3:Spring 对非简单类型参数默认按 @ModelAttribute 处理,从表单字段与 query 参数绑定,与本项目 application/x-www-form-urlencoded 契约完全契合。

需顺带修正 SystemController.userPage@RequestBody——它违反项目 API 契约。

3.5 校验(建议分期,不阻塞本次)

入参类上加 JSR-380 注解、Controller 加 @Valid,把 Service 层手写的 StrUtil.isBlank 判断前移。当前使用率为零,全量补齐工作量大,建议只对新增与本次改动的入参类要求,存量不强制。


四、待决策项

写入参的命名:本文 §3.1 推荐 XxxSaveParam(放 param/),依据是 README §3 已有此约定 + param/ 包语义统一 + 不与阿里手册 DTO 定义冲突。此前口头讨论中曾倾向 XxxSaveDTO(放 dto/),当时尚未发现 README §3 的既有约定。两者都可自洽,需拍一个:

XxxSaveParam @ param/(本文推荐) XxxSaveDTO @ dto/
README §3 一致,零文档变更 需改 README §3
包语义 param/ = 一切客户端入参,边界含安全意义 dto/ 混装入参与跨层对象
与阿里手册 无冲突(Param 是自有约定) DTO 原指跨层传输,语义偏移
直觉性 「Param 像查询专用」是唯一弱点 「DTO 就是接口对象」符合多数人习惯

五、落地后需同步更新的文档

  • README.md §4 类命名规范表:删除「CustomerDTO extends BaseDTO 是接口出入参主要载体」,补入写入参与出参两行,消除与 §3 的矛盾。
  • README.md §3:param/ 注释与最终命名对齐。
  • 新增 ADR(下一个可用编号 0017):记录「Controller 禁用 Entity 收发 + 三类对象职责划分」这一决策及其安全依据。
  • 各模块 CONTEXT.md:若涉及跨模块 seam 的对象(如 FileInfoDTO)身份变化,需补充说明。
  • docs/frontend-*.md:出参从 Entity 改为 VO 会改变返回字段集合(去掉审计字段),属破坏性变更,需与前端确认后同步文档。

六、引用源清单

性质 用途
Martin Fowler, Data Transfer Object (PoEAA) 模式原始定义 DTO 原意、assembler 归属、VO 术语混淆史
Alibaba Java Coding Guidelines(第一方英文版) 行业规范 DO/DTO/BO/Query/VO 分层与命名、POJO 禁默认值
Spring Framework Reference — @ModelAttribute 框架官方文档 「用专为 web 绑定裁剪的对象」的安全建议、隐式 @ModelAttribute 机制、allowedFields
OWASP Cheat Sheet — Mass Assignment 安全规范 漏洞定名(Spring MVC 语境称 Autobinding)、DTO 作为架构级解法、setAllowedFields 兜底
本仓库源码 一手 §二 全部现状事实

未能取证的一项:Spring 文档 @ModelAttribute 页链接的 "Model Design" 深入章节,两个候选 URL 均返回 404,故本文只引用 @ModelAttribute 页内已验证的原文,未引用该深入章节。