1. 问题背景
在 Java 后端开发中,几乎每天都要面对这样的场景:Service 层查出一个 List<User>、List<OrderVO>,然后要返回给前端。看似简单的一行 return list;,实际却藏着不少坑:
- 直接返回实体集合,字段全暴露,敏感信息(密码、手机号)跟着一起出去;
- 返回
null还是空数组,前端处理逻辑不一致,动不动就Cannot read property 'length' of null; - 分页信息、总数、状态码没有统一结构,前端每个接口都要单独适配;
- 字段命名风格不统一,一会儿
userName一会儿user_name,前端直呼受不了。
本文从「能跑」到「优雅」,一步步梳理把 List 返回给前端的几种做法和最佳实践。
2. 最朴素的做法与它的问题
先看一个最常见的写法:
java
@GetMapping("/users")
public List<User> list() {
return userService.list();
}
这段代码能跑,但存在几个明显问题:
- 实体直接外泄 :
User实体里的password、salt、deleted等字段会原样序列化给前端。 - 返回结构不统一:有的接口返回数组,有的返回对象,前端需要写多套解析逻辑。
- 缺少元信息:没有总数、没有分页、没有业务状态码,前端无法判断请求是否真正成功。
- 空集合语义模糊 :
null和[]在前端表现不同,容易引发空指针异常。
所以「优雅」的第一步,是不要直接把实体 List 丢给前端。
3. 优雅方案一:统一响应体 + VO 转换
3.1 定义统一响应结构
一个通用的 Result<T> 能解决结构不统一的问题:
java
@Data
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> fail(Integer code, String message) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(message);
return r;
}
}
3.2 用 VO 隔离实体
不要让实体直接出现在接口返回值里,用 VO(View Object)承载前端需要的字段:
java
@Data
public class UserVO {
private Long id;
private String nickname;
private String avatar;
// 不包含 password、salt 等敏感字段
}
3.3 Controller 层组装返回
java
@GetMapping("/users")
public Result<List<UserVO>> list() {
List<UserVO> voList = userService.listUsers()
.stream()
.map(UserConverter::toVO)
.collect(Collectors.toList());
return Result.success(voList);
}
这样前端拿到的永远是 { code, message, data } 结构,data 里是干净的 VO 数组,字段可控、结构统一。
4. 优雅方案二:分页场景用 PageResult
列表接口十有八九要分页,此时只返回 List 是不够的,还需要总数、页码等信息。可以定义一个分页结果对象:
java
@Data
public class PageResult<T> {
private Long total;
private Integer pageNum;
private Integer pageSize;
private List<T> records;
public static <T> PageResult<T> of(IPage<T> page) {
PageResult<T> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setPageNum((int) page.getCurrent());
result.setPageSize((int) page.getSize());
result.setRecords(page.getRecords());
return result;
}
}
Controller 中配合 MyBatis-Plus 的 Page 使用:
java
@GetMapping("/users/page")
public Result<PageResult<UserVO>> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
Page<User> page = userService.page(new Page<>(pageNum, pageSize));
List<UserVO> voList = page.getRecords()
.stream()
.map(UserConverter::toVO)
.collect(Collectors.toList());
PageResult<UserVO> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setPageNum(pageNum);
result.setPageSize(pageSize);
result.setRecords(voList);
return Result.success(result);
}
前端拿到 total 就能渲染分页器,拿到 records 就能渲染列表,语义清晰。
5. 优雅方案三:全局统一包装,Controller 只返回 List
如果觉得每个接口都手写 Result.success(...) 太啰嗦,可以用 ResponseBodyAdvice 做全局自动包装,让 Controller 保持「只返回业务数据」的清爽写法:
java
@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 已经手动包装过的、或标注了忽略注解的,不再重复包装
return !returnType.hasMethodAnnotation(IgnoreWrap.class);
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
return Result.success(body);
}
}
此时 Controller 可以写成:
java
@GetMapping("/users")
public List<UserVO> list() {
return userService.listUserVOs();
}
前端实际收到的仍然是 { code, message, data: [...] }。业务代码干净,返回结构统一,两全其美。
注意:使用
ResponseBodyAdvice时,如果返回类型是String,会因为StringHttpMessageConverter与Jackson的转换器冲突而报错,需要特殊处理或避免直接返回String。
6. 空集合与 null 的处理
优雅的另一个细节是永远不要返回 null 集合 。空集合用 Collections.emptyList() 或 new ArrayList<>() 表示,前端统一按数组处理即可:
java
public List<UserVO> listUserVOs() {
List<User> users = userMapper.selectList(null);
if (CollectionUtils.isEmpty(users)) {
return Collections.emptyList();
}
return users.stream()
.map(UserConverter::toVO)
.collect(Collectors.toList());
}
配合 Jackson 的全局配置,还可以把 null 字段直接忽略,减少无用字段传输:
yaml
spring:
jackson:
default-property-inclusion: non_null
7. 字段命名与序列化规范
前后端联调时,字段命名不一致是高频摩擦点。建议统一约定:
- 后端 Java 用驼峰
userName,通过 Jackson 全局配置输出为下划线或保持驼峰,二者选其一并写进接口文档; - 日期统一格式化为
yyyy-MM-dd HH:mm:ss,避免前端拿到时间戳还要自己转; - 大整数(如雪花 ID)用
@JsonSerialize(using = ToStringSerializer.class)转成字符串,防止 JS 精度丢失。
java
@Data
public class UserVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String userName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
}
8. 方案对比与选型建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
直接返回 List<Entity> |
代码最少 | 字段外泄、结构不统一 | 仅限内部调试 |
Result<List<VO>> |
结构统一、字段可控 | 每个接口需手动包装 | 大多数业务接口 |
Result<PageResult<VO>> |
带分页元信息 | 结构稍复杂 | 列表分页接口 |
ResponseBodyAdvice 全局包装 |
Controller 极简 | 需处理特殊返回类型 | 中大型项目统一规范 |
选型建议:
- 小项目、接口少:直接用
Result<List<VO>>,简单直接; - 中大型项目:上
ResponseBodyAdvice全局包装,配合 VO 和分页对象,形成团队统一规范; - 无论哪种方案,实体不出 Controller、空集合不返回 null、字段命名有约定,这三条是底线。
9. 总结
把 List 优雅地返回给前端,核心不在于用了多花哨的框架,而在于三点:
- 隔离:用 VO 隔离实体,控制暴露字段;
- 统一 :用
Result/PageResult统一响应结构,前端只写一套解析逻辑; - 规范:空集合、字段命名、日期格式、大整数精度,提前约定好。
做到这三点,接口返回就从「能跑」升级为「好维护、好联调、好扩展」。