作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
为什么需要统一返回结果
之前在前后端数据传输格式(下)我们讨论过前后端数据的交互格式,也就是现在最常用的JSON。咋一看好像没问题,但实际上存在一些不足。
无论是返回对象 集合:
还是返回诸如Person、Student、Department这样的单个对象:
{
"name": "bravo1988",
"age": 18,
"company": {
"name": "某电商",
"city": "杭州"
}
}
它们都有一个缺点:只能用于接口正常返回的情况。
想象以下场景:
- 后端参数校验发现name.length>3,希望提示前端"name长度不能大于3",该怎么办?
- 后端逻辑不严谨导致空指针异常或远程服务挂了,直接把一大堆异常信息抛到前端,甚至还写着UserService.java:251 NullPointerException,丢人不?
类似的场景还有很多,出现这种窘境的主要原因是:前后端目前的数据格式没有兼容性,只考虑了正常响应的情况。
所以,为了兼容各种可能的情况,提供更好的接口联调体验,需要拟定统一的返回格式,必要时需做统一异常处理。
常见的处理方式
每家公司对于统一结果的封装各不相同,命名也大相径庭,有叫ResponseVO的,也有叫ResultVO的,还有叫ApiResultTO的,静态方法有success()、error(),也有buildSuccess()、buildFailed(),这些都不重要,归根结底是同一种东西。这里例举几种常见封装方式,不论哪种,通常至少有以下字段:
{
"data": {},
"success": true,
"message": "success"
}
或者
{
"data": {},
"code": 200,
"message": "success"
}
- data:用来存储实际的返回数据
- code/success:用来表示本次请求是否成功(前端可以直接根据这个字段判断,清晰多了)
- message:无论接口因为什么原因调用失败,如果后端希望告知前端,都可以将必要信息存入这个字段
但上面只是JSON格式,实际上后端可以有多种方法返回这种格式。
手写
我还真见过每个TO自己手写data、success、message的,具体做法是:
public class BaseTO implements Serializable {}
// 这个POJO是本来就需要的
@Data
public class UserTO extends BaseTO {
// 省略字段
}
// 但这个,只是为了保持统一的返回格式,却每次都要写一个新的,比如下次BookResponseTO
@Data
public class UserResponseTO extends BaseTO {
private UserTO data; // 塞入UserTO
private Boolean success; // 统一格式
private String message; // 统一格式
}
@GetMapping("/getUser")
public UserResponseTO getUser() {
// 查询并返回
UserResponseTO result = new UserResponseTO();
result.setData(new UserTO(...));
result.setSuccess(Boolean.TRUE);
result.setMessage("成功");
return result;
}
老实说,效率很低...
抽取ApiResultTO
上面的写法写多了,其实就会意识到,只要把data、success、message抽取到泛型类中就可以通用了:
@Data
@EqualsAndHashCode(callSuper = false)
public class ApiResultTO<T extends Serializable> extends BaseTO {
private static final long serialVersionUID = 4785961935752148785L;
/**
* 是否操作成功
*/
@ApiModelProperty(value = "是否操作成功", required = true, example = "true")
private boolean success;
/**
* 错误码
*/
@ApiModelProperty(value = "错误码", required = true, example = "0001")
private Integer errorCode;
/**
* 错误提示
*/
@ApiModelProperty(value = "错误提示", required = true, example = "系统错误")
private String message;
/**
* 操作结果
*/
@ApiModelProperty(value = "操作结果", required = true)
private T data;
public void setData(T data) {
this.data = data;
}
public static <K extends Serializable> ApiResultTO<K> buildSuccess(K data) {
ApiResultTO<K> result = new ApiResultTO<>();
result.setSuccess(true);
result.setErrorCode(0);
result.setData(data);
return result;
}
public static <K extends Serializable> ApiResultTO<K> buildSuccess(K data, String message) {
ApiResultTO<K> result = new ApiResultTO<>();
result.setSuccess(true);
result.setMessage(message);
result.setErrorCode(0);
result.setData(data);
return result;
}
public static <K extends Serializable> ApiResultTO<K> buildFailed(Integer code, String message) {
ApiResultTO<K> result = new ApiResultTO<>();
result.setSuccess(false);
result.setErrorCode(code);
result.setMessage(message);
return result;
}
public static <K extends Serializable> ApiResultTO<K> buildFailed(ErrorCodeEnum errorCodeEnum) {
ApiResultTO<K> result = new ApiResultTO<>();
result.setSuccess(false);
result.setErrorCode(errorCodeEnum.getCode());
result.setMessage(errorCodeEnum.getMessage());
return result;
}
public static <K extends Serializable> ApiResultTO<K> buildFailed(ErrorCodeEnum errorCodeEnum, String message) {
ApiResultTO<K> result = new ApiResultTO<>();
result.setSuccess(false);
result.setErrorCode(errorCodeEnum.getCode());
result.setMessage(message);
return result;
}
}
此时ApiResultTO更像一个工具类,就是用来包装实际的接口返回值的:
@GetMapping("/getUser")
public ApiResultTO<UserTO> getUser() {
// 查询并返回
return ApiResultTO.buildSuccess(new UserTO(...));
}
遇到诸如参数校验异常等情况,也可以按固定的格式告诉前端:
@GetMapping("/getUser")
public ApiResultTO<UserTO> getUser() {
// 参数校验失败
if(StringUtils.isEmpty(username)) {
// 也可以把常见的错误信息抽取到ErrorCodeEnum并传入(参考小册《枚举的应用》)
return ApiResultTO.buildFailed("用户名不能为空");
}
// 查询并返回
return ApiResultTO.buildSuccess(new UserTO(...));
}
但接口除了返回单个对象,还可能返回列表,此时后端必须返回相关的分页信息,又怎么处理呢?
分页
由于分页一般都是要传page和pageSize的,所以可以抽取出一个PageQueryTO让其他分页RequestTO继承:
@Data
public class PageQueryTO extends BaseTO {
private static final long serialVersionUID = -1319283881967144831L;
private static final Integer DEFAULT_PAGE = 1;
private static final Integer DEFAULT_PAGE_SIZE = 20;
/**
* 当前页数
*/
private Integer page = DEFAULT_PAGE;
/**
* 每页大小
*/
private Integer pageSize = DEFAULT_PAGE_SIZE;
}
好,接下来讨论如何处理分页请求的响应格式。
一般处理
现在很多公司都在用分页插件,比如PageHelper,此时针对PageHelper的分页可以封装一个专门的PageResult:
@Data
public class PageResult<T> implements Serializable {
private Integer code;
private String message;
private List<T> data;
// 新增和分页相关的三个字段
private Integer page;
private Integer pageSize;
private Long total;
private PageResult(List<T> data, Integer page, Integer pageSize, Long total) {
this.code = ExceptionCodeEnum.SUCCESS.getCode();
this.message = ExceptionCodeEnum.SUCCESS.getDesc();
this.data = data;
this.page = page;
this.pageSize = pageSize;
this.total = total;
}
/**
* 成功实体,传入一个page对象
*
* @param page
*/
public static <T> PageResult<T> success(Page<T> page) {
if (page != null) {
return new PageResult<>(page.getResult(), page.getPageNum(), page.getPageSize(), page.getTotal());
} else {
return new PageResult<>(new ArrayList<T>(), 0, 1, 10L);
}
}
}
此时,UserService#listUser()可能是这样定义的:
public Page<UserTO> listUser();
然后就可以在Controller层直接封装:
@GetMapping("/listUser")
public PageResult<UserTO> listUser() {
// 查询并返回(userService返回的是Page对象,含有列表+分页信息)
return PageResult.success(userService.listUser());
}
单个对象用Result(和上面的ApiResultTO没什么区别):
@Data
@NoArgsConstructor
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
private Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
private Result(Integer code, String message) {
this.code = code;
this.message = message;
this.data = null;
}
/**
* 带数据成功返回
*
* @param data
* @param <T>
* @return
*/
public static <T> Result<T> success(T data) {
return new Result<>(ExceptionCodeEnum.SUCCESS.getCode(), ExceptionCodeEnum.SUCCESS.getDesc(), data);
}
/**
* 不带数据成功返回
*
* @return
*/
public static <T> Result<T> success() {
return success(null);
}
/**
* 通用错误返回
*
* @param exceptionCodeEnum
* @return
*/
public static <T> Result<T> error(ExceptionCodeEnum exceptionCodeEnum) {
return new Result<>(exceptionCodeEnum.getCode(), exceptionCodeEnum.getDesc());
}
/**
* 通用错误返回
*
* @param exceptionCodeEnum
* @param msg
* @return
*/
public static <T> Result<T> error(ExceptionCodeEnum exceptionCodeEnum, String msg) {
return new Result<>(exceptionCodeEnum.getCode(), msg);
}
/**
* 通用错误返回
*
* @param exceptionCodeEnum
* @param data
* @param <T>
* @return
*/
public static <T> Result<T> error(ExceptionCodeEnum exceptionCodeEnum, T data) {
return new Result<>(exceptionCodeEnum.getCode(), exceptionCodeEnum.getDesc(), data);
}
}
电商APP处理
需要提一下的是,对于电商APP,你会发现大部分情况下它的"分页"都是采用"上滑"触发的,即本次请求的数据到底后,才会触发下一次请求。这样的好处是,后端不需要真的去COUNT(*)。
也不推荐COUNT(*),因为单表的数量经常是百万级别以上,使用COUNT(*)会拖慢查询效率。
后端只需要返回Boolean hasMore即可,表示是否还有数据,有的话客户端就可以继续发起请求:
@Data
public class UserListTO extends BaseTO {
private List<UserTO> userList;
private Boolean hasMore;
}
@GetMapping("/getUser")
public ApiResultTO<UserTO> getUser(Integer page, Integer pageSize) {
// 查询用户列表
List<UserTO> userList = userService.listUser();
// 封装
UserListTO userListTO = new UserListTO();
userListTO.setUserList(userList);
userListTO.setHasMore(userList.size() >= pageSize);
return ApiResultTO.buildSuccess(userListTO);
}
看明白setHasMore()的意思了吗?
但是上面的做法还是比较乱,它的JSON格式是这样的:
{
"data": {
"userList": [...], // 可能叫userList,也可能叫studentList
"hasMore": true
},
"success": true,
"message": "success"
}
为了格式更统一些,我们也封装一个TO,并且把hasMore提出来和message等字段同级:
{
"list": [], // 因为已经确定是分页,所以肯定是list
"hasMore": true,
"success": true,
"message": "success"
}
@Data
@NoArgsConstructor
public class PageResultTO<T extends Serializable> extends BaseTO {
private static final long serialVersionUID = 5962947140449215602L;
/**
* 是否查询成功
*/
private Boolean success;
/**
* 错误码
*/
private String errorCode;
/**
* 错误提示
*/
private String message;
/**
* 列表结果
*/
private List<T> itemList;
/**
* 当前页数
*/
private Integer currentPage;
/**
* 是否有下一页
*/
private boolean hasNextPage;
public static <K extends Serializable> PageResultTO<K> buildFailed(String errorCode, String message) {
PageResultTO<K> pageResult = new PageResultTO<>();
pageResult.setSuccess(false);
pageResult.setErrorCode(errorCode);
pageResult.setMessage(message);
return pageResult;
}
public static <K extends Serializable> PageResultTO<K> buildSuccess(List<K> itemList, Integer currentPage,
Boolean hasNextPage) {
PageResultTO<K> pageResult = new PageResultTO<>();
pageResult.setSuccess(true);
pageResult.setItemList(itemList);
pageResult.setCurrentPage(currentPage);
pageResult.setHasNextPage(hasNextPage);
return pageResult;
}
public static <K extends Serializable> PageResultTO<K> buildSuccess(List<K> itemList) {
PageResultTO<K> pageResult = new PageResultTO<>();
pageResult.setSuccess(true);
pageResult.setItemList(itemList);
pageResult.setCurrentPage(1);
pageResult.setHasNextPage(Boolean.FALSE);
return pageResult;
}
}
有更好的方案欢迎评论区留言~
具体工具类如何封装并不是最重要的,公司项目已经搭好了,拿来用就好了。关键是要明白,不同的情况到底需要返回什么字段,以及嵌套关系是怎样的。
统一结果的封装就讲到这里,下一节我们聊聊统一异常处理。
思考题
除了分页的响应格式稍微有点特殊,上面我们大致介绍了两种响应格式:
{
"data": {},
"success": true,
"message": "success"
}
{
"data": {},
"code": 200,
"message": "success"
}
大家觉得这两个有啥区别呢?
我曾经接触到一个场景:邀请3个好友进行红包助力,助力成功发放优惠券。
这个需求有个地方特别麻烦,它有很多场景需要判断:
- 单人当日发起助力次数是否达到上限
- 单人当日帮别人助力次数是否达到上限
- 当日优惠券是否已经被领完
- 当前助力活动是否完成
- 当前助力活动是否过期
- ...
用伪代码展示大概如下(逻辑判断顺序随便写的,不用在意):
public Object getActivity(Long uid) {
// 1.单人当日发起助力次数是否达到上限
// 2.单人当日帮别人助力次数是否达到上限
// 3.当日优惠券是否已经被领完
// ...
}
大家思考一下,对于不符合条件的情况,到底返回success=true还是false呢?
好像怎么说都对...比如,你可以认为这仅仅是业务逻辑不符合而已,整个请求并未发生异常,所以应该返回true。但从另一个角度讲,由于这个请求没有返回预期结果,返回false似乎也合理。
但不论返回true还是false,都有一个问题无法解决:前端/客户端如何知道具体是哪种情况导致返回的数据不符合预期呢?
为什么前端要知道是那种情况呢?因为不同情况需要展示的文案、图片都是不同的,后续处理的方式也不尽相同。
此时可以通过code来标识,比如code=100表示"单人当日发起助力次数是否达到上限",code=101表示"单人当日帮别人助力次数是否达到上限",只要和前端/客户端约定好,他们就可以根据code展示特定的页面。
所以一般来说,我推荐使用:
{
"data": {},
"code": 200,
"message": "success"
}
或者这样也行(兼容处理):
{
"data": {
"code": 200,
"xxx": "xxx"
},
"success": true
"message": "success"
}
不知道你有没有更好的解决方案呢?欢迎下方留言。