一次在 SpringMVC 中实现对响应报文加密的实践(部分源码解读 + 6 种方式实现)

1,773 阅读13分钟

前言

某项目里已经存在对外的固定报文格式,最近需求增加,需要兼容另一个系统的接口请求和返回格式。虽然对请求的解析也不一致,但这里要讲的是对返回值的处理,所以忽略请求。先来看看原先的和目前需要兼容的返回值上面有什么区别:

  • 旧:JSON 明文、RSA 签名
  • 新:Base64 + URLEncode 格式化(后面统一称为加密)、RSA 签名

Spring(SpringMVC) 提供了非常多切入点来实现这个需求。下面我会结合着自己的 实践经历和源码 来简单地谈谈各种实现方式。看完这篇你可能会了解到以下几点:

  • 一个 HTTP 请求在 SpringMVC 中会经过哪些步骤?
  • SpringMVC 对消息的转换是在哪个类做的?
  • 如何自定义实现 HandlerMethodReturnValueHandlerHttpMessageConverter?有哪些注意点?
  • 如何自定义实现 @ResponseBody 注解对应的功能
  • ControllerAdvice功能是什么?以及是在源码中哪一步实现的?
  • 如何使用 Filter、AOP 等等实现自定义输出

大概能对 Java Web 实现对响应内容、形式等改变的需求的几种实现方式有所了解,再遇到类似场景就可以挑选合适的方式来满足实际业务。

一、固定调用实现

我们能想到的 最简单朴实的方法是在代码里面用逻辑调用来实现,使用统一的父类接受业务层的返回,在每个 Controller 方法中固定调用签名和加密方法。如下:

public class NewOpenApiController {
    @Autowired
    private NewService newService;

    @PostMapping(value = {"/var/{action}"})
    @ResponseBody
    public NewBaseResponseVo webapi(@PathVariable("action") String action) {
        return doSignAndCodec(service.progress());
    }

    private NewBaseResponseVo doSignAndCodec(NewBaseResponseVo responseVo) {
        // do sign
        // do codec
        return responseVo;
    }
}

假如我们这个 Controller 对外提供 100 个方法,那我们这个调用至少需要写 100 次,虽然可以用 @PathVariable 来尽可能地减少 Controller 层的接口方法数量。但调用这个动作依旧是重复的,这个方式确实不够优雅,我们先把它作为最后的妥协方案。

二、AOP 拦截器

我们设想是能够将签名、加密方法与业务逻辑分离开,业务层只管自己返回,剩下的签名加密在另外的类实现。到这里我们自然而然就能想到 Spring 提供的 AOP 功能(日志切面、权限控制等)。只需要使用简单的注解就能对指定方法的增强,从而 将一些通用的切面逻辑与业务隔离开。如下:

@Aspect
@Component
public class NewOpenApiAspect {
    @Around("execution(* com.xxx.xxx.Controller..*.*(..))")
    public String handleResponse(ProceedingJoinPoint pjp) {
        // 自定义签名、加密逻辑
        return "处理后的响应报文";
    }
}

这样子还不够,在 Spring 中 Aspect 拦截比 SpringMVC 对结果值的 convert 操作优先,结果输出和格式化最终还是在 SpringMVC 内置的转换器中进行

而对结果值的 convert 操作在 Spring 容器启动时已经绑定好,假如我们 Controller 方法此时的返回值声明的还是 NewBaseResponseVo 类型,利用 AOP 我们在 运行时动态地修改成了 String 类型,那执行时就会 报错。为了避免这个问题,这里还需要将 Controller 方法的返回值也同样改为 String,如下:

public class NewOpenApiController {
    @Autowired
    private NewService newService;

    @PostMapping(value = {"/var/{action}"})
    @ResponseBody
    public String webapi(@PathVariable("action") String action) {
        return doformat(service.progress());
    }

    private String doformat(NewBaseResponseVo responseVo) {
        // 将实体类 format 成 String 类型
        return responseVo;
    }
}

到这里我们已经可以实现需求了。虽然隔离了签名加密逻辑,但在 Controller 层除了调用业务层逻辑外还有 format 操作,与第一种方法相比也没优雅多少。再来看看其他的方法。

三、基于 @ControllerAdvice

假如是该需求只是实现对返回报文进行签名,那 @ControllerAdvice 注解已经能满足需求

