ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

答辩前一周,我给鸿蒙 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 和 Service,Service 不能依赖HttpServletRequest,Mapper 更不能认识 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表字段即 API,password 顺手带出
校验注解写在实体上契约规则和表约束混在一起改单个字段也要整对象校验
Service 返回Map没有出参类型前端靠猜字段,Swagger 生成不出来
DTO 放进dao包层间依赖反了换调用方式要连 DAO 一起改
VO 里塞UserMapper出参层偷偷查库循环依赖、事务边界混乱

六、改造顺序:先加,再切,最后删

这次改造最怕的不是写错,是鸿蒙端的老版本还在读user.password判断登录成功。所以我没做"一把梭":

  1. Service 新增login返回UserVO,旧接口原样保留,先让新接口在 Swagger 里跑通;
  2. 鸿蒙端LoginPage.ets切到新接口,用message字段判断结果,不再看id是否为 0;
  3. 观察一段时间旧路径的调用日志,确认没有存量版本还在用,再删旧接口;
  4. 最后打开/swagger-ui.html搜一遍password,响应示例里搜不到才算收尾。

兼容老客户端的原则是"加字段可以,改语义要慢"。给 VO 新增一个字段,老版本无感;把user从对象换成字符串,老版本立刻解析失败。判据不是"我这边编译通过",而是"用户手机上的那个版本还认不认"。

小结

实体是表的影子,DTO 是接口的契约,VO 是业务的输出。三者混用时不会立刻报错,但每加一个字段、每改一次表、每上一个接口,风险都会沿着这条缝扩散。最小可行的做法就三条:Mapper 只碰实体;Controller 只收 DTO、只返回 VO;Service 负责把实体翻译成 VO,顺手把不该出门的字段留在里面。至于密码,它从始至终都不该出现在任何返回对象里——这一点靠纪律不如靠类型,因为类型不会忘。

返回列表