Spring MVC 下载后报 getOutputStream,检查 JSP 是否继续写响应
二进制下载后继续渲染JSP会与Writer冲突;由单一路径处理响应,并检查已提交半文件与原始读取异常。
文件已经下载,日志却报 getOutputStream() has already been called for this response,栈中还有 JspWriterImpl、PageContextImpl.release 或 JSP 视图渲染。先查同一个响应是否既写了二进制数据,又继续渲染页面。
Servlet 的 getOutputStream() 用于二进制输出,getWriter() 用于字符输出。同一响应不能直接同时使用两者。把下载代码放在 JSP 中,即使页面看起来没有其他文字,JSP 的输出和清理过程也可能继续取得 Writer,与已经使用的 OutputStream 冲突。
让下载入口直接处理响应
下载不需要先返回一个 JSP,再由 JSP 打开文件。可以把文件读取和二进制输出放回控制器,由一个路径负责最终响应;原先返回下载视图的代码也要一并调整。
在 Spring MVC 中,void 返回类型配合 HttpServletResponse 参数,表示方法已经处理响应。下面只是方法签名,具体鉴权、文件选择和输出仍由项目实现:
public void download(HttpServletResponse response) throws IOException
Spring Boot 3 使用 jakarta.servlet.http.HttpServletResponse,Boot 2 的常用版本使用 javax.servlet.http.HttpServletResponse;IOException 是 java.io.IOException。保留原接口的映射与参数,不要只复制签名后期待它自动提供下载。
仅把方法改成 void,却不满足已经处理响应的条件,在 HTML 控制器中仍可能触发默认视图选择。也不要写完文件再返回 String 视图名或 ModelAndView。
另一种方式是返回 ResponseEntity<Resource>,让消息转换器统一写出正文。采用这种方式时,控制器不要再手工取得响应流写同一份文件。两种方式选择一条,确认原拦截器、异常处理器和过滤器没有在下载结束后追加页面或 JSON。
文件选择和响应头也要一起核对
从业务文件标识找到用户有权访问的资源,不要把请求参数直接当任意服务器文件路径。保持原来的身份验证、授权和允许目录约束,避免移动下载代码时绕过已有保护。
在输出正文之前设置类型、文件名和已知长度。文件名需要正确编码和处理控制字符,可采用框架的 ContentDisposition 构建方式;不要直接拼接未校验的请求值。长度超过 int 范围时应使用支持 long 的接口,不能强制截断。
读取端的文件流需要可靠关闭。文件很大时避免先整体加载进内存;是否使用异步下载,则还涉及执行器、超时和资源生命周期,应按实际实现另行核对。
已提交的半个响应不能换成错误页面
还要看栈中最早的异常。读取文件时出现 IOException,随后错误处理尝试写 HTML,可能让 Writer 冲突盖住了真正的文件读取失败。这与一开始就在 JSP 内下载的路径不同。
response.isCommitted() 为真时,响应状态和头已经发出。此时不能靠改状态、reset() 或写一段 JSON,把已经发送的二进制正文变成完整错误响应。应记录原始失败,按容器和项目的错误处理约定结束输出,客户端也要能识别不完整下载。
在尚未提交时,reset() 可以清除状态、响应头、缓冲及 Writer/OutputStream 的选择状态,但旧流或 Writer 不能继续使用。resetBuffer() 只清缓冲内容,不会提供同样的完整重置。不要把重置当成正常下载和视图渲染混用的固定步骤。
修改后比较下载文件的长度或受控校验值,确认类型和文件名正确,日志没有第二次页面输出。同时覆盖无权限、文件不存在、读取失败和客户端中断。异常消失并不证明文件完整;保存原控制器与视图配置,回退时一起恢复对应入口,避免下载逻辑在两处重复执行。