@ControllerAdvice(assignableTypes = {NewOpenApiController.class})
public class NewOpenApiResponseAdvice implements ResponseBodyAdvice<NewBaseResponseVo> {
    @Override
    public boolean supports(MethodParameter t, Class<? extends HttpMessageConverter<?>> c) {
        return true;
    }
    
    @Override
    public NewBaseResponseVo beforeBodyWrite(NewBaseResponseVo body, MethodParameter mp, MediaType mt, Class<? extends HttpMessageConverter<?>> t, ServerHttpRequest r, ServerHttpResponse s) {
        // 自定义签名、加密逻辑
        return body;
    }
}

自定义类,实现 ResponseBodyAdvice 接口,并将返回值抽象为统一的一个父类 NewBaseResponseVo。为了不影响其他的 Controller 类。注解上的 assignableTypes 属性指定为需要拦截 Controller 类。

我们可以从 beforeBodyWrite 方法名字上面看出,这是 SpringMVC 在将响应实体写入响应流前的动作,只能改变响应的内容,并不能改变响应报文的格式,且从类设计上面也能看出该方法的返回值是在用了 ResponseBodyAdvice 类级别泛型,当创建类时,已不可改变。

我们需要做的不仅仅是对响应内容进行改变,在响应格式上也需要进行 Base64 等操作,所以在这里单单使用该注解是并不能满足我们的需求的。

那我们如果 结合 AOP 再对定义好的 Advice 进行拦截从而改变响应格式呢?理论上讲这么做是没问题,但是这么实现过于 “野蛮”,相当于是在 AOP 的基础上再进行了一次 AOP,在这里完全不推荐这么做

那还有其他的方式实现么?继续往下看。

四、使用 Filter 拦截

在一开始学 Severlet 时,我们就学过 Filter 技术,这个 不属于 SpringMVC 范畴,但是确实是能达到我们的目的。如下:

public class NewOpenApiFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
    throws IOException, ServletException {
        // 将 response 进行包装
        ServletResponseWrapper custResponse = new ServletResponseWrapper((HttpServletResponse) response);
        chain.doFilter(request, custResponse);
        // 拿到 Controller 层往实际
        String result = custResponse.getResult();

        // 自定义签名、加密逻辑
        
        // 对外输出
        response.getWriter().write(result);
    }
}

public class ServletResponseWrapper extends HttpServletResponseWrapper {
    private PrintWriter cachedWriter;
    private CharArrayWriter bufferedWriter;

    public ServletResponseWrapper(HttpServletResponse response) {
        super(response);
        bufferedWriter = new CharArrayWriter();
        cachedWriter = new PrintWriter(bufferedWriter);
    }

    @Override
    public PrintWriter getWriter() {
        return cachedWriter;
    }

    public String getResult() {
        return new String(bufferedWriter.toCharArray());
    }
}

这里需要注意几个点:

  • ServletResponseWrapper 是自定义的包装类,为的是能在 不清空对外输出的 buffer 情况下 获取到 Controller 的输出内容
  • filter 属于 Severlet 容器的范畴,而不是 SpringMVC,所以那些 Spring 注解(@Autowired)在这里无效
  • 示例省略了将自定义 filter 注册进 Web 容器的动作,要用自己补上

如果采用这个方案,那 Controller 响应也要改

public class NewOpenApiController {
    @Autowired
    private NewService newService;

    @PostMapping(value = {"/var/{action}"})
    @ResponseBody
    public void webapi(@PathVariable("action") String action, HttpServletResponse response) {
        return doWrite(service.progress(), response);
    }

    private void doWrite(NewBaseResponseVo responseVo, HttpServletResponse response) {
        // 写入自定义 buffer
        response.getWriter().write(JSON.toJSONString(responseVo));
    }
}

这时候,Controller 方法就没有返回值了,统一将业务层响应格式化成 JSON 后,输出到自定义 buffer 中。接着 filter 就能拿到 buffer 进一步处理。

这个方式还是没有达到我们的要求,那还有吗?当然是有的,接着我们再往深一些,来看一下 SpringMVC 的源码。

五、使用 HandlerMethodReturnValueHandler 实现 + 部分源码剖析

我们在 Controller 方法上打个断点,能得到如下调用栈

image.png

