统一结果封装

作者简介:大家好,我是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"
}

不知道你有没有更好的解决方案呢?欢迎下方留言。

相关推荐
ljh_a14 个月前
设计App的后端接口分类以及环境依赖包详情
sql·mysql·微服务·php·swoole·项目设计·easyswoole
smart哥1 年前
再看参数校验
参数校验·项目设计