接口字段入参出参分离技巧:从注解到DTO分层实践
在Java后端开发中,"入参需接收、出参需隐藏"是高频需求,例如筛选条件、权限字段、临时验证参数等,若处理不当易导致接口冗余或信息泄露。本文结合数据筛选、权限字段、临时参数三类核心场景,通过订单查询、用户列表、表单提交等具体业务案例,由浅入深分享两种落地方案,兼顾开发效率与代码规范性。
一、核心需求与场景拆解
1. 核心诉求
- 入参能力:正常接收查询条件、权限标识等字段,完成业务处理;
- 出参隐藏:返回响应时排除非核心字段,避免冗余或敏感信息暴露;
- 场景适配:覆盖数据筛选、权限控制、临时参数传递等常见业务场景。
2. 三类典型场景
| 场景类型 | 业务案例 | 核心特点 |
|---|---|---|
| 数据筛选 | 订单查询(按时间范围、订单状态筛选) | 入参为筛选条件,出参无需返回 |
| 权限字段 | 用户列表(按角色ID筛选,返回时隐藏角色ID) | 入参是权限关联字段,出参需脱敏 |
| 临时参数 | 表单提交(验证码验证,返回时无需携带) | 入参为临时校验信息,业务处理后废弃 |
二、基础方案:注解调整(快速落地)
核心思路:使用Jackson的@JsonProperty(access = WRITE_ONLY)注解,仅允许字段"接收参数",禁止"序列化返回",改动成本极低,适合简单场景。
1. 问题根源
默认使用@JsonIgnore注解会同时禁用序列化(返回JSON) 和反序列化(接收参数),导致字段既不能接收也不能返回,无法满足"入参要、出参不要"的核心需求。
2. 解决方案:@JsonProperty(access = WRITE_ONLY)
Jackson提供的@JsonProperty注解可通过access属性精细控制字段的序列化/反序列化权限,是解决该问题的核心注解。
核心案例:数据筛选(订单查询接口)
业务需求 :订单查询接口需接收createTimeStart、createTimeEnd、orderStatus作为筛选条件,返回订单详情时隐藏这些筛选字段。
java
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Date;
@Data
public class Order {
private Long id;
private String orderNo;
private BigDecimal amount;
// 仅接收筛选参数,返回时隐藏
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private Date createTimeStart;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private Date createTimeEnd;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private Integer orderStatus;
}
扩展:Jackson常用字段序列化/反序列化注解
掌握以下注解可覆盖90%的字段权限控制场景,是后端开发的基础必备:
| 注解 | 核心用途 | 常用参数/取值 | 业务案例 |
|---|---|---|---|
@JsonProperty |
控制字段序列化/反序列化权限,也可自定义JSON字段名 | access属性: ✅ WRITE_ONLY:仅接收(入参),不返回(出参) ✅ READ_ONLY:仅返回(出参),不接收(入参) ✅ READ_WRITE:默认,既接收也返回 ✅ AUTO:自动适配(同READ_WRITE) 额外:value = "order_no"(自定义JSON字段名) |
1. WRITE_ONLY:筛选条件、验证码、密码 2. READ_ONLY:创建时间、主键ID(前端无需传,后端返回) 3. value:解决Java驼峰与前端下划线命名不一致 |
@JsonIgnore |
全量忽略:既不接收参数,也不返回字段 | 无参数 | 数据库乐观锁字段version、扩展字段extData(全程无需暴露) |
@JsonIgnoreProperties |
类级别批量忽略字段 | value = {"version", "extData"}(指定忽略的字段名) |
实体类需批量隐藏多个字段时(如version+extData),避免字段级注解泛滥 |
@JsonInclude |
控制空值是否序列化返回 | Include.NON_NULL:排除null字段 Include.NON_EMPTY:排除空字符串/空集合 Include.ALWAYS:默认,包含所有 |
避免返回null字段(如failureReason: null),精简响应数据 |
扩展案例演示
java
import com.fasterxml.jackson.annotation.*;
import lombok.Data;
import java.util.Date;
@Data
// 类级别批量忽略:version、extData全程不暴露
@JsonIgnoreProperties({"version", "extData"})
// 全局排除null字段,精简响应
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
// READ_ONLY:前端无需传ID,后端返回时展示
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Long id;
// 自定义JSON字段名:Java驼峰→前端下划线
@JsonProperty(value = "user_name")
private String username;
// WRITE_ONLY:仅接收验证码,返回时隐藏
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String verifyCode;
// READ_ONLY:前端无需传创建时间,后端返回时展示
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Date createTime;
// 全忽略:既不接收也不返回
@JsonIgnore
private String password;
}
3. 场景延伸:临时参数(表单提交接口)
业务需求 :用户注册接口需接收verifyCode(验证码)做临时校验,注册成功后返回用户信息时,无需携带验证码。
java
@Data
public class User {
private Long id;
private String username;
private String phone;
// 临时校验参数:仅接收,不返回
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String verifyCode;
// 创建时间:仅返回,不接收(前端无需传)
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Date createTime;
}
4. 方案优劣与适用场景
| 维度 | 优点 | 缺点 |
|---|---|---|
| 开发效率 | 改动极小,仅需添加/替换注解,10分钟即可落地 | 实体类兼顾"数据存储+接口传输",职责混乱 |
| 维护性 | 无额外类,代码量少 | 多接口复用实体时,字段权限易冲突(如A接口需返回roleId,B接口需隐藏) |
| 扩展性 | 注解组合灵活,可覆盖简单场景 | 不支持精细化参数校验(需手写逻辑) |
| 适用场景 | ✅ 个人/小型项目、快速迭代需求 ✅ 单接口专用实体类(无复用) ✅ 临时筛选接口、简单表单提交 | ❌ 中大型项目、多接口复用场景 ❌ 需严格参数校验的核心接口 |
三、进阶方案:DTO分层(规范可维护)
核心思路:遵循"单一职责原则",拆分入参DTO(接收参数)、出参VO(返回数据)、实体类(数据库映射),彻底解耦,适合中大型项目与团队协作。
1. 场景1:权限字段(用户列表接口)
业务需求 :管理员查询用户列表时,需通过roleId筛选指定角色用户,返回用户信息时隐藏roleId(避免权限信息泄露),同时需校验roleId非空。
步骤1:入参DTO(接收筛选与权限字段)
java
import lombok.Data;
import javax.validation.constraints.NotNull;
// 入参DTO:接收查询条件,支持参数校验
@Data
public class UserQueryDTO {
@NotNull(message = "角色ID不能为空")
private Integer roleId; // 权限筛选字段
private String username; // 模糊查询字段
}
步骤2:出参VO(仅返回核心业务数据)
java
import lombok.Data;
import java.util.Date;
// 出参VO:返回业务数据,隐藏敏感/筛选字段
@Data
public class UserVO {
private Long id;
private String username;
private String phone;
private String nickname;
private Date createTime;
// 不包含roleId,避免权限信息暴露
}
步骤3:接口改造(DTO→实体→VO转换)
java
import org.springframework.beans.BeanUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
import java.util.stream.Collectors;
@RequestMapping("/user")
public class UserController {
@GetMapping("/list")
public ResultData<List<UserVO>> list(@Validated UserQueryDTO queryDTO) {
// 1. DTO转实体(用于数据库查询)
User userParam = new User();
BeanUtils.copyProperties(queryDTO, userParam);
// 2. 业务查询
List<User> userList = userService.findList(userParam);
// 3. 实体转VO(隐藏敏感字段)
List<UserVO> voList = userList.stream()
.map(user -> {
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
return vo;
})
.collect(Collectors.toList());
return ResultData.success(voList);
}
}
2. 场景2:复杂数据筛选(订单统计接口)
业务需求 :按payTimeStart、payTimeEnd、merchantId筛选订单,返回统计结果(订单数、总金额),隐藏筛选条件。
java
// 入参DTO:接收复杂筛选条件
@Data
public class OrderStatQueryDTO {
private Date payTimeStart;
private Date payTimeEnd;
private Long merchantId;
}
// 出参VO:仅返回统计结果
@Data
public class OrderStatVO {
private Integer orderCount; // 订单总数
private BigDecimal totalAmount; // 总金额
}
// 接口实现
@RequestMapping("/order")
public class OrderController {
@GetMapping("/stat")
public ResultData<OrderStatVO> stat(@Validated OrderStatQueryDTO queryDTO) {
OrderStatVO statVO = orderService.statOrder(queryDTO);
return ResultData.success(statVO);
}
}
3. 方案优化:MapStruct简化对象转换
手动使用BeanUtils易出现字段名不一致、类型不匹配问题,推荐使用MapStruct自动生成类型安全的转换代码:
java
// 1. 引入依赖(Maven)
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
<scope>provided</scope>
</dependency>
// 2. 定义转换接口
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper(componentModel = "spring")
public interface UserConverter {
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
// DTO转实体
User toEntity(UserQueryDTO dto);
// 实体转VO
UserVO toVO(User user);
// 批量转换
List<UserVO> toVOList(List<User> userList);
}
// 3. 接口中使用
@GetMapping("/list")
public ResultData<List<UserVO>> list(@Validated UserQueryDTO queryDTO) {
User userParam = UserConverter.INSTANCE.toEntity(queryDTO);
List<User> userList = userService.findList(userParam);
List<UserVO> voList = UserConverter.INSTANCE.toVOList(userList);
return ResultData.success(voList);
}
4. 方案优劣与适用场景
| 维度 | 优点 | 缺点 |
|---|---|---|
| 开发效率 | 初期需创建DTO/VO,略增加代码量 | 长期维护效率高,字段变更仅需修改转换类 |
| 维护性 | 职责清晰(DTO收参、VO返参、实体映射),易扩展 | 无明显缺点,是团队协作的最佳实践 |
| 扩展性 | 支持精细化参数校验、字段脱敏、格式转换 | 需学习MapStruct等工具(成本极低) |
| 适用场景 | ✅ 中大型项目、团队协作 ✅ 多接口复用实体类 ✅ 核心业务接口(需严格校验/脱敏) | ❌ 临时一次性接口(注解方案更高效) |
四、场景适配与最佳实践总结
| 方案 | 适配场景 | 业务案例参考 | 核心建议 |
|---|---|---|---|
| 注解调整 | 小型项目、快速开发、单接口专用 | 临时订单筛选、简单用户注册(仅验证码校验) | 1. 优先用WRITE_ONLY/READ_ONLY替代@JsonIgnore 2. 类级别用@JsonIgnoreProperties批量隐藏字段 3. 用@JsonInclude精简空值响应 |
| DTO分层 | 中大型项目、团队协作、多接口复用 | 用户列表(权限筛选)、订单统计(复杂筛选)、支付回调(参数校验) | 1. 入参DTO加@Validated做参数校验 2. 出参VO仅保留核心字段,隐藏筛选/权限字段 3. 用MapStruct替代BeanUtils,减少手动错误 |
关键避坑点
- 不要在实体类中混用
WRITE_ONLY和READ_ONLY适配多接口:易导致字段权限混乱,优先用DTO/VO; - 密码/验证码等敏感字段必须用
WRITE_ONLY:绝对禁止返回给前端; - 空值控制:统一用
@JsonInclude(Include.NON_NULL),避免响应数据冗余; - 字段命名:DTO/VO用前端友好的命名(如下划线),实体用Java驼峰,通过
@JsonProperty或MapStruct转换。
两种方案均能解决"入参接收、出参隐藏"的核心需求,开发者可根据项目规模、团队协作模式灵活选择:小型项目追求效率用注解,中大型项目注重规范用DTO分层,最终目标是保证接口清晰、数据安全、代码可维护。