文件上传异常之The temporary upload location xxx is not valid的解决方案

前言:工作中遇到文件上传接口上传后提示异常的问题,但之前是正常的呢。网上扒了一翻,找到一个比较详见的解决案例,特此转载记录一下。

Failed to parse multipart servlet request; nested exception is java.io.IOException: The temporary upload location [/tmp/tomcat.3085574908520253229.8088/work/Tomcat/localhost/ROOT] is not valid

 以下是详细案例,本文是转载,请支持原创。

————————————————————————————————————————————————————————

SpringBoot搭建的应用,一直工作得好好的,突然发现上传文件失败,提示org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.io.IOException: The temporary upload location [/tmp/tomcat.6239989728636105816.19530/work/Tomcat/localhost/ROOT] is not valid目录非法,实际查看目录,结果还真没有,下面就这个问题的表现,分析下SpringBoot针对文件上传的处理过程

I. 问题分析

0. 堆栈分析

问题定位,最佳的辅助手段就是堆栈分析,首先捞出核心的堆栈信息

 

 
  1. org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.io.IOException: The temporary upload location [/tmp/tomcat.6239989728636105816.19530/work/Tomcat/localhost/ROOT] is not valid

  2. at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.handleParseFailure(StandardMultipartHttpServletRequest.java:122)

  3. at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.parseRequest(StandardMultipartHttpServletRequest.java:113)

  4. at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.<init>(StandardMultipartHttpServletRequest.java:86)

  5. at org.springframework.web.multipart.support.StandardServletMultipartResolver.resolveMultipart(StandardServletMultipartResolver.java:93)

  6. at org.springframework.web.servlet.DispatcherServlet.checkMultipart(DispatcherServlet.java:1128)

  7. at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:960)

  8. at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:925)

  9. at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:974)

  10. at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:877)

  11. at javax.servlet.http.HttpServlet.service(HttpServlet.java:661)

  12. at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:851)

  13. at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)

  14. at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)

  15. at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)

  16. at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)

  17. at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)

从堆栈内容来看,问题比较清晰,目录非法,根据path路径,进入目录,结果发现,没有这个目录,那么问题的关键就是没有目录为什么会导致异常了,这个目录到底有啥用

先简单描述下上面的原因,上传的文件会缓存到本地磁盘,而缓存的路径就是上面的/tmp/tomcat.6239989728636105816.19530/work/Tomcat/localhost/ROOT,接着引入的疑问就是:

  • 为什么上传的文件要缓存到本地
  • 为什么临时目录会不存在
  • 什么地方实现文件缓存

1. 场景模拟

要确认上面的问题,最直观的方法就是撸源码,直接看代码就有点蛋疼了,接下来采用debug方式来层层剥离,看下根源再哪里。

首先是搭建一个简单的测试项目,进行场景复现, 首先创建一个接收文件上传的Controller,如下

 

 
  1. @RestController

  2. @RequestMapping(path = "/file")

  3. public class FileUploadRest {

  4.  
  5. /**

  6. * 保存上传的文件

  7. *

  8. * @param file

  9. * @return

  10. */

  11. private String saveFileToLocal(MultipartFile file) {

  12. try {

  13. String name = "/tmp/out_" + System.currentTimeMillis() + file.getName();

  14. FileOutputStream writer = new FileOutputStream(new File(name));

  15. writer.write(file.getBytes());

  16. writer.flush();

  17. writer.close();

  18. return name;

  19. } catch (Exception e) {

  20. e.printStackTrace();

  21. return e.getMessage();

  22. }

  23. }

  24.  
  25. @PostMapping(path = "upload")

  26. public String upload(@RequestParam("file") MultipartFile file) {

  27. String ans = saveFileToLocal(file);

  28. return ans;

  29. }

  30. }

其次就是使用curl来上传文件

 

curl http://127.0.0.1:8080/file/upload -F "file=@/Users/user/Desktop/demo.jpg" -v

