原文: 一灰灰Blog之Spring系列教程文件上傳異常原理分析java
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針對文件上傳的處理過程linux
問題定位,最佳的輔助手段就是堆棧分析,首先撈出核心的堆棧信息git
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路徑,進入目錄,結果發現,沒有這個目錄,那麼問題的關鍵就是沒有目錄爲何會致使異常了,這個目錄到底有啥用github
先簡單描述下上面的緣由,上傳的文件會緩存到本地磁盤,而緩存的路徑就是上面的/tmp/tomcat.6239989728636105816.19530/work/Tomcat/localhost/ROOT
,接着引入的疑問就是:web
要確認上面的問題,最直觀的方法就是擼源碼,直接看代碼就有點蛋疼了,接下來採用debug方式來層層剝離,看下根源再哪裏。redis
首先是搭建一個簡單的測試項目,進行場景復現, 首先建立一個接收文件上傳的Controller,以下spring
@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來上傳文件apache
curl http://127.0.0.1:8080/file/upload -F "file=@/Users/user/Desktop/demo.jpg" -v
而後在接收文件上傳的方法中開啓斷點,注意下面紅框中的 location
, 就是文件上傳的臨時目錄vim
上面的截圖能夠確認確實將上傳的文件保存到了臨時目錄,驗證方式就是進入那個目錄進行查看,會看到一個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));
到上面,基本上就撈到了最終的問題,先看如何解決這個問題
方法1
方法2
server.tomcat.basedir=/tmp/tomcat
方法3
@Bean MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory = new MultipartConfigFactory(); factory.setLocation("/tmp/tomcat"); return factory.createMultipartConfig(); }
方法4
vim /usr/lib/tmpfiles.d/tmp.conf # 添加一行 x /tmp/tomcat.*
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); } } }
前面提出了幾個問題,如今給一個簡單的回答,由於篇幅問題,後面會單開一文,進行詳細說明
上面的定位過程給出答案,具體實現邏輯在 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
由於流取一次消費以後,後面沒法再從流中獲取數據,因此緩存方便後續複用;這一塊後面詳細說明
定位這個問題的感受,就是對SpringBoot和tomcat的底層,實在是不太熟悉,做爲一個以Spring和tomcat吃飯的碼農而言,發現問題就須要改正,列入todo列表,後續須要深刻一下
一灰灰的我的博客,記錄全部學習和工做中的博文,歡迎你們前去逛逛
盡信書則不如,以上內容,純屬一家之言,因我的能力有限,不免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激
一灰灰blog
知識星球