0%

在SpringBoot中使用统一的API Response

我记得最之前就已经搞过这个,但是没记录成日志,后来一直都没找到要点去这样做。

其实这个功能真的很实用,统一的API Response,对前端开发是非常友好的,也可以约束服务端的开发规范。

一、什么是统一的Response

我们一般都是基于Restful开发的API,一般情况下会占用HTTP STATUS来表示Response的状态。但是HTTP STATUS的表达能务非常有限(也就只有那几种状态),而且容易给开发造成误解(有时真的是容器出现了异常而不是应用出现了异常,导致误判)。

所以,一般对外的API,我们都不会使用HTTP STATUS来做API输出,而是会将状态下移,放进应答之内:

1
2
3
4
5
{
"code": 0,
"error": null,
"data": "some data"
}

一般我们会定义一个类来封装它:

1
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
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* API response
*
* @author kut
*/
@ApiModel
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Response {
// code
@ApiModelProperty(value = "code", example = "0")
private int code;

// error
@ApiModelProperty(value = "error", example = "some error")
private String error;

// data
@ApiModelProperty(value = "data")
private Object data;
}

这样,前端开发人员就很容易区分服务器异常和应用异常了。只要是HTTP STATUS不是200(*)的,直接认为是服务器异常,而应答中code不为0的,则认为是应用异常。

二、问题

由于使用了统一的response,所以也导致了我们代码上的一些问题,我们的代码再也做不到像以前那样Restful了:

1
2
3
4
@GetMapping("/{id}")
public Response fetchDetail(@PathVariable("id") String id) {
return ...
}

很明显,上面的代码表现能力相当有限,Response是什么鬼?它返回什么类型的数据?真真不如原先写的代码有表达力:

1
2
3
4
@GetMapping("/{id}")
public UserVO fetchDetail(@PathVariable("id") String id) {
return ...
}

上面的代码很清晰地就表达了返回的数据类型,明显比之前的好上许多。

那么,有没有好的办法既使用原先的代码,又可以统一使用统一Response?

三、答案

答案:有的

SpringMVC 提供了改写应答数据的方法,在数据被渲染前,对数据进行改装。

这里只拿WebFlux进行说明,因为在API级别,我们已经不再使用占用大量资源的Tomcat等Servlet容器,而是使用更轻量的Netty Webflux实现。

我们需要实一个ResponseBodyResultHandler,对数据进行改写:

1
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
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

@Slf4j
public class ApiResponseBodyResultHandler extends ResponseBodyResultHandler {
public ApiResponseBodyResultHandler(List<HttpMessageWriter<?>> writers, RequestedContentTypeResolver resolver) {
super(writers, resolver);
}

private static MethodParameter param;

static {
// 这里构建一个Webflux框能识别的param,这个param表征为一个方法,返回值为我们的统一Response
try {
param = new MethodParameter(
ApiResponseBodyResultHandler.class.getDeclaredMethod("methodForParams"),
-1
);
} catch (NoSuchMethodException e) {
log.warn("Build param failure: {} - {}", e.getMessage(), e.getStackTrace());
}
}

private static Mono<Response> methodForParams() {
return null;
}

@Override
public boolean supports(HandlerResult result) {
return super.supports(result);
}

@Override
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) {
Object returnValue = result.getReturnValue();

// 这里区分ApiError,ApiError 生成一个带错误码的 Response
Response response;
if (returnValue instanceof ApiError) {
ApiError error = (ApiError) returnValue;
response = new Response(error.getCode(), error.getError(), null);
}
// 这里将返回的数据包状在repsonse中,实现对数据的包装。
else {
response = new Response(0, null, result.getReturnValue());
}
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return writeBody(response, param, exchange);
}
}

搞定上面这个类后,还需要将它注册到Webflux中,这样才能让改写生效:

1
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
import am.common.api.ApiExceptionHandler;
import am.common.api.ApiResponseBodyResultHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.config.CorsRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler;

/**
* Api configuration
*
* @author kut
*/
@Configuration
@Slf4j
public class ApiConfiguration implements WebFluxConfigurer {

@Bean
public ResponseBodyResultHandler buildResponseBodyResultHandler(
ServerCodecConfigurer serverCodecConfigurer,
RequestedContentTypeResolver requestedContentTypeResolver) {
return new ApiResponseBodyResultHandler(serverCodecConfigurer.getWriters(), requestedContentTypeResolver);
}
}

到此,整个实现完成,我们可以既保持原有的Restful API的写法,又可以使用统一的Response。

四、处理错误

前面说到对ApiError进行特殊处理,那么ApiError又是什么来的呢?

ApiError是一个对错误异常进行统一包装的类,和Response有点像。我们对应用所有RuntimeException和Checked Exception进行统一处理并返回。例如:

1
2
3
4
public void someCodes() {
// ..... some codes
throw new ApiException(503, "fuck you every day!");
}

我们希望得到以下应答:

1
2
3
4
{
"code": 503,
"error": "fuck you every day!"
}

所以,我们需要对异常进行全局拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class ApiExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiError convertExceptionMsg(Exception e) {
if (e instanceof ApiException) {
ApiException apiException = (ApiException) e;
return new ApiError(apiException.getCode(), apiException.getError());
} else {
e.printStackTrace(System.err);
log.warn("Receive exception: {}", (Object) e.getStackTrace());
return new ApiError(500, e.getMessage());
}
}
}

这里为什么不直接使用Response,而是使用ApiError呢?

其实ApiExceptionHandler处理异常后返回的数据会再次交由前面定义的ApiResponseBodyResultHandler类进行包装,所以,如果直接返回Response,会得到下面的结果:

1
2
3
4
5
6
7
8
{
"code": 0
"error": null
"data": {
"code": 500,
"error": "some error"
}
}

对,你的response被当成是数据包装在另一个response中了,这显然不是我们想要的。

所以我们返回ApiError,让ApiResponseBodyResultHandler类识别它,并重新包装成Response数据返回,这样做才能避免上面的问题。

搞完上面的类,我们需要注册到SpringBoot中才能使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import am.common.api.ApiExceptionHandler;
import am.common.api.ApiResponseBodyResultHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.config.CorsRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler;

/**
* Api configuration
*
* @author kut
*/
@Configuration
@Slf4j
public class ApiConfiguration implements WebFluxConfigurer {

@Bean
public ApiExceptionHandler buildApiExceptionHandler() {
return new ApiExceptionHandler();
}
}

五、总结

总算写完,其实这个方案在五六年前就已经在旧项目用了,只是没做记录,一次又一次的数据丢失,导致解决方案没有留下来,导致后面的项目写得特别别扭。

现在好了,记录下来,以备后用。