背景介绍
我想用Interceptor的postHandle方法实现JWT的登录续期。主要逻辑是用户请求接口,服务端执行完逻辑后,对用户的JWT剩余时间进行判断,如果发现需要续期则将新的AccessToken和RefreshToken放到Response请求头中。这里我想使用Interceptor的postHandle,在服务端执行完Controller方法后进行拦截,并设置Response请求头,结果发现设置失败。
Bug介绍
将问题抽象为在Interceptor的postHandle方法中设置Response请求头失败,我编写了一个专门设置Response请求头的Interceptor。
@Slf4j
@Component
public class ResponseInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("pre set header");
response.setHeader("X-Custom-Header", "pre");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("post set header");
response.setHeader("X-Custom-Header", "post");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
log.info("afterCompletion set header");
response.setHeader("X-Custom-Header", "afterC");
}
}
逻辑比较简单,在pre、post和afterCompletion方法中都进行Response请求头的设置。
日志输出:

请求头结果:

代表postHandle和afterCompletion方法设置Response请求头都失败。
问题分析
首先需要明确为什么设置Response请求头失败。我在Interceptor中设置的Response请求头时,使用的方法是setHeader()
和addHeader()
方法,它们方法内部是:
public void setHeader(String name, String value) {
if (!this.isCommitted()) {
this.response.setHeader(name, value);
}
}
public void addHeader(String name, String value) {
if (!this.isCommitted()) {
this.response.addHeader(name, value);
}
}
可以发现,如果isCommitted()方法返回true,会导致addHeader和setHeader失效。所以设置Response请求头失败的原因是isCommitted()方法返回了true。
进一步追踪isCommitted()方法,最终发现是因为CoyoteResponse中的Committed属性被标记为了true。
public boolean isCommitted() {
return this.getCoyoteResponse().isCommitted();
}
CoyoteResponse是Tomcat服务器内部组件的一部分,主要用于表示HTTP响应。CoyoteResponse的Committed属性被标记为true代表的是该Response已经被提交,无法修改Header了。
那么是在哪一步提交CoyoteResponse的呢?
根据断点+日志分析,我发现在Interceptor的preHandle()方法执行完毕,Controller方法执行完成之后,在Interceptor的postHandle()方法执行之前提交的。由于afterCompletion是在postHandle方法之后,因此下文不再对其进行讨论。
断点跟踪入DispatcherServlet.class后,找到了关键步骤:
public class DispatcherServlet{
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// ...忽略无关代码
// 获取 HandlerAdapter
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
// 执行Interceptor的preHandle方法
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 在HandlerAdapter中执行handler,也就是Controller中的方法
// 这一步执行完之后Response被提交
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
// 执行Interceptor的postHandle方法
mappedHandler.applyPostHandle(processedRequest, response, mv);
// ...忽略无关代码
}
}
HandlerAdapter主要作用是适配各种类型的处理器(Handler),将HTTP请求传入的参数的数据格式转换为Handler所需的数据格式,并将Handler返回的数据格式转换为HTTP响应的格式。
这里创建了HandlerAdapter,并在其中执行Handler,也就是Controller中的逻辑。继续跟踪入ha.handle()方法,之后进入了很多中间过程类,各个过程类省略,最终是在OutputBuffer类中的doFlush方法中执行了CoyoteResponse类的sendHeader方法,这个方法使得Response被提交。
public void sendHeaders() {
this.action(ActionCode.COMMIT, this);
this.setCommitted(true);
}
其中OutputBuffer类是Tomcat中负责管理HTTP响应输出流的一个类。它在数据被写入响应流时,负责将数据缓冲并提交到客户端。而它的doFlush方法是将缓冲区中的数据写入输出流。这个步骤是在HandlerAdapter将Handler返回值转换为HTTP响应时。
综上所述,之所以在Interceptor的postHandle方法中设置Response请求头失效,是因为在HandlerAdapter把Handler返回值转换为HTTP响应时,将Response相关数据从缓冲区写入输出流了。为了确保数据被写入输出流后到发送给客户端之间过程的数据一致性和正确性,使用了一个“已提交(Committed)”状态来屏蔽后续的修改操作。
Bug修复
由于MVC设计逻辑如此,所以无法在postHandle修改Response请求头。最终的解决方法是在preHandle时就执行JWT续期机制,或者考虑通过AOP,在Controller执行方法后,Response提交前执行JWT续期机制。
更多思考
为什么要这么早的将Response相关数据从缓冲区写入输出流,而不是在后续postHandle、AfterCompletion和Filter后续操作之后再写入呢。
几个可能的原因:
- 尽早的将数据从缓冲区写入到输出流,可以减少内存占用,防止数据在内存中堆积。
- 虽然响应头已提交,但客户端只会在数据流完成后接收完整的响应。这种设计可以分阶段处理数据,减少等待时间。
- 提高容错,提交响应头后,如果在后续处理中发生错误,服务器可以根据已提交的部分响应内容来生成适当的错误响应,而不需要重新构建整个响应。
666