DispatcherServlet#doDispatch 开始最终会到 RequestMappingHandlerAdapter#invokeHandlerMethod 开始进行实际方法调用。仔细来看下 invokeHandlerMethod 方法。

protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
                HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
    ServletWebRequest webRequest = new ServletWebRequest(request, response);
    try {
        WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
        ModelFactory modelFactory = getModelFactory(handlerMethod, binderFactory);
        
        ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
        if (this.argumentResolvers != null) {
            invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers); // 1
        }
        if (this.returnValueHandlers != null) {
            invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers); // 2
        }
        
        // 省略...

        invocableMethod.invokeAndHandle(webRequest, mavContainer); // 3
        if (asyncManager.isConcurrentHandlingStarted()) {
                return null;
        }
        return getModelAndView(mavContainer, modelFactory, webRequest);
    }
    finally {
            webRequest.requestCompleted();
    }
}

无关的部分已经省略。动作 1 设置了参数解析器,动作 2 设置了返回值处理器,动作 3 进行实际 Controller 层方法调用。我们再点进 invokeAndHandle 方法看下:

image.png

进入 handleReturnValue 方法

@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
                ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
    HandlerMethodReturnValueHandler handler = selectHandler(returnValue, returnType);
    if (handler == null) {
        throw new IllegalArgumentException("Unknown return value type: " + returnType.getParameterType().getName());
    }
    handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}

代码很清晰,首先选出合适的 HandlerMethodReturnValueHandler 实例,然后调用实例的 handleReturnValue 方法处理 Controller 层的返回值。

到这里我们是不是可以知道,实现 HandlerMethodReturnValueHandler 接口创建自定义返回值处理器,并把这个处理器注册给 SpringMVC 容器。就可以实现自定义返回。开始尝试,首先我们创建自己的处理类

@Component
public class NewOpenApiReturnValueHandler implements HandlerMethodReturnValueHandler {

    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return false;
    }

    @Override
    public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest) throws Exception {

    }
}

要实现两个方法。先看 supportsReturnType 方法,回想上文的 selectHandler 方法。是的!这个方法就是用于挑选合适的 HandlerMethodReturnValueHandler。你需要根据传入参数来决定你当前所支持的情况。

对应到现在的情况,我们和其他的 Controller 能区别的点是返回值,我们统一了 NewBaseResponseVo 返回值,所以当方法返回值是 NewBaseResponseVo 类时是我们能支持的情况,那这个方法里面怎么才能拿到呢?暂时不知道。放一边,看看另一个 handleReturnValue 方法。

returnValue 一看就是我们 Controller 的返回值,强转一下就能拿到响应实例,这个简单。接着进行签名、加密等操作,也简单。接着下一步就是输出了,这里怎么 没有我们熟悉的 ServletResponse,拿不到输出流,怎么办?

为了解决这两个问题,只能调试了。先注册!注册方法也非常方便,SpringMVC 提供了对外的扩展点,很容易把自定义处理实例加入处理器列表

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private NewOpenApiReturnValueHandler returnValueHandler;
    
    @Override
    public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
        handlers.add(returnValueHandler);
    }
}

我们兴奋地在自定义的返回值处理器中打上断点,却发现断点怎么都进不来,怎么回事呢?问题就出在 HandlerMethodReturnValueHandlerComposite#selectHandler 方法中,我们看一下这个源码

@Nullable
private HandlerMethodReturnValueHandler selectHandler(@Nullable Object value, MethodParameter returnType) {
    boolean isAsyncValue = isAsyncReturnValue(value, returnType);
    for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers) {
        if (isAsyncValue && !(handler instanceof AsyncHandlerMethodReturnValueHandler)) {
                continue;
        }
        if (handler.supportsReturnType(returnType)) {
                return handler;
        }
    }
    return null;
}

原来所有的处理器都放在一个列表里,使用 for 循环,匹配到能支持的第一个就立即返回。而我们在 handlers.add 时,由于是列表肯定是 往后追加,排在默认的处理器最后,由于先匹配到了前边的,后边的便不会再执行。所以我们需要 改写注册方法,调整处理器位置,将自定义的处理器放在第一位

@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
    if (handlers instanceof ArrayList) {
        handlers.add(0, returnValueHandler);
    }
    else {
        throw new RuntimeException("类型不是 ArrayList 无法初始化返回值处理器");
    }
}