然后在接收文件上传的方法中开启断点,注意下面红框中的 location, 就是文件上传的临时目录

IMAGE

2. 源码定位

上面的截图可以确认确实将上传的文件保存到了临时目录,验证方式就是进入那个目录进行查看,会看到一个tmp文件,接下来我们需要确定的是在什么地方,实现将数据缓存到本地的。

注意下图,左边红框是这次请求的完整链路,我们可以通过逆推链路,去定位可能实现文件缓存的地方

IMAGE

如果对spring和tomcat的源码不熟的话,也没什么特别的好办法,从上面的链路中,多打一些断点,采用传说中的二分定位方法来缩小范围。

通过最开始的request对象和后面的request对象分析,发现一个可以作为参考标准的就是上图中右边红框的request#parts属性;开始是null,文件保存之后则会有数据,下面给一个最终定位的动图

2.gif

所以关键就是org.springframework.web.filter.HiddenHttpMethodFilter#doFilterInternal 中的 String paramValue = request.getParameter(this.methodParam); 这一行代码

IMAGE

到这里在单步进去,主要的焦点将集中在 org.apache.catalina.connector.Request#parseParts

IMAGE

进入上面方法的逻辑,很容易找到具体的实现位置 org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest,这个方法的实现比较有意思,有必要贴出来看一下

 

 
  1. public List<FileItem> parseRequest(RequestContext ctx)

  2. throws FileUploadException {

  3. List<FileItem> items = new ArrayList<>();

  4. boolean successful = false;

  5. try {

  6. FileItemIterator iter = getItemIterator(ctx);

  7. // 注意这里,文件工厂类,里面保存了临时目录的地址

  8. // 这个对象首次是在 org.apache.catalina.connector.Request#parseParts 方法的

  9. FileItemFactory fac = getFileItemFactory();

  10. if (fac == null) {

  11. throw new NullPointerException("No FileItemFactory has been set.");

  12. }

  13. while (iter.hasNext()) {

  14. final FileItemStream item = iter.next();

  15. // Don't use getName() here to prevent an InvalidFileNameException.

  16. final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name;

  17. // 创建一个临时文件对象

  18. FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(),

  19. item.isFormField(), fileName);

  20. items.add(fileItem);

  21. try {

  22. // 流的拷贝,这块代码也挺有意思,将输入流数据写入输出流

  23. // 后面会贴出源码,看下开源大佬们的玩法,和我们自己写的有啥区别

  24. Streams.copy(item.openStream(), fileItem.getOutputStream(), true);

  25. } catch (FileUploadIOException e) {

  26. throw (FileUploadException) e.getCause();

  27. } catch (IOException e) {

  28. throw new IOFileUploadException(String.format("Processing of %s request failed. %s",

  29. MULTIPART_FORM_DATA, e.getMessage()), e);

  30. }

  31. final FileItemHeaders fih = item.getHeaders();

  32. fileItem.setHeaders(fih);

  33. }

  34. successful = true;

  35. return items;

  36. } catch (FileUploadIOException e) {

  37. throw (FileUploadException) e.getCause();

  38. } catch (IOException e) {

  39. throw new FileUploadException(e.getMessage(), e);

  40. } finally {

  41. if (!successful) {

  42. for (FileItem fileItem : items) {

  43. try {

  44. fileItem.delete();

  45. } catch (Exception ignored) {

  46. // ignored TODO perhaps add to tracker delete failure list somehow?

  47. }

  48. }

  49. }

  50. }

  51. }

核心代码就两点,一个是文件工厂类,一个是流的拷贝;前者定义了我们的临时文件目录,也是我们解决前面问题的关键,换一个我自定义的目录永不删除,不就可以避免上面的问题了么;后面一个则是数据复用方面的

首先看下FileItemFactory的实例化位置,在org.apache.catalina.connector.Request#parseParts中,代码如下

IMAGE

具体的location实例化代码为

 

 
  1. // TEMPDIR = "javax.servlet.context.tempdir";

  2. location = ((File) context.getServletContext().getAttribute(ServletContext.TEMPDIR));

