SpringBoot文件上传异常之temporary upload location not valid

news/2024/7/1 21:11:15

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
       at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.handleParseFailure(StandardMultipartHttpServletRequest.java:122)
       at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.parseRequest(StandardMultipartHttpServletRequest.java:113)
       at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.<init>(StandardMultipartHttpServletRequest.java:86)
       at org.springframework.web.multipart.support.StandardServletMultipartResolver.resolveMultipart(StandardServletMultipartResolver.java:93)
       at org.springframework.web.servlet.DispatcherServlet.checkMultipart(DispatcherServlet.java:1128)
       at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:960)
       at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:925)
       at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:974)
       at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:877)
       at javax.servlet.http.HttpServlet.service(HttpServlet.java:661)
       at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:851)
       at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)
       at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
       at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
       at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
       at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)

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

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

  • 为什么上传的文件要缓存到本地

  • 为什么临时目录会不存在

  • 什么地方实现文件缓存

1. 场景模拟

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping(path = "/file")
public class FileUploadRest {

   /**
    * 保存上传的文件
    *
    * @param file
    * @return
    */
   private String saveFileToLocal(MultipartFile file) {
       try {
           String name = "/tmp/out_" + System.currentTimeMillis() + file.getName();
           FileOutputStream writer = new FileOutputStream(new File(name));
           writer.write(file.getBytes());
           writer.flush();
           writer.close();
           return name;
       } catch (Exception e) {
           e.printStackTrace();
           return e.getMessage();
       }
   }

   @PostMapping(path = "upload")
   public String upload(@RequestParam("file") MultipartFile file) {
       String ans = saveFileToLocal(file);
       return ans;
   }
}

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

1
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public List<FileItem> parseRequest(RequestContext ctx)
       throws FileUploadException {
   List<FileItem> items = new ArrayList<>();
   boolean successful = false;
   try {
       FileItemIterator iter = getItemIterator(ctx);
       // 注意这里,文件工厂类,里面保存了临时目录的地址
       // 这个对象首次是在 org.apache.catalina.connector.Request#parseParts 方法的
       FileItemFactory fac = getFileItemFactory();
       if (fac == null) {
           throw new NullPointerException("No FileItemFactory has been set.");
       }
       while (iter.hasNext()) {
           final FileItemStream item = iter.next();
           // Don't use getName() here to prevent an InvalidFileNameException.
           final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name;
           // 创建一个临时文件对象
           FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(),
                                              item.isFormField(), fileName);
           items.add(fileItem);
           try {
               // 流的拷贝,这块代码也挺有意思,将输入流数据写入输出流
               // 后面会贴出源码,看下开源大佬们的玩法,和我们自己写的有啥区别
               Streams.copy(item.openStream(), fileItem.getOutputStream(), true);
           } catch (FileUploadIOException e) {
               throw (FileUploadException) e.getCause();
           } catch (IOException e) {
               throw new IOFileUploadException(String.format("Processing of %s request failed. %s",
                                                      MULTIPART_FORM_DATA, e.getMessage()), e);
           }
           final FileItemHeaders fih = item.getHeaders();
           fileItem.setHeaders(fih);
       }
       successful = true;
       return items;
   } catch (FileUploadIOException e) {
       throw (FileUploadException) e.getCause();
   } catch (IOException e) {
       throw new FileUploadException(e.getMessage(), e);
   } finally {
       if (!successful) {
           for (FileItem fileItem : items) {
               try {
                   fileItem.delete();
               } catch (Exception ignored) {
                   // ignored TODO perhaps add to tracker delete failure list somehow?
               }
           }
       }
   }
}

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

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

具体的location实例化代码为

1
2
// TEMPDIR = "javax.servlet.context.tempdir";
location = ((File) context.getServletContext().getAttribute(ServletContext.TEMPDIR));

3. 问题review

a. 解决问题

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

方法1

  • 应用重启

方法2

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

1
server.tomcat.basedir=/tmp/tomcat

方法3

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

1
2
3
4
5
6
@Bean
MultipartConfigElement multipartConfigElement() {
   MultipartConfigFactory factory = new MultipartConfigFactory();
   factory.setLocation("/tmp/tomcat");
   return factory.createMultipartConfig();
}

方法4

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

1
2
3
4
vim /usr/lib/tmpfiles.d/tmp.conf

# 添加一行
x /tmp/tomcat.*