再次调试发现 还没有进入断点。怀疑我们自定义的实例是不是没有加入到处理器列表,调试 selectHandler 方法 image.png 自定义处理器存在列表里,但不是排在最前面。为什么会这样子?注册时明明是放在了第一位。继续在注册方法上面打断点 image.png 发现 Spring 对外的提供的注册口,只是将处理器加入到 customReturnValueHandlers 列表里,而实际上用的是一个叫 returnValueHandlers 的列表。且在 RequestMappingHandlerAdapter#getDefaultReturnValueHandlers 方法里,**优先往 returnValueHandlers 里面放默认的处理器,再放 customReturnValueHandlers,**所以有了明明调整成了第一位实际却不是的效果。代码如下:

private List<HandlerMethodReturnValueHandler> getDefaultReturnValueHandlers() {
    List<HandlerMethodReturnValueHandler> handlers = new ArrayList<>();
    // 优先放默认处理器
    handlers.add(new ModelAndViewMethodReturnValueHandler());
    // 省略
    // 再放自定义处理器
    if (getCustomReturnValueHandlers() != null) {
            handlers.addAll(getCustomReturnValueHandlers());
    }
    // 省略
    return handlers;
}

查看了 WebMvcConfigurer 接口发现 Spring 并没有提供操作 returnValueHandlers 的扩展点。既然没办法调整顺序,那到底怎样才能进入到我们自己的处理器中呢?

其实很简单,把 Controller 方法上面的 @ResponseBody 注解去掉即可。这个注解 一部分作用就是在声明需要当前方法需要用 RequestResponseBodyMethodProcessor 来处理,观察其 supportsReturnType 便可知。

收!终于能回到我们需要解决的问题上了。先查看 supportsReturnType 方法的参数 MethodParameter 到底是个什么东西,调试 image.png

根据 getParameterType() 能拿到响应实例类型,那我们只需要判断 NewBaseResponseVo 是不是相同类型,或是否是其超类或超接口即可,如下

NewBaseResponseVo.class.isAssignableFrom(returnType.getParameterType());

接着怎么解决没有 ServletResponse,拿不到输出流问题。还记得上文提到过的 RequestResponseBodyMethodProcessor 类吗?其实我们实现的功能应该跟它是大致类似的,所以我们只要拷贝其代码即可

protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
    HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
    Assert.state(response != null, "No HttpServletResponse");
    return new ServletServerHttpResponse(response);
}

拿到了输出流,第二个问题也随之解决了。

那么你可能会想我们自定义的处理器只是在 RequestResponseBodyMethodProcessor 上增加了一些功能 为什么不继承这个类?这个问题你尝试过就会发现,此类 构造函数必须存在 converters 参数,且不存在无参构造函数,我们也拿不到它所需要的参数 converters,所以无法实现,只能退而求其次实现接口 HandlerMethodReturnValueHandler image.png

两个问题都解决了,来看下 自定义处理器最终的实现效果

@Component
public class NewOpenApiReturnValueHandler implements HandlerMethodReturnValueHandler {

    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return NewBaseResponseVo.class.isAssignableFrom(returnType.getParameterType());
    }

    @Override
    public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest) throws Exception {
        // 强转
        NewBaseResponseVo responseVo = (NewBaseResponseVo) returnValue;

        // 进行签名、加密

        ServletServerHttpResponse serverHttpResponse = this.createOutputMessage(webRequest);
        serverHttpResponse.getServletResponse().getWriter().write("签名、加密后的内容");
    }

    private ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
        HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
        Assert.state(response != null, "No HttpServletResponse");
        return new ServletServerHttpResponse(response);
    }
}

而且,我们的 Controller 只需要把 @ResponseBody 去掉,并不需要再 format 或者调用其他的方法。至此,相对起来说,这种算是比较优雅的一种方式了。不过还有更好的。最后一种,接着往下看。

思考!既然实现了自定义的返回值处理器,那我们是不是可以自定义实现一个类似 @ResponseBody 的注解,当打上这个注解时,使用我们自己的处理器?

六、使用 HttpMessageConverter 实现

HandlerMethodReturnValueHandler 里边更深入一点,Spring 还提供了一种实现自定义输出的扩展点,就是 HttpMessageConverter,我们来看下源码。

