问题背景
后台生成文件并返回zip供前端下载,直接用响应流构造zip输出流,代码如下
@Override
public void downloadCode(HttpServletResponse response, List<Long> tableIds) {
boolean failure = false;
try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream())) {
response.setHeader("Content-Disposition", "attachment; filename=\"generation.zip\"");
response.setContentType("application/octet-stream; charset=UTF-8");
for (Long tableId : tableIds) {
generateCode(tableId, zip);
}
} catch (Exception e) {
log.error("代码生成出错", e);
failure = true;
} finally {
if (failure) {
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.sendError(500, "代码生成出错,无法下载");
} catch (IOException ex) {
log.error("写报错信息出错", ex);
}
}
}
}
private void generateCode(Long tableId, ZipOutputStream zip) {
// 查询表信息
GenTable table = tableMapper.queryTableAndColumns(tableId, null, null);
// 设置父表信息
setSubTable(table);
// 设置主键列信息
setPkColumn(table);
VelocityUtil.initVelocity();
VelocityContext context = VelocityUtil.prepareContext(table);
// 获取模板列表
List<String> templates = VelocityUtil.getTemplateList(table.getTplCategory());
for (String template : templates) {
try {
// 渲染模板
StringWriter sw = new StringWriter();
Template tpl = Velocity.getTemplate(template, CharSet.UTF_8);
tpl.merge(context, sw);
// 添加到zip
zip.putNextEntry(new ZipEntry(VelocityUtil.getFileName(template, table)));
zip.write(sw.toString().getBytes(StandardCharsets.UTF_8));
sw.close();
zip.flush();
zip.closeEntry();
} catch (IOException e) {
log.error("写zip失败,表名:" + table.getTableName(), e);
throw new UziException("代码生成失败!", 500);
} catch (RuntimeException e) {
log.error("渲染模板失败", e);
throw new UziException("代码生成失败!", 500);
}
}
}
调试时,忘记导入模板,导致代码报错,但是前端没有任何提示(已经做了全局统一异常响应处理)
原因分析
1、首先是找报错点(偏业务),获取不到模板
2、正常debug,发现也进到了捕获异常处理的地方(上面106行报错,被118行捕获到),但是前端此时已经收到响应,200状态,但是是空文件。那就奇怪了,我已经断点住了,为啥请求结束了
3、这个现象很奇怪,但也很明显,就是一旦抛出异常,输出流自动关闭,请求结束(即servlet生命周期结束),再结合servlet的输出流不需要手动关闭,这种原因的可能性很大。那么下面就是来证实我的猜测(结果猜测正确,过程放到问题解决部分)
4、既然是servlet出的问题,那么就来调试看下servlet的源码,看下整个请求过程
servlet是继承的GenericServlet,有个很重要的方法service ()(GenericServlet有两个生命周期函数init()和destroy()这里不展开说了,只是init()方法仅在服务器装载Servlet时才由服务器执行一次,而每次客户向服务器发请求时,服务器就会调用Service()方法),进到service方法后进行调试
调用FrameworkServlet的processRequest方法
调用DispatcherServlet的doDispatch方法
此时终于找到了我们的目标controller,继续走
进到我自己异常捕获逻辑(前端没有响应)
再进到外层的异常捕获逻辑,貌似好像没有问题,但是此时却发现前端响应已经返回了,仔细观察后发现,response.isCommitted()此时由false变成了true(后面在我们调用sendError时,因为判断出response.isCommitted()是true时,会抛出我们开始出现的异常)。那么重新来一遍,看看这个值是如何被设置成true的
重新请求后,在进入我的最外层的异常捕获之前,先进入了CoyoteOutputStream类,该类继承ServletOutputStream,重写了ServletOutputStream继承自OutputStream的close方法,最终调用了OutputBuffer的doFlush方法
最终response设置committed为true,到此为止,为什么报Cannot call sendError() after the response has been committed的原因找到了,那么为什么自动先响应了呢?继续
随后OutputBuffer关闭,并触发Response的action方法
最终调用了finishResponse方法,里面关闭了socket
后面再补充具体是怎么结束请求的,当前先搞清楚是怎么触发调用OutputStream的close方法的?待叙。。。
解决方案
问题很明显,就是servlet自动关闭流导致请求结束造成的,那么最简单的解决方案就是先准备好数据,最后再往响应流里面写,那么异常导致的请求关闭只可能是写时的IO异常,业务的异常就能正常提示到前端了,代码如下:
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(outputStream);
try {
response.setHeader("Content-Disposition", "attachment; filename=\"generation.zip\"");
response.setContentType("application/octet-stream; charset=UTF-8");
for (Long tableId : tableIds) {
generateCode(tableId, zip);
}
zip.close(); // 当不用响应流创建zip时,需要手动关闭zip流(响应流会自动关闭)
response.getOutputStream().write(outputStream.toByteArray());
} catch (Exception e) {
log.error("代码生成出错", e);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.sendError(500, "代码生成出错,无法下载");
} catch (IOException ex) {
log.error("响应报错信息出错", e);
}
} finally {
try {
outputStream.close();
} catch (IOException e) {
log.error("最终关闭流失败!", e);
}
}
如果有更好的解决方案,请多指教。