3. 问题review

a. 解决问题

到上面,基本上就捞到了最终的问题,先看如何解决这个问题

方法1

  • 应用重启

方法2

  • 增加服务配置,自定义baseDir

 

server.tomcat.basedir=/tmp/tomcat

方法3

  • 注入bean,手动配置临时目录

 

 
  1. @Bean

  2. MultipartConfigElement multipartConfigElement() {

  3. MultipartConfigFactory factory = new MultipartConfigFactory();

  4. factory.setLocation("/tmp/tomcat");

  5. return factory.createMultipartConfig();

  6. }

方法4

  • 配置不删除tmp目录下的tomcat

 

 
  1. vim /usr/lib/tmpfiles.d/tmp.conf

  2.  
  3. # 添加一行

  4. x /tmp/tomcat.*

b. 流拷贝

tomcat中实现流的拷贝代码如下,org.apache.tomcat.util.http.fileupload.util.Streams#copy(java.io.InputStream, java.io.OutputStream, boolean, byte[]) , 看下面的实现,直观影响就是写得真特么严谨

 

 
  1. public static long copy(InputStream inputStream,

  2. OutputStream outputStream, boolean closeOutputStream,

  3. byte[] buffer)

  4. throws IOException {

  5. OutputStream out = outputStream;

  6. InputStream in = inputStream;

  7. try {

  8. long total = 0;

  9. for (;;) {

  10. int res = in.read(buffer);

  11. if (res == -1) {

  12. break;

  13. }

  14. if (res > 0) {

  15. total += res;

  16. if (out != null) {

  17. out.write(buffer, 0, res);

  18. }

  19. }

  20. }

  21. if (out != null) {

  22. if (closeOutputStream) {

  23. out.close();

  24. } else {

  25. out.flush();

  26. }

  27. out = null;

  28. }

  29. in.close();

  30. in = null;

  31. return total;

  32. } finally {

  33. IOUtils.closeQuietly(in);

  34. if (closeOutputStream) {

  35. IOUtils.closeQuietly(out);

  36. }

  37. }

  38. }

c. 自问自答

前面提出了几个问题,现在给一个简单的回答,因为篇幅问题,后面会单开一文,进行详细说明

什么地方缓存文件

上面的定位过程给出答案,具体实现逻辑在 org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest

为什么目录会不存在

springboot启动时会创建一个/tmp/tomcat.*/work/Tomcat/localhost/ROOT的临时目录作为文件上传的临时目录,但是该目录会在n天之后被系统自动清理掉,这个清理是由linux操作系统完成的,具体的配置如下 vim /usr/lib/tmpfiles.d/tmp.conf

 

 
  1. # This file is part of systemd.

  2. #

  3. # systemd is free software; you can redistribute it and/or modify it

  4. # under the terms of the GNU Lesser General Public License as published by

  5. # the Free Software Foundation; either version 2.1 of the License, or

  6. # (at your option) any later version.

  7.  
  8. # See tmpfiles.d(5) for details

  9.  
  10. # Clear tmp directories separately, to make them easier to override

  11. v /tmp 1777 root root 10d

  12. v /var/tmp 1777 root root 30d

  13.  
  14. # Exclude namespace mountpoints created with PrivateTmp=yes

  15. x /tmp/systemd-private-%b-*

  16. X /tmp/systemd-private-%b-*/tmp

  17. x /var/tmp/systemd-private-%b-*

  18. X /var/tmp/systemd-private-%b-*/tmp

为什么要缓存文件

因为流取一次消费之后,后面无法再从流中获取数据,所以缓存方便后续复用;这一块后面详细说明

4. 小结

定位这个问题的感觉,就是对SpringBoot和tomcat的底层,实在是不太熟悉,作为一个以Spring和tomcat吃饭的码农而言,发现问题就需要改正,列入todo列表,后续需要深入一下

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值