还是来看看 RequestResponseBodyMethodProcessor#handleReturnValue 方法,点进去发现调用了父类的 AbstractMessageConverterMethodProcessor#writeWithMessageConverters 方法。跳过不关心的方法,往下可以看到: image.png

动作 1、2 是不是似曾相识,与上文在挑选 HandlerMethodReturnValueHandler 并判断是否支持当前输出的动作简直一模一样。只不过对象不一样,这里是挑选一个合适的 HttpMessageConverter。(PS:是不是有中举一反三的感觉,明白一点,另一点竟然也会了)

有了经验后,所以我们只需 把处理思路照搬:是不是只要定义自己的 HttpMessageConverter,并注册进这个 this.messageConverters 就可以达到自定义输出的效果。接着来

@Component
public class NewOpenApiMessageConverter extends AbstractHttpMessageConverter<NewBaseResponseVo> {
    @Override
    public List<MediaType> getSupportedMediaTypes() {
        List<MediaType> temp = new ArrayList<>();
        temp.add(MediaType.TEXT_PLAIN);
        return temp;
    }
    
    @Override
    protected boolean supports(Class<?> clazz) {
        return NewBaseResponseVo.class.isAssignableFrom(clazz);
    }

    /**
     * 不会有将 WebapiBaseResponseVo 类型作为参数的情况
     * 所以暂时先直接返回为 null
     */
    @Override
    protected NewBaseResponseVo readInternal(Class<? extends NewBaseResponseVo> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {
        return null;
    }


    @Override
    protected void writeInternal( responseVo, HttpOutputMessage outputMessage)
            throws IOException, HttpMessageNotWritableException {
        Writer writer = new OutputStreamWriter(outputMessage.getBody(), StandardCharsets.UTF_8);
        try {
            writer.write(this.signAndSerialize(responseVo));
        }
        catch (Exception ex) {
            throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex);
        }
        writer.flush();
    }

    private String signAndSerialize(NewBaseResponseVo responseVo) {
        // 签名、加密逻辑
        return "";
    }
}

这里我们继承了 AbstractHttpMessageConverter 类,复写了几个方法。需要注意的是 supports 方法是对输入、输出都有效。

getSupportedMediaTypes 方法返回当前 Converter 类支持的 Content-Type 类型,这里我们选择支持文本明文。接着来看其他的方法:

假设你在 Controller 中有个方法,使用 NewBaseResponseVo 类型来接收参数,会走到 readInternal 逻辑中,同理 Controller 方法返回 NewBaseResponseVo 类型则会走到 writeInternal 方法中。因为此时我们不会将该类型作为输入参数使用所以直接将 read 方法返回为 null。再来看看注册

// 建议用
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    if (converters instanceof ArrayList) {
        // 还是老样子,需要放到第一位,不然会被默认转换器优先返回
        converters.add(0, newOpenApiMessageConverter);
    }
    else {
        throw new RuntimeException("类型不是 ArrayList 无法初始化消息转换器");
    }
}

// 不建议用
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {

}

注意,WebMvcConfigurer 提供了两个扩展方法。就如命名字面意思,extendMessageConverters 用于扩展,configureMessageConverters 用于配置。区别在于 extendMessageConverters 操作不会影响默认转换器的注册,而 configureMessageConverters 一旦 add 了新的 Converter 默认转换器注册就会被关闭(PS:可以试下删除一个 Converter 会怎样)!慎重使用

Note that adding converters to the list, turns off default converter registration. To simply add a converter without impacting default registration, consider using the method

再看看我们的 Controller,由于我们没有改 HandlerMethodReturnValueHandler 所以并不需要把 @ResponseBody 注解去掉,还是按照正常开发即可。

public class NewOpenApiController {
    @Autowired
    private NewService newService;
    
    @PostMapping(value = {"/var/{action}"}, produces = MediaType.TEXT_PLAIN_VALUE)
    @ResponseBody
    public NewBaseResponseVo webapi(@PathVariable("action") String action, HttpServletResponse response) {
        return newService.progress();
    }
}

非常的干净和优雅。当新增方法或者新增 Controller 实例时,只要返回值是 NewBaseResponseVo 就一定能走到我们自定义的 MessageConverter 中。此场景下 非常建议用这种方式

至此!6 种方式全部介绍完毕!

参考

Web on Servlet Stack (spring.io)