Skip to content

Spring MVC 统一包装返回结构

1. 背景介绍

在项目开发中,我们希望接口返回统一的结构体:

java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class R<T> {
    private int code;
    private String msg;
    private T data;

    public static <T> R ok(){
        return new R<T>(1, "success", null);
    }

    public static <T> R ok(T data){
        return new R<T>(1, "success", data);
    }

    public static <T> R error(String msg){
        return new R<T>(-1, msg, null);
    }

    public static <T> R error(String msg, T data){
        return new R<T>(-1, msg, data);
    }

    public static <T> R error(int code, String msg, T data){
        return new R<T>(code, msg, data);
    }
}

当然,我们可以在Controller的方法中显式地声明返回值类型为R

java
@RestController
public class TestController {
    @GetMapping("/calc")
    public R calc(@RequestParam("num1") int num1,
                  @RequestParam("num2") int num2)
    {
        int result = num1 / num2;
        return R.ok(result);
    }
}

但是,使用上面的方法,会导致每个方法的返回值都是R,对于代码理解不直观。我们希望方法直接返回目标类型,例如:

java
@RestController
public class GoodsController {
    @GetMapping("/goods/{id}")
    public Goods getGoodsById(@PathVariable("id") int id) {
        Goods goods = new Goods(
                id,
                "1001",
                "手机",
                "部",
                "12GB+1TB",
                new BigDecimal(6000),
                new BigDecimal(4800),
                1000
        );

        return goods;
    }
}

如上,在Controller的方法中直接返回数据类型(Goods),然后通过其他方法包装为R

2. 解决方案

我们可以通过实现ResponseBodyAdvice接口来达到上述目的:

java
@ControllerAdvice
public class GlobalResponseHandlerBasic implements ResponseBodyAdvice {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        return R.ok(body);
    }
}

该接口有两个方法:

  • supports(MethodParameter returnType, Class converterType):表示可以处理的方法。此处表示默认处理所有Controller接口方法。
  • beforeBodyWrite(...):实际处理方法,用于将Controller方法的返回值进行包装,第一个参数Object object就是Controller方法的返回值。 注意,ResponseBodyAdvice接口的实现需要加注解@ControllerAdvice,表示用于增强Controller。

此时,我们请求/goods/{id}接口,返回如下:

![image-20241219220248501](./assets/Spring MVC 统一包装返回结构/image-20241219220248501.png)

可以看到接口返回内容已经统一为R了。但是,此时还有一个问题:

![image-20241219220343391](./assets/Spring MVC 统一包装返回结构/image-20241219220343391.png)

如果Controller中的接口方法返回R,那么再包一层就会造成结构冗余。所以,我们可以在supports()方法中进行判断,如果接口方法已经返回R了,那么就不要进行包装:

java
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
    Class<?> returnClasstype = returnType.getMethod().getReturnType();
    if(returnClasstype == R.class) {
        return false;
    }
    return true;
}

![image-20241219220801965](./assets/Spring MVC 统一包装返回结构/image-20241219220801965.png)

3. 和@JsonView结合的问题

我们先在Goods类中定义视图,然后在接口方法中使用视图:

java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Goods {
    @JsonView(InnerJsonView.class)
    private int id;
    @JsonView(OuterJsonView.class)
    private String goodsCode;
    @JsonView(OuterJsonView.class)
    private String goodsName;
    @JsonView(OuterJsonView.class)
    private String unit;
    @JsonView(OuterJsonView.class)
    private String spec;
    @JsonView(OuterJsonView.class)
    private BigDecimal price;
    // 成本价
    @JsonView(InnerJsonView.class)
    private BigDecimal costPrice;
    // 库存
    @JsonView(OuterJsonView.class)
    private int stock;

    public static class OuterJsonView{}
    public static class InnerJsonView extends OuterJsonView{}
}
java
@GetMapping("/goods/{id}")
@JsonView(Goods.OuterJsonView.class)
public Goods getGoodsById(@PathVariable("id") int id) {
    Goods goods = new Goods(
            id,
            "1001",
            "手机",
            "部",
            "12GB+1TB",
            new BigDecimal(6000),
            new BigDecimal(4800),
            1000
    );

    return goods;
}

之后请求接口,结果如下:

