在 Spring Boot 中通过 ResponseBodyAdvice 统一编码响应体

在前文 《在 Spring Boot 中通过 RequestBodyAdvice 统一解码请求体》 中,我们介绍了如何通过 RequestBodyAdvice 组件统一地解码客户端编码后的请求体。

有请求,就有响应,本文将会带你了解如何使用 ResponseBodyAdvice 来统一对响应体进行编码。

这种场景比较常见,常用于 APP 客户端和服务器之间的通信。为了避免中间人通过抓包直接获取到服务器的响应数据,所以会对数据进行加密(AES、3DES、RSA 等等)。

客户端获取到加密数据后,使用本地存储的密钥进行解密从而得到原始数据。由于密钥没有经过网络传输,意味着加密数据不会轻易被人破解。当然,密钥由于存储在客户端,需要通过混淆等手段保证它不会被轻易的破解、获取(这种安全话题超出了本文的范畴)!

ResponseBodyAdvice

ResponseBodyAdvice spring mvc 提供的一个增强接口,用于在返回对象被 HttpMessageConverter 执行序列化之前对其进行一些自定义的操作。

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);

}

这是一个带泛型 <T> 的接口,T 表示返回的对象类型,这决定了它会对哪些返回的对象生效。另外,它比 RequestBodyAdvice 简单,它只有 2 个方法。

  • supports:用于确定该实现类是否支持对响应体进行处理,通过 returnType 参数可以获取到 Controller 方法的返回类型等信息。
  • beforeBodyWrite:该方法在响应体写入之前被调用,在这个方法中可以通过参数获取到最终要响应给客户端的对象,我们可以对这个对象进行一些操作,最后返回修改后的对象。

接下来我们通过一个示例来了解如何使用 ResponseBodyAdvice

假设,服务器返回的所有 JSON 数据都会被编码为 Base64 格式。

定义 @EncodeBody 注解

定义一个 @EncodeBody 注解,可用于 Controller 方法。

package cn.springdoc.demo.annotations;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Retention(RUNTIME)
@Target(METHOD)
public @interface EncodeBody {

}

只有使用 @EncodeBody 注解标识的 API 方法返回的对象,才会被编码。

Controller

package cn.springdoc.demo.web.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import cn.springdoc.demo.annotations.EncodeBody;

@RestController
@RequestMapping
public class DemoController {

    @GetMapping("/demo")
    @EncodeBody
    public ResponseEntity<Object> demo() {
        
        Map<String, Object> response = new HashMap<>();
        response.put("title", "spring 中文网");
        response.put("url", "springdoc.cn");
        
        return ResponseEntity.ok(response);
    }
}

这是一个非常普通的 API 端点,它返回一个 Map 对象,并且注解了 @EncodeBody

ResponseBodyEncodeAdvice

定义 ResponseBodyAdvice 的实现:ResponseBodyEncodeAdvice,用于把响应的 JSON 数据编码为 Base64。

package cn.springdoc.demo.web.advice;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import cn.springdoc.demo.annotations.EncodeBody;

@RestControllerAdvice
public class ResponseBodyEncodeAdvice implements ResponseBodyAdvice<Object> {

    // 系统中默认使用的 ObjectMapper
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 方法上注解了 @EncodeBody,才对返回对象进行编码
        return returnType.hasMethodAnnotation(EncodeBody.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
            ServerHttpResponse response) {

        if (body == null) {
            return body;
        }

        String jsonText = null;

        try {
            // 把响应体序列化为 JSON 字符串
            jsonText = this.objectMapper.writeValueAsString(body);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        
        // 对原始数据进行 Base64 编码
        String encodedText = Base64.getEncoder().encodeToString(jsonText.getBytes(StandardCharsets.UTF_8));
        
        // 以 Map 形式响应给客户端,保持 JSON 格式
        return Collections.singletonMap("data", encodedText);

    }
}

ResponseBodyEncodeAdvice 实现了 ResponseBodyAdvice 接口,并且指定了泛型为 Object,意味着该实现对所有返回对象都生效。

还注入了 ObjectMapper,用于把返回的对象序列化为 JSON 字符串。spring mvc 默认使用 Jackson 作为 JSON 的序列化、反序列化库,所以 ObjectMapper 是已经预置的,不需要自己创建。当然,你如果对序列化方式有特殊需求,可以自己实例化一个 ObjectMapper,甚至是换一个 JSON 库,比如:FastJson 都可以。

beforeBodyWrite 方法中,先使用 ObjectMapper 把返回对象序列化为 JSON 字符串,也就是响应的原文。然后再把原文编码为 Base64 字符串。最后通过一个通用的 Map 返回到客户端。

测试

启动应用,使用 cURL 进行测试:

$ curl localhost:8080/demo
{"data":"eyJ0aXRsZSI6InNwcmluZyDkuK3mlofnvZEiLCJ1cmwiOiJzcHJpbmdkb2MuY24ifQ=="}

如你所见,返回的 JSON 格式正是我们在 ResponseBodyEncodeAdvice 中定义的。其中 data 字段的值就是对响应原文进行 Base64 编码后的密文。

解码 data:

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class Main {
    public static void main(String[] args) throws Exception {
        
        byte[] raw = Base64.getDecoder().decode("eyJ0aXRsZSI6InNwcmluZyDkuK3mlofnvZEiLCJ1cmwiOiJzcHJpbmdkb2MuY24ifQ==");
        
        System.out.println(new String(raw, StandardCharsets.UTF_8));
        
    }
}

输出如下:

{"title":"spring 中文网","url":"springdoc.cn"}

一切 OK,解码后的结果正是在 Controller 中定义的返回对象。客户端也只要遵循这种格式,统一地对响应体进行解码就可以获取到原文。

而这一切对 Controller 都是透明的,Controller 不需要进行任何修改。

总结

本文介绍了 Spring 中的 ResponseBodyAdvice 接口,开发人员可以对 Controller 方法返回的响应体进行处理,例如添加统一的响应头、修改响应体的内容格式、对响应体进行编码、加密等。这能够更加方便地实现全局的响应处理逻辑,提升代码的可维护性和复用性。