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. 堆疊分析
問題定位,最佳的輔助手段就是堆疊分析,首先撈出核心的堆疊資訊
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,如下
@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來上傳檔案
curl http://127.0.0.1:8080/file/upload -F "file=@/Users/user/Desktop/demo.jpg" -v
複製程式碼
然後在接收檔案上傳的方法中開啟斷點,注意下面紅框中的 location
, 就是檔案上傳的臨時目錄
2. 原始碼定位
上面的截圖可以確認確實將上傳的檔案儲存到了臨時目錄,驗證方式就是進入那個目錄進行檢視,會看到一個tmp檔案,接下來我們需要確定的是在什麼地方,實現將資料快取到本地的。
注意下圖,左邊紅框是這次請求的完整鏈路,我們可以通過逆推鏈路,去定位可能實現檔案快取的地方
如果對spring和tomcat的原始碼不熟的話,也沒什麼特別的好辦法,從上面的鏈路中,多打一些斷點,採用傳說中的二分定位方法來縮小範圍。
通過最開始的request物件和後面的request物件分析,發現一個可以作為參考標準的就是上圖中右邊紅框的request#parts
屬性;開始是null,檔案儲存之後則會有資料,下面給一個最終定位的動圖
所以關鍵就是org.springframework.web.filter.HiddenHttpMethodFilter#doFilterInternal
中的 String paramValue = request.getParameter(this.methodParam);
這一行程式碼
到這裡在單步進去,主要的焦點將集中在 org.apache.catalina.connector.Request#parseParts
進入上面方法的邏輯,很容易找到具體的實現位置 org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest
,這個方法的實現比較有意思,有必要貼出來看一下
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例項化程式碼為
// TEMPDIR = "javax.servlet.context.tempdir";
location = ((File) context.getServletContext().getAttribute(ServletContext.TEMPDIR));
複製程式碼
3. 問題review
a. 解決問題
到上面,基本上就撈到了最終的問題,先看如何解決這個問題
方法1
- 應用重啟
方法2
- 增加服務配置,自定義baseDir
server.tomcat.basedir=/tmp/tomcat
複製程式碼
方法3
- 注入bean,手動配置臨時目錄
@Bean
MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setLocation("/tmp/tomcat");
return factory.createMultipartConfig();
}
複製程式碼
方法4
- 配置不刪除tmp目錄下的tomcat
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[])
, 看下面的實現,直觀影響就是寫得真特麼嚴謹
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
# 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. 專案
1. 一灰灰Blog
- 一灰灰Blog個人部落格 blog.hhui.top
- 一灰灰Blog-Spring專題部落格 spring.hhui.top
一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛
2. 宣告
盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激
- 微博地址: 小灰灰Blog
- QQ: 一灰灰/3302797840
3. 掃描關注
一灰灰blog
知識星球