SpringBoot - 接口统一返回格式
# SpringBoot - 接口统一返回格式
# 为什么要返回统一的标准格式
在默认情况下,SpringBoot的返回格式常见的有三种:
第一种:返回 String
@GetMapping("/hello")
public String getStr(){
return "hello,javadaily";
}
2
3
4
此时调用接口获取到的返回值是这样:
hello,javadaily
第二种:返回自定义对象
@GetMapping("/aniaml")
public Aniaml getAniaml(){
Aniaml aniaml = new Aniaml(1,"pig");
return aniaml;
}
2
3
4
5
此时调用接口获取到的返回值是这样:
{
"id": 1,
"name": "pig"
}
2
3
4
第三种:接口异常
@GetMapping("/error")
public int error(){
int i = 9/0;
return i;
}
2
3
4
5
此时调用接口获取到的返回值是这样:
{
"timestamp": "2021-07-08T08:05:15.423+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/wrong"
}
2
3
4
5
6
基于以上种种情况,如果你和前端开发人员联调接口她们就会很懵逼,由于我们没有给他一个统一的格式,前端人员不知道如何处理返回值。
还有甚者,有的同学比如小张喜欢对结果进行封装,他使用了Result对象,小王也喜欢对结果进行包装,但是他却使用的是Response对象,当出现这种情况时我相信前端人员一定会抓狂的。
所以我们项目中是需要定义一个统一的标准返回格式的。
# 定义返回标准格式
一个标准的返回格式至少包含3部分:
- status 状态值:由后端统一定义各种返回结果的状态码
- message 描述:本次接口调用的结果描述
- data 数据:本次返回的数据。
- timestamp: 接口调用时间(可选)
{
"status":"200",
"message":"操作成功",
"data":"hello,javadaily"
}
2
3
4
5
# 定义返回对象
/**
* 统一返回格式
*
* @author jason
*/
public class ResultResponse<T> implements Serializable {
@Getter
@Setter
private Integer code;
@Getter
@Setter
private String message;
@Getter
@Setter
private T data;
private long timestamp;
public ResultResponse() {
this.timestamp = System.currentTimeMillis();
}
private static final int SUCCESS_CODE = 200;
public static final String DEFAULT_SUCCESS_MESSAGE = "SUCCESS";
public static final String DEFAULT_ERROR_MESSAGE = "UNKNOWN ERROR";
public static <T> ResultResponse<T> createFailureResponse(int code, String msg, T data) {
return new ResultResponse<T>().genError(code, msg, data);
}
public static <T> ResultResponse<T> createSuccessResponse(T data) {
return new ResultResponse<T>().genSuccess(data);
}
public static <T> ResultResponse<T> createFailureResponse(BizException bizException) {
return new ResultResponse<T>().genError(bizException);
}
public static <T> ResultResponse<T> createFailureResponse(BizExceptionCode bizExceptionCode) {
return new ResultResponse<T>().genError(bizExceptionCode);
}
public static <T> ResultResponse<T> createFailureResponse(BizExceptionCode bizExceptionCode, T data) {
return new ResultResponse<T>().genError(bizExceptionCode, data);
}
public ResultResponse<T> genSuccess(T data) {
code = SUCCESS_CODE;
message = DEFAULT_SUCCESS_MESSAGE;
this.data = data;
return this;
}
public ResultResponse<T> genError(int code, String msg, T data) {
this.code = code;
this.message = msg;
this.data = data;
return this;
}
public ResultResponse<T> genError(BizException ex) {
code = ex.getErrorCode();
message = ex.getMessage();
data = null;
return this;
}
public ResultResponse<T> genError(BizExceptionCode bizExceptionCode) {
code = bizExceptionCode.code;
message = bizExceptionCode.message;
data = null;
return this;
}
public ResultResponse<T> genError(BizExceptionCode bizCode, T data) {
code = bizCode.code;
message = bizCode.message;
this.data = data;
return this;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# 定义状态码
/**
* @author jason
*/
public enum BizCode {
/**服务异常**/
BAD_REQUEST(400, "参数错误"),
SYSTEM_ERROR(500,"系统异常,请稍后重试"),
INVALID_TOKEN(2001,"访问令牌不合法"),
ACCESS_DENIED(2003,"没有权限访问该资源");
public final Integer code;
public final String message;
BizCode(int code, String message) {
this.code = code;
this.message = message;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 定义异常类
业务逻辑代码通常封装在service类中,且需要抛出异常(自定义状态码和异常信息)
/**
* @author jason
*/
public abstract class BaseException extends RuntimeException {
private static final long serialVersionUID = -5099085051827483520L;
@Getter
@Setter
private int errorCode;
public BaseException(String errorMessage) {
super(errorMessage);
}
public BaseException(int errorCode, String errorMessage) {
super(errorMessage);
this.errorCode = errorCode;
}
public BaseException(String errorMessage, Throwable e) {
super(errorMessage, e);
}
public BaseException(int errorCode, String errorMessage, Throwable e) {
super(errorMessage, e);
this.errorCode = errorCode;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
以及子类 BizException:
/**
* 自定义业务异常,有明确的业务语义,不需要记录Error日志,不需要Retry
*
* @author jason
*/
public class BizException extends BaseException {
private static final int DEFAULT_ERROR_CODE = 510;
private static final long serialVersionUID = -7646408688819800761L;
public BizException(String errorMessage) {
super(DEFAULT_ERROR_CODE, errorMessage);
}
public BizException(int errorCode, String errorMessage) {
super(errorCode, errorMessage);
}
public BizException(String errorMessage, Throwable e) {
super(errorMessage, e);
}
public BizException(int errorCode, String errorMessage, Throwable e) {
super(errorCode, errorMessage, e);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 统一返回格式
/**
* @author jason
*/
@RestController
public class DemoController {
@GetMapping("/success")
public ResultResponse<String> success() {
return ResultResponse.createSuccessResponse("hello!");
}
@GetMapping("/exception")
public ResultResponse<String> exception() {
return ResultResponse.createFailureResponse(BizCode.ACCESS_DENIED);
}
@GetMapping("/exception2")
public ResultResponse<String> exception2() {
try {
return ResultResponse.createSuccessResponse(mockServiceMethod());
} catch (BizException e) {
return ResultResponse.createFailureResponse(e);
}
}
private String mockServiceMethod() {
throw new BizException(BizCode.ACCESS_DENIED.code, BizCode.ACCESS_DENIED.message);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
返回结果:
success结果:
{
"code": 200,
"message": "SUCCESS",
"data": "hello!",
"timestamp": 1682001192443
}
exception和exception2结果:
{
"code": 2003,
"message": "没有权限访问该资源",
"data": null,
"timestamp": 1682001467136
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 错误码设计
# 方案一
根据《Java 开发手册》给出的错误码定义建议设计出的面向外部传递的错误码共五位,并且有如下分类:
- 错误类型,表示错误来源,一位字母。
- 错误编码,表示具体错误,四位数字。
错误码的后三位编号与 HTTP 状态码没有任何关系。
错误码即人性,感性认知+口口相传,使用纯数字来进行错误码编排不利于感性记忆和分类
说明:数字是一个整体,每位数字的地位和含义是相同的。 反例:一个五位数字 12345,第1位是错误等级,第 2 位是错误来源,345 是编号,人的大脑不会主动地分辨每位数字的不同含义。
下图是《手册》给出的错误码示例:
来源:阿里云开发 https://zhuanlan.zhihu.com/p/152115647
# 方案二
需求说明
设计错误码规约前,首先要说清楚我们的需求:
- 让客户端能够感知到 HTTP 请求是否成功,以便客户端决定下一步该如何处理。
- 错误返回时需要携带错误码和出错信息,可以让开发者通过错误码快速定位问题原因。
错误码设计建议
错误码主要是为了体现业务系统的报错位置及原因,因此在设计时需要给它赋予一定的业务属性。
谁的错?错在哪?
程序开发人员拿到错误码后,必须能够快速知晓错误来源,判断是谁的问题。这也就要求我们设计的错误码首先要具有一定的识别度,方便记忆和对比,并且要有利于团队快速对错误原因达到一致认知。
内容格式统一
错误码应该是统一长度,且内容格式也应该固定的,而不应该是随机编码产生的。其内容可以是一个整数,也可以是一个字符串,但它必须是错误的唯一标识。
通过研究对比各个平台的错误码,我总结出了一个比较实用的错误码设计方案:使用字符数字组合表示,一共有4段组成,不同段表示不同含义。
合理的内容格式,更便于错误码的管理和使用,一方面我们可以根据业务需求有规则的自行扩展;另一方面通过错误码能够精准地定位到具体是哪里出现的什么错误。
错误语义信息
错误码之外的业务独特信息应由 message 来承载,而不是让错误码本身涵盖过多的具体业务属性。通常在请求出现错误时,我们需要返回的错误描述一般包括:错误码,错误说明以及参考文档(可选)
需要注意的是,错误说明是展示给用户的,最好直接告诉用户“该怎么做”而不是“哪里错了”,并且错误说明中不应该包含敏感信息(例如:堆栈信息)。但是为了方便问题排查,可以在系统内部日志中打印详细的错误堆栈信息。
合理使用 HTTP 状态码
当请求出错时,应该合理的返回 HTTP 状态码,以便让客户端感知到 HTTP 是否请求成功。为了更清晰的表述和区分状态码的含义,将 HTTP 状态码分成如下5类:
HTTP 状态码有很多,但是在错误码设计时,你只需要重点关注下面这些状态码:
- 200 - 表示请求成功执行
- 400 - 表示客户端出问题
- 401 - 表示认证失败
- 403 - 表示授权失败
- 404 - 表示资源找不到
- 405 - 客户端请求中的方法被禁止
- 500 - 表示服务端出问题
在项目开发中,你需要根据实际情况,灵活控制状态码的使用,让客户端的处理更容易。
综上所述,一个合理的错误返回应该包含 HTTP 状态码和错误码,并且错误码需要对外暴露错误说明,最好还要支持返回参考文档。例如:
HTTP/1.1 405
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 28 Apr 2022 09:55:54 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{"code":"U00N03", "msg":"HTTP请求方式不受支持"}
2
3
4
5
6
7
8
总结
总的来说,制定一套错误码规约还是相对容易的,难的是在长期的实践中如何管理并正确使用错误码,保证不被滥用,其复杂程度跟项目规模相匹配。
所以我建议在规约设计时,应该以服务业务为导向,避免过度设计,保持简洁;在管理使用时,应该以先到先得的原则统一审批生效,生效后永久固定。
以上就是我对于错误码规约制定一些想法,规则的制定是灵活的,可变通的,同样也没有绝对的好与坏,只要可以满足你的业务需求就是好的,优秀的。
本人能力有限,各位勉强一观,若能抛砖引玉,不失为一件幸事。
来源:作者 小瓦匠学编程 https://mp.weixin.qq.com/s/2Puw3y3mIHjIkfPMA8vQBQ
# 优雅实现方式
# 使用ResponseBodyAdvice
要优化这段代码很简单,我们只需要借助SpringBoot提供的ResponseBodyAdvice
即可。
ResponseBodyAdvice的作用:拦截Controller方法的返回值,统一处理返回值/响应体,一般用来统一返回格式,加解密,签名等等。
先来看下ResponseBodyAdvice
的源码:
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
}
2
3
4
5
6
我们只需要编写一个具体实现类即可:
/**
* @author jason
*/
@RestControllerAdvice(basePackages = "cn.jtoss")
@Slf4j
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return true;
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object object, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if (object instanceof String) {
return objectMapper.writeValueAsString(ResultResponse.createSuccessResponse(object));
}
return ResultResponse.createSuccessResponse(object);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
需要注意两个地方:
@RestControllerAdvice
注解@RestControllerAdvice
是@RestController
注解的增强,可以实现三个方面的功能:- 全局异常处理
- 全局数据绑定
- 全局数据预处理
String类型判断
if (object instanceof String) {
return objectMapper.writeValueAsString(ResultResponse.createSuccessResponse(object));
}
2
3
如果Controller直接返回String的话,SpringBoot是直接返回,故我们需要手动转换成json。
经过上面的处理我们就再也不需要通过ResultResponse.createSuccessResponse()
来进行转换了,直接返回原始数据格式,SpringBoot自动帮我们实现包装类的封装。
@GetMapping("/successElegant")
public String successElegant() {
return "Elegant";
}
2
3
4
返回结果:
{
"code": 200,
"message": "SUCCESS",
"data": "Elegant",
"timestamp": 1682003906603
}
2
3
4
5
6
# 接口异常问题
此时有个问题,由于我们没对Controller的异常进行处理,当我们调用的方法一旦出现异常,就会出现问题,比如下面这个接口
@GetMapping("/error")
public int error() {
int i = 1/0;
return i;
}
2
3
4
5
返回结果为Spring Boot框架定义的状态码为500的HTML页面或json格式,这显然不是我们想要的结果。
# 全局异常处理器
使用全局异常处理器可以解决如下问题:
- 不用手写try...catch,由全局异常处理器统一捕获
- 对于自定义异常,只能通过全局异常处理器来处理
- 当我们引入Validator参数校验器时,参数校验不通过会抛出异常,此时是无法用
try...catch
捕获的,只能使用全局异常处理器。
实现方式如下:
/**
* @author jason
*/
@Slf4j
@RestControllerAdvice
public class RestExceptionHandler {
/**
* 抓取全局异常(保底)
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultResponse<String> exception(Exception e) {
log.error("全局异常信息: {}", e.getMessage(), e);
return ResultResponse.createFailureResponse(BizCode.SYSTEM_ERROR.code, e.getMessage(), null);
}
/**
* 抓取自定义异常
*/
@ExceptionHandler(BaseException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultResponse<String> exception(BaseException e) {
return ResultResponse.createFailureResponse(e.getErrorCode(), e.getMessage(), null);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
有三个细节需要说明一下:
@RestControllerAdvice
,RestController的增强类,可用于实现全局异常处理器@ExceptionHandler
,统一处理某一类异常,从而减少代码重复率和复杂度,比如要获取自定义异常可以@ExceptionHandler(BaseException.class)
@ResponseStatus
指定客户端收到的http 状态码,可以统一设置为 200,具体状态码以 response body 中的 code 为准;或者根据情况设置为 500,401,403 等状态
这时候之前的exception2接口就可以修改为:
@GetMapping("/exception3")
public String exception3() {
return mockServiceMethod();
}
2
3
4
但是当我们同时启用统一标准格式封装功能ResponseAdvice
和RestExceptionHandler
后,为同时兼容上面两种方式返回结果,需要在 ResponseAdvice
类中修改 beforeBodyWrite
方法:
@SneakyThrows
@Override
public Object beforeBodyWrite(Object object, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if (object instanceof String) {
return objectMapper.writeValueAsString(ResultResponse.createSuccessResponse(object));
}
// 如果返回的结果是ResultResponse对象,直接返回即可
if (object instanceof ResultResponse) {
return object;
}
return ResultResponse.createSuccessResponse(object);
}
2
3
4
5
6
7
8
9
10
11
12
13
# 处理非 Controller 异常
@RestControllerAdvice 与 @ExceptionHandler 只能全局捕获由 Controller 抛出的异常,如 Spring Security Filter 中抛出的异常默认情况下则无法该方式捕获,比如 AccessDeniedException、AuthenticationException 或 业务自定义异常。这是因为这些异常是在 Controller 之前抛出的。
而统一处理 Spring Security 中抛出的异常可以通过下面两种方式:
- 通过自定义类实现 AuthenticationEntryPoint 接口并重写 commence 方法,自定义返回 body
- 通过自定义类实现 AuthenticationEntryPoint 接口并重写 commence 方法,并将异常交由框架的 HandlerExceptionResolver 处理,再通过 @RestControllerAdvice 与 @ExceptionHandler 处理
具体实现方式见这篇文章:https://www.baeldung.com/spring-security-exceptionhandler
更加方便的处理方式:重写 BasicErrorController 的 /error
接口
@RestController
@Slf4j
public class GlobalErrorController extends BasicErrorController {
public GlobalErrorController() {
this(new DefaultErrorAttributes(), new ErrorProperties());
}
public GlobalErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties) {
super(errorAttributes, errorProperties);
}
public GlobalErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties, List<ErrorViewResolver> errorViewResolvers) {
super(errorAttributes, errorProperties, errorViewResolvers);
}
@SneakyThrows
@Override
@AnonymousAccess
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
Throwable throwable = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (throwable != null) {
if (throwable instanceof BizException) {
throw (BizException) throwable;
}
if (throwable instanceof Exception) {
throw (Exception) throwable;
}
}
HttpStatus status = getStatus(request);
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
String errorMessage = (String) body.getOrDefault("error", "未知错误");
ResultResponse<String> resultResponse = ResultResponse.createFailureResponse(status.value(), errorMessage, null);
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> data = objectMapper
.convertValue(resultResponse, new TypeReference<Map<String, Object>>() {});
return new ResponseEntity<>(data, status);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
直接重新抛出异常,由于是 Controller 中抛出的,所以会被 @RestControllerAdvice 与 @ExceptionHandler 捕获。而最初的异常可以通过 request 中的属性获取:
Throwable throwable = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
如果 throwable 为 null,就需要手动返回 ResponseEntity,由于此时 ResponseEntity 的 body 是 Map 类型,所以同时需要在 ResponseAdvice
类中修改 beforeBodyWrite
方法:
@SneakyThrows
@Override
public Object beforeBodyWrite(Object object, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if (object instanceof String) {
return objectMapper.writeValueAsString(ResultResponse.createSuccessResponse(object));
}
if (object instanceof ResultResponse) {
return object;
}
// 如果是 Map 则直接返回,因为已经处理过了
if (object instanceof Map) {
return object;
}
return ResultResponse.createSuccessResponse(object);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
需要注意的是由于重写了 /error
,需要在 Spring Security 将该 url 重新设置为任何人可访问。
# 另外
在实际开发中,非生成环境为了方便定位调用接口返回的异常,可以将异常 stackTrace 部分信息也返回。
比如下面代码:
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultResponse<String> exception(Exception e) {
log.error("全局异常信息: {}", e.getMessage(), e);
return ResultResponse.createFailureResponse(BizCode.SYSTEM_ERROR.code, e.getMessage(), stackTraceMessage(e));
}
private String stackTraceMessage(Exception e) {
if (EnvironmentEnum.PRODUCT.getProfile().equalsIgnoreCase(profile)) {
return null;
}
int defaultDeep = 10;
StringBuilder sb = new StringBuilder();
StackTraceElement[] stackTrace = e.getStackTrace();
int deep = Math.min(stackTrace.length, defaultDeep);
for (int i = 0; i < deep; i++) {
sb.append(stackTrace[i]);
sb.append("\r\n ");
}
return sb.toString();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 易错场景
例如当前接口调用了其他服务接口,其他服务接口也会返回结果,那当前接口应该如何返回。详细可见:接口的响应要明确表示处理结果
# 源码
源码可见:https://github.com/hengwen/spring-demo/tree/main/springbootresponse
# 参考
- https://www.cnblogs.com/jianzh5/p/15018838.html
- https://www.baeldung.com/spring-security-exceptionhandler
- https://www.baeldung.com/exception-handling-for-rest-with-spring
- 《错误码如何设计才合理?》https://zhuanlan.zhihu.com/p/152115647
- 《错误码应该如何设计?》https://mp.weixin.qq.com/s/2Puw3y3mIHjIkfPMA8vQBQ