b. 流拷贝

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public static long copy(InputStream inputStream,
           OutputStream outputStream, boolean closeOutputStream,
           byte[] buffer)
   throws IOException {
   OutputStream out = outputStream;
   InputStream in = inputStream;
   try {
       long total = 0;
       for (;;) {
           int res = in.read(buffer);
           if (res == -1) {
               break;
           }
           if (res > 0) {
               total += res;
               if (out != null) {
                   out.write(buffer, 0, res);
               }
           }
       }
       if (out != null) {
           if (closeOutputStream) {
               out.close();
           } else {
               out.flush();
           }
           out = null;
       }
       in.close();
       in = null;
       return total;
   } finally {
       IOUtils.closeQuietly(in);
       if (closeOutputStream) {
           IOUtils.closeQuietly(out);
       }
   }
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#  This file is part of systemd.
#
#  systemd is free software; you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 2.1 of the License, or
#  (at your option) any later version.

# See tmpfiles.d(5) for details

# Clear tmp directories separately, to make them easier to override
v /tmp 1777 root root 10d
v /var/tmp 1777 root root 30d

# Exclude namespace mountpoints created with PrivateTmp=yes
x /tmp/systemd-private-%b-*
X /tmp/systemd-private-%b-*/tmp
x /var/tmp/systemd-private-%b-*
X /var/tmp/systemd-private-%b-*/tmp

为什么要缓存文件

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

4. 小结

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

II. 其他

0. 项目

  • 工程:https://github.com/liuyueyi/spring-boot-demo

1. 一灰灰Blog

  • 一灰灰Blog个人博客 https://blog.hhui.top

  • 一灰灰Blog-Spring专题博客 http://spring.hhui.top

一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

2. 声明

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

  • 微博地址: 小灰灰Blog

  • QQ: 一灰灰/3302797840

3. 扫描关注

一灰灰blog

知识星球


http://www.niftyadmin.cn/n/4231754.html

相关文章

200多个js技巧代码(四)

121.集中为按钮改变颜色<style>button{benc:expression(this.onfocus function(){this.style.backgroundColor#E5F0FF;})}</style><button>New</button>//122.判断是左键还是右键被按下<body οnmοusedοwnif(event.button1)alert("左键&quo…

SpringBoot高级篇MongoDB之如何新增文档

本篇博文为mongodb的curd中一篇&#xff0c;前面介绍简单的查询使用&#xff0c;这一篇重点则放在插入数据; I. 基本使用 首先是准备好基本环境&#xff0c;可以参考博文 181213-SpringBoot高级篇MongoDB之基本环境搭建与使用190113-SpringBoot高级篇MongoDB之查询基本使用姿…

应用的代码没有用新的类库来进行编译(转)

一个一直运行正常的应用突然无法运行了。在类库被更新之后&#xff0c;返回下面的错误。 Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V at com.nhn.service.UserService.add(UserService.jav…

【原创】SQLServer将数据导出为SQL脚本的方法

最近很多同学问到一个问题&#xff0c;如何将MSSQLServer的数据库以及里面的数据导出为SQL脚本&#xff0c;主要问的是MSSQLServer2000和2005&#xff0c;因为2008的管理器已经有了这个功能&#xff0c;2000和2005则没有。 上网查了一下&#xff0c;有用命令什么的&#xff0c;…

SpringBoot基础篇AOP之基本使用姿势小结

原文&#xff1a;190301-SpringBoot基础篇AOP之基本使用姿势小结 一般来讲&#xff0c;谈到Spring的特性&#xff0c;绕不过去的就是DI&#xff08;依赖注入&#xff09;和AOP&#xff08;切面&#xff09;&#xff0c;在将bean的系列中&#xff0c;说了DI的多种使用姿势&#…

传球游戏

描述 上体育课的时候&#xff0c;小蛮的老师经常带着同学们一起做游戏。这次&#xff0c;老师带着同学们一起做传球游戏。 游戏规则是这样的&#xff1a;n个同学站成一个圆圈&#xff0c;其中的一个同学手里拿着一个球&#xff0c;当老师吹哨子时开始传球&#xff0c;每个同学可…

Android性能优化典范第一季

2015年伊始&#xff0c;Google发布了关于Android性能优化典范的专题&#xff0c;一共16个短视频&#xff0c;每个3-5分钟&#xff0c;帮助开发者创建更快更优秀的Android App。课程专题不仅仅介绍了Android系统中有关性能问题的底层工作原理&#xff0c;同时也介绍了如何通过工…

Mysql DDL出现长时间等待MDL问题分析

给表新增字段时&#xff0c;发现锁表了&#xff0c;查看进程&#xff0c;提示Waiting for table metadata lock&#xff0c;等待锁释放&#xff1b;然而蛋疼的是几分钟过去了&#xff0c;依然没有任何的进展&#xff0c;特此记录下这个问题的定位过程以及MDL的相关背景知识 看…