![image-20241219221254783](./assets/Spring MVC 统一包装返回结构/image-20241219221254783.png)

发现结果为空!这显然是不对的。

4. 解决和@JsonView结合的问题

要解决和@JsonView结合的问题,需要我们自己定义一个注解来代替@JsonView,并在ResponseBodyAdvice接口实现中进行处理。

首先自定义一个注解来代替@jsonView,我们命名为@NestedJsonView

java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NestedJsonView {
    // 指定视图 
    Class<?> value() ;
}

然后在ResponseBodyAdvice接口实现中进行处理:

java
@ControllerAdvice
public class GlobalResponseHandler implements ResponseBodyAdvice {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        // 如果接口方法返回值类型为R,不再进行包装
        Class<?> returnClassType = returnType.getMethod().getReturnType();
        if (returnClassType == R.class)
            return false;

        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        String json;

        NestedJsonView methodAnnotation = returnType.getMethodAnnotation(NestedJsonView.class);
        if(methodAnnotation != null && methodAnnotation.value() != null){
            // 如果接口方法标注了NestedJsonView,则根据视图进行json序列化
            json = objectMapper.writerWithView(methodAnnotation.value()).writeValueAsString(body);
        }else{
            // 如果接口方法没有标注NestedJsonView或NestedJsonView的值为空,则直接进行json序列化
            json = objectMapper.writeValueAsString(body);
        }

        // 包装成统一返回结构体
        return R.ok(json);
    }
}

然后再接口方法上标注自定义注解@NestedJsonView

java
@GetMapping("/goods/{id}")
//    @JsonView(Goods.OuterJsonView.class)
@NestedJsonView(Goods.OuterJsonView.class)
public Goods getGoodsById(@PathVariable("id") int id) {
    Goods goods = new Goods(
            id,
            "1001",
            "手机",
            "部",
            "12GB+1TB",
            new BigDecimal(6000),
            new BigDecimal(4800),
            1000
    );

    return goods;
}

**注意:**我们需要在统一返回结构体的数据字段中标注@JsonRawValue注解:

java
public class R<T> {
    private int code;
    private String msg;
    @JsonRawValue
    private T data;
}

结果如下:

![image-20241219222552208](./assets/Spring MVC 统一包装返回结构/image-20241219222552208.png)

5. 原理分析

我们通过分析原理来回答为什么和@JsonView结合会出现问题。首先先看以下大概的流程图:

![请求在spring中的简单分析](./assets/Spring MVC 统一包装返回结构/请求在spring中的简单分析.png)

假设现在我们在接口方法中标注了@JsonView,并在ResponseBodyAdvice中统一包装返回值:

java
@GetMapping("/goods/{id}")
@JsonView(Goods.OuterJsonView.class)
public Goods getGoodsById(@PathVariable("id") int id) {
    Goods goods = new Goods(
            id,
            "1001",
            "手机",
            "部",
            "12GB+1TB",
            new BigDecimal(6000),
            new BigDecimal(4800),
            1000
    );

    return goods;
}
java
@Override
public Object beforeBodyWrite(Object body,
                              MethodParameter returnType,
                              MediaType selectedContentType,
                              Class selectedConverterType,
                              ServerHttpRequest request,
                              ServerHttpResponse response) {
    return R.ok(body);
}

那么当请求接口/goods/1后,接口方法返回值是Goods对象:

txt
Goods(id=1, goodsCode=1001, goodsName=手机, unit=部, spec=12GB+1TB, price=6000, costPrice=4800, stock=1000)

然后接口方法返回值进入RequestResponseBodyMethodProcessorhandleReturnValue方法进行处理。注意RequestResponseBodyMethodProcessor中存在字段advice,包括返回值处理器链responseBodyAdvice,其中就有我们定义的GlobalResponseHandlerBasic以及Spring自带的JsonViewResponseBodyAdvice。我们自定义的GlobalResponseHandlerBasic处在第一个。

![image-20241220183854439](./assets/Spring MVC 统一包装返回结构/image-20241220183854439.png)

RequestResponseBodyMethodProcessorhandleReturnValue方法中跟踪writeWithMessageConverters方法,最终会到RequestResponseBodyAdviceChainprocessBody方法:

java
	private <T> Object processBody(@Nullable Object body, MethodParameter returnType, MediaType contentType,
			Class<? extends HttpMessageConverter<?>> converterType,
			ServerHttpRequest request, ServerHttpResponse response) {

		for (ResponseBodyAdvice<?> advice : getMatchingAdvice(returnType, ResponseBodyAdvice.class)) {
			if (advice.supports(returnType, converterType)) {
				body = ((ResponseBodyAdvice<T>) advice).beforeBodyWrite((T) body, returnType,
						contentType, converterType, request, response);
			}
		}
		return body;
	}

可以看到该方法就是依次调用ResponseBodyAdvice的实现类。根据我们程序的顺序,先调用我们自定义的GlobalResponseHandlerBasic,包装成R后返回:

java
R(code=1, msg=success, data=Goods(id=1, goodsCode=1001, goodsName=手机, unit=部, spec=12GB+1TB, price=6000, costPrice=4800, stock=1000))

然后调用JsonViewResponseBodyAdvice中的方法,由于我们的接口方法标注了@JsonView,所以会进行处理:

java
public class JsonViewResponseBodyAdvice extends AbstractMappingJacksonResponseBodyAdvice {

	@Override
	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
    // 标注了JsonView注解
		return super.supports(returnType, converterType) && returnType.hasMethodAnnotation(JsonView.class);
	}

	@Override
	protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaType contentType,
			MethodParameter returnType, ServerHttpRequest request, ServerHttpResponse response) {

		JsonView ann = returnType.getMethodAnnotation(JsonView.class);
		Assert.state(ann != null, "No JsonView annotation");

		Class<?>[] classes = ann.value();
		if (classes.length != 1) {
			throw new IllegalArgumentException(
					"@JsonView only supported for response body advice with exactly 1 class argument: " + returnType);
		}

    // 设置序列化视图
		bodyContainer.setSerializationView(classes[0]);
	}

}

可以看到,JsonViewResponseBodyAdvice并没有直接序列化我们的返回结构R

之后,包装后返回值继续走到AbstractJackson2HttpMessageConverter中的writeInternal方法进行处理:

![image-20241220185332588](./assets/Spring MVC 统一包装返回结构/image-20241220185332588.png)

可以看到使用了JsonViewResponseBodyAdvice中配置的视图Goods.OuterJsonView来序列化返回值R,所以结果为空!

6. 贴一下完整的全局返回值处理器

由于在data上标注了@JsonRawValue,所以如果接口方法直接返回R,也需要处理:

java
@ControllerAdvice
public class GlobalResponseHandler implements ResponseBodyAdvice {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        Class<?> returnClassType = returnType.getMethod().getReturnType();
      	// 如果返回String,那么返回类型也应该是String,否则会报错
      	// 因为@JsonRawValue,所以需要将字符串再包一层objectMapper.writeValueAsString(body)
      	if (returnClassType == String.class){
            return objectMapper.writeValueAsString(R.ok(objectMapper.writeValueAsString(body)));
        }
        if (returnClassType == R.class){
            R r = (R)body;
            // 如果返回异常对象,会导致异常,并且会暴露内部实现,所以永远不要直接返回异常对象
            if(r.getData() != null && r.getData() instanceof Throwable){
                r.setData(null);
            }
            // 由于在data上标注了@jsonRawValue,所以需要将其内容转换为字符串
            r.setData(objectMapper.writeValueAsString(r.getData()));
            return r;
        }

        String json;

        NestedJsonView methodAnnotation = returnType.getMethodAnnotation(NestedJsonView.class);
        if(methodAnnotation != null && methodAnnotation.value() != null){
            // 如果接口方法标注了NestedJsonView,则根据视图进行json序列化
            json = objectMapper.writerWithView(methodAnnotation.value()).writeValueAsString(body);
        }else{
            // 如果接口方法没有标注NestedJsonView或NestedJsonView的值为空,则直接进行json序列化
            json = objectMapper.writeValueAsString(body);
        }

        // 包装成统一返回结构体
        return R.ok(json);
    }

}

注:如果接口方法直接返回字符串,建议在响应头中标注Content-Type:

java
@GetMapping(value = "/", produces = "application/json")
String main(){
    return "Hello World";
}