资讯动态

SpringBoot 三层架构里 DTO 该放哪一层:从鸿蒙登录接口返回密码说起

发布时间:2026/10/27 16:06:14 来源:尧图企业网站定制
答辩前一周我给鸿蒙 App 配的后端lifeApp0110已经跑通了登录、待办和图标三个模块。技术栈很普通SpringBoot 2.3.4、MyBatis 2.1.4、MySQL 5.x包名com.qf.lifeapp0110用Controller ResponseBody把对象直接转成 JSON 回给客户端。答辩前一天我打开 Swagger 核对接口/user/one的响应示例里赫然列着password。再看代码这个登录接口连密码比对都没写只按用户名查了一次就把整行数据交了出去。我一开始以为补一行密码判断就完了真正改起来才发现问题不在那一行if而在接口的形状是由数据库表决定的表里有什么列客户端就能收到什么字段。这篇把那次改造完整写一遍重点不是背分层口诀而是说清每一类对象为什么只能待在一个地方。一、实体为什么不能直接出网关实体Entity的定义很简单一个类对应一张表一个对象对应一行记录。项目里user表只有id、username、password三列MyBatis 的queryByName返回的就是这个实体。问题在于实体一旦被ResponseBody序列化它就从数据库的投影变成了对外契约。当时的代码大致是这样RequestMapping(/one) ResponseBody public Map queryOne(RequestBody User user1) { Map map new HashMap(); User user userMapper.queryByName(user1.getUsername()); if (user ! null) { map.put(user, user); // 整个实体进了 JSON map.put(message, ok); } else { map.put(user, new User()); // 失败也用空对象兜底 map.put(message, error); } return map; }这段代码有三个隐患而且都不是改一行能解决的。第一返回体里带着password。只要字段在实体上任何返回实体的接口都会带上它今天是登录明天是查个人资料后天是管理员看用户列表。靠每个接口手动置空迟早漏一个而漏的那次通常没人发现。第二表结构和 API 绑死。以后给user加phone、is_deleted客户端收到的 JSON 立刻多出字段反过来客户端想要的昵称 头像如果不在表里接口就拼不出来。接口本应描述业务能力现在却成了 DDL 的镜像。第三new User()把没查到编码成了一个 id 为 0 的空对象。鸿蒙端必须知道id 为 0 就表示失败这个隐含约定编译器不检查Swagger 也写不出来。这类约定一旦散落在客户端代码里后端想改错误码都改不动。二、三类对象三种生命周期改造时我先把对象按职责分开而不是急着建一堆类对象所在包由谁创建字段从哪来能不能出网关Entity / POentityMyBatis 映射数据库表的列不能DTO入参dtoSpring MVC 反序列化客户端请求体只在入方向VO出参voService 组装业务需要展示的字段可以且只暴露必要字段这张表里最关键的是最后一列。实体不能出网关不是因为它层次低而是它的字段集合由 DDL 决定对外字段集合应该由接口设计决定这两个集合的变更节奏根本不同。表可以为了查询性能加冗余列接口却不能因为这个就把冗余列抖给客户端。还要说明一点不是每个接口都得配三个类。这个项目里我只有LoginRequest和UserVO两个够用了。真正不能破的是方向——Controller 可以依赖 DTO 和 ServiceService 不能依赖HttpServletRequestMapper 更不能认识 VO。三、入参 DTO校验注解跟着契约走登录接口的入参单独抽出来校验注解放这里public class LoginRequest { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) private String password; // getter / setter 省略 } PostMapping(/login) public UserVO login(Valid RequestBody LoginRequest req) { return userService.login(req); }为什么校验不写在User实体上因为这个实体同时被注册、改资料、后台编辑复用三处对字段的要求并不一样注册时用户名密码都必填改昵称时密码根本不该出现。注解写在实体上就得引入 validation groups 按场景分组分组一多注解比字段还长后来接手的人也不敢删。更重要的是校验规则属于接口契约接口改规则就改 DTO不该逼着表一起动。入参 DTO 还只放客户端能决定的东西。id、create_time这类字段不要出现在请求对象里它们要么由数据库生成要么由服务端赋值。请求对象里没有这个字段客户端就没法伪造这比在 Service 里补一堆if (id ! null) throw省事得多。四、出参 VO转换放在 Service出参单独定义并且把哪些字段可以出门这条规则收在一处public class UserVO { private Integer id; private String username; public static UserVO from(User user) { UserVO vo new UserVO(); vo.setId(user.getId()); vo.setUsername(user.getUsername()); return vo; } } Service public class UserService { Autowired private UserMapper userMapper; Autowired private PasswordEncoder passwordEncoder; public UserVO login(LoginRequest req) { User user userMapper.queryByName(req.getUsername()); if (user null || !passwordEncoder.matches(req.getPassword(), user.getPassword())) { throw new BizException(用户名或密码错误); } return UserVO.from(user); } }为什么转换放在 Service 而不是 Controller因为 Controller 的职责是把 HTTP 翻译成方法调用它不该知道哪个字段敏感。让 Controller 转换每加一个接口就要重新判断一次该脱哪些字段漏一次就是一次事故放在 Service规则只有一份登录、资料页、后台列表都走同一个UserVO.from。这里有个我踩过的细节一开始图省事用了BeanUtils.copyProperties(user, vo)后来 VO 的id写成Long实体是Integer类型不匹配时它直接静默跳过id一直是 null。接口返回 200前端拿到空值排查了半天。字段少的时候手写from()最稳字段多了上 MapStruct它是编译期生成代码源字段对不上会给出警告配置成 ERROR 就直接编译失败比运行时才发现强。顺带说密码。表里存的还是明文列定义是varchar(32)这次我只把接口改造和密码存储拆成两件事接口先改成不返回密码、比对走PasswordEncoder.matches引入spring-security-crypto这个独立包就够不必把整个 Spring Security 拖进来哈希迁移单独排期。两件事混在一个提交里出问题很难判断是哪一步导致的。五、包结构和依赖方向改造后的目录是这样的每个包只做一件事com.qf.lifeapp0110 ├── controller UserController 只依赖 dto 和 service ├── service UserService 业务规则、实体转 VO ├── dao UserMapper 只进出 Entity ├── entity User 与表一一对应 ├── dto LoginRequest 入参 校验注解 ├── vo UserVO 出参 └── common BizException 统一异常依赖方向只有一条controller → service → dao。dto和vo被上层依赖自己不反过来依赖 mapper。只要在vo包里看到import com.qf.lifeapp0110.dao.UserMapper分层就已经破了而且这种破坏往往是从顺手查一下开始的。DAO 的入参也要守住。queryByName(String username)是对的query(LoginRequest req)就不对——DAO 一旦认识 HTTP 契约将来接口从 REST 换成内部 RPC 或者定时任务调用DAO 都得跟着改。常见放错位置的现象我整理成了这样一张表现象根因后果Controller 直接返回 User 实体出参用了 PO表字段即 APIpassword 顺手带出校验注解写在实体上契约规则和表约束混在一起改单个字段也要整对象校验Service 返回Map没有出参类型前端靠猜字段Swagger 生成不出来DTO 放进dao包层间依赖反了换调用方式要连 DAO 一起改VO 里塞UserMapper出参层偷偷查库循环依赖、事务边界混乱六、改造顺序先加再切最后删这次改造最怕的不是写错是鸿蒙端的老版本还在读user.password判断登录成功。所以我没做一把梭Service 新增login返回UserVO旧接口原样保留先让新接口在 Swagger 里跑通鸿蒙端LoginPage.ets切到新接口用message字段判断结果不再看id是否为 0观察一段时间旧路径的调用日志确认没有存量版本还在用再删旧接口最后打开/swagger-ui.html搜一遍password响应示例里搜不到才算收尾。兼容老客户端的原则是加字段可以改语义要慢。给 VO 新增一个字段老版本无感把user从对象换成字符串老版本立刻解析失败。判据不是我这边编译通过而是用户手机上的那个版本还认不认。小结实体是表的影子DTO 是接口的契约VO 是业务的输出。三者混用时不会立刻报错但每加一个字段、每改一次表、每上一个接口风险都会沿着这条缝扩散。最小可行的做法就三条Mapper 只碰实体Controller 只收 DTO、只返回 VOService 负责把实体翻译成 VO顺手把不该出门的字段留在里面。至于密码它从始至终都不该出现在任何返回对象里——这一点靠纪律不如靠类型因为类型不会忘。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