文件上傳踩坑記及文件清理原理探究
最近搞一個文件上傳功能,由於文件太大,或者說其中包含了比較多的內容,需要大量邏輯處理。為了優化用戶體驗,自然想到使用非同步來做這件事。也就是說,用戶上傳完文件後,我就開啟另一個執行緒來處理具體邏輯,主執行緒就直接返回用戶成功資訊了。這樣就顯得非常快了,要看具體結果可以到結果頁進行查看。看起來很棒!
然後,我踩坑了。表象就是系統報找不到文件的錯誤。具體如下!
1. 糟糕的非同步存儲文件實現
為快速起見,我將原來同步的事情,直接改為了非同步。如下:
@RestController @RequestMapping("/hello") @Slf4j public class HelloController { @PostMapping("uploadFileWithParam") public Object uploadFileWithParam(HttpServletRequest request, @RequestParam Map<String, Object> params) { log.info("param:{}", params); DefaultMultipartHttpServletRequest multipartRequest = (DefaultMultipartHttpServletRequest) request; MultipartFile file = multipartRequest.getFile("file"); // 原本同步的工作,使用非同步完成 new Thread(() -> { // do sth else SleepUtil.sleepMillis(10L); if(file == null || file.isEmpty()) { log.error("文件為空"); return; } try { file.transferTo(new File("/tmp/" + System.currentTimeMillis() + ".dest")); } catch (IOException e) { log.error("文件存儲異常", e); } log.info("文件處理完成"); // do sth else }).start(); return "success"; } }
看起來挺簡單的,實則埋下一大坑。也不是自己不清楚這事,只是一時糊塗,就幹了。這會有什麼問題?
至少我在本地debug的時候,沒有問題。然後似乎,如果不去注意上傳後的結果,好像一切看起來都很美好。然而,線上預期就很骨感了。上傳處理失敗,十之八九。
所以,結果就是,處理得快,出錯得也快。尷尬不!具體原因,下節詳述。
2. 異常原因推理
為什麼會出現異常?而且我們仔細看其異常資訊,就會發現,其報的是文件未找到的異常。
實際也很簡單,因為我們是開的非同步執行緒去處理文件的,那麼和外部的請求執行緒不是一起的。而當外部執行緒處理完業務後,其攜帶的文件就會被刪除。
為什麼會被刪除呢?我還持有其引用啊,它不應該刪除的啊。這麼想也不會有問題,因為GC時只會清理無用對象。沒錯,MultipartFile 這個實例我們仍然是持有有效引用的,不會被GC掉。但是,其中含有的文件,則不在GC的管理範疇了。它並不會因為你還持有file這個對象的引用,而不會將文件刪除。至少想做這一點是很難的。
所以,總結:請求執行緒結束後,上傳的臨時文件會被清理掉。而如果文件處理執行緒在文件被刪除掉之後,再進行處理的話,自然就會報文件找不到的異常了。
同時,也可以解釋,為什麼我們在debug的時候,沒有報錯了。因為,這是巧合啊。我們在debug時,也許剛好遇到子執行緒先處理文件,然後外部執行緒才退出。so, 你贏了。
另有一問題:為什麼請求執行緒會將文件刪除呢?回答這個問題,我們可以從反面問一下,如果請求執行緒不清理文件,會怎麼樣呢?答案是,系統上可能存在的臨時文件會越來越多,從而將磁碟搞跨,而這不是一個完美的框架該有的表現。
好了,理解了可能是框架層面做掉了清理這一動作,那麼到底是誰幹了這事?又是如何干成的呢?我們稍後再講。附模擬請求curl命令:
curl -F '[email protected]' -F 'a=1' -F 'b=2' http://localhost:8081/hello/uploadFileWithParam
3. 問題解決方式
ok, 找到了問題的原因,要解決起來就容易多了。既然非同步處理有問題,那麼就改成同步處理好了。如下改造:
@RestController @RequestMapping("/hello") @Slf4j public class HelloController { @PostMapping("uploadFileWithParam") public Object uploadFileWithParam(HttpServletRequest request, @RequestParam Map<String, Object> params) { log.info("param:{}", params); DefaultMultipartHttpServletRequest multipartRequest = (DefaultMultipartHttpServletRequest) request; MultipartFile file = multipartRequest.getFile("file"); if(file == null || file.isEmpty()) { log.error("文件為空"); return "file is empty"; } String localFilePath = "/tmp/" + System.currentTimeMillis() + ".dest"; try { file.transferTo(new File(localFilePath)); } catch (IOException e) { log.error("文件存儲異常", e); } // 原本同步的工作,使用非同步完成 new Thread(() -> { // do sth else SleepUtil.sleepMillis(10L); log.info("從文件:{} 中讀取數據,處理業務", localFilePath); log.info("文件處理完成"); // do sth else }).start(); return "success"; } }
也就是說,我們將文件存儲的這一步,移到了請求執行緒中去處理了,而其他的流程,則同樣在非同步執行緒中處理。有同學可能會問了,你這樣做不就又會導致請求執行緒變慢了,從而回到最初的問題點上了嗎?實際上,同學的想法有點多了,對一個文件的轉存並不會耗費多少時間,大可不必擔心。之所以導致處理慢的原因,更多的是因為我們的業務邏輯太過複雜導致。所以將文件轉存放到外部執行緒,一點問題都沒有。而被存儲到其他位置的文件,則再不會受到框架管理的影響了。
不過,還有個問題需要注意的是,如果你將文件放在臨時目錄,如果程式碼出現了異常,那麼文件被框架清理掉,而此時你將其轉移走後,程式碼再出異常,則只能自己承擔這責任了。所以,理論上,我們還有一個最終的文件清理方案,比如放在 try … finnaly … 進行處理。樣例如下:
// 原本同步的工作,使用非同步完成 new Thread(() -> { try { // do sth else SleepUtil.sleepMillis(10L); log.info("從文件:{} 中讀取數據,處理業務", localFilePath); log.info("文件處理完成"); // do sth else } finally { FileUtils.deleteQuietly(new File(localFilePath)); } }).start();
如此,問題解決。
本著問題需要知其然,知其所以然的搬磚態度,我們還需要更深入點。探究框架層面的文件清理實現!請看下節。
4. spring清理文件原理
很明顯,spring框架輕車熟路,所以必拿其開刀。spring 中清理文件的實現比較直接,就是在將請求分配給業務程式碼處理完成之後,就立即進行後續清理工作。
其操作是在 org.springframework.web.servlet.DispatcherServlet 中實現的。具體如下:
/** * Process the actual dispatching to the handler. * <p>The handler will be obtained by applying the servlet's HandlerMappings in order. * The HandlerAdapter will be obtained by querying the servlet's installed HandlerAdapters * to find the first that supports the handler class. * <p>All HTTP methods are handled by this method. It's up to HandlerAdapters or handlers * themselves to decide which methods are acceptable. * @param request current HTTP request * @param response current HTTP response * @throws Exception in case of any kind of processing failure */ protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HttpServletRequest processedRequest = request; HandlerExecutionChain mappedHandler = null; boolean multipartRequestParsed = false; WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request); try { ModelAndView mv = null; Exception dispatchException = null; try { // 主動解析MultipartFile文件資訊,並使用如 StandardServletMultipartResolver 封裝request processedRequest = checkMultipart(request); multipartRequestParsed = (processedRequest != request); // Determine handler for the current request. mappedHandler = getHandler(processedRequest); if (mappedHandler == null) { noHandlerFound(processedRequest, response); return; } // Determine handler adapter for the current request. HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // Process last-modified header, if supported by the handler. String method = request.getMethod(); boolean isGet = "GET".equals(method); if (isGet || "HEAD".equals(method)) { long lastModified = ha.getLastModified(request, mappedHandler.getHandler()); if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) { return; } } if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // Actually invoke the handler. mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); if (asyncManager.isConcurrentHandlingStarted()) { return; } applyDefaultViewName(processedRequest, mv); mappedHandler.applyPostHandle(processedRequest, response, mv); } catch (Exception ex) { dispatchException = ex; } catch (Throwable err) { // As of 4.3, we're processing Errors thrown from handler methods as well, // making them available for @ExceptionHandler methods and other scenarios. dispatchException = new NestedServletException("Handler dispatch failed", err); } processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException); } catch (Exception ex) { triggerAfterCompletion(processedRequest, response, mappedHandler, ex); } catch (Throwable err) { triggerAfterCompletion(processedRequest, response, mappedHandler, new NestedServletException("Handler processing failed", err)); } finally { if (asyncManager.isConcurrentHandlingStarted()) { // Instead of postHandle and afterCompletion if (mappedHandler != null) { mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response); } } else { // 如果是 multipart 文件上傳,則做清理動作 // Clean up any resources used by a multipart request. if (multipartRequestParsed) { cleanupMultipart(processedRequest); } } } } /** * Clean up any resources used by the given multipart request (if any). * @param request current HTTP request * @see MultipartResolver#cleanupMultipart */ protected void cleanupMultipart(HttpServletRequest request) { if (this.multipartResolver != null) { MultipartHttpServletRequest multipartRequest = WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); if (multipartRequest != null) { this.multipartResolver.cleanupMultipart(multipartRequest); } } }
值得一提的是,要觸發文件的清理動作,需要有兩個前提:1. 本次上傳的是文件且被正常解析; 2. 配置了正確的文件解析器即 multipartResolver;否則,文件並不會被處理掉。說這事的原因是,在spring框架的低版本中,multipartResolver默認是不配置的,所以此時文件並不會被清理掉。而在高版本或者 springboot中,該值會被默認配置上。也就是說,如果你不小心踩到了這個坑,你可能是因為中途才配置了這個 resolver 導致。
下面我們再來看下真正的清理動作是如何運行的:
// 1. StandardServletMultipartResolver 的清理實現:直接迭代刪除 // org.springframework.web.multipart.support.StandardServletMultipartResolver#cleanupMultipart @Override public void cleanupMultipart(MultipartHttpServletRequest request) { if (!(request instanceof AbstractMultipartHttpServletRequest) || ((AbstractMultipartHttpServletRequest) request).isResolved()) { // To be on the safe side: explicitly delete the parts, // but only actual file parts (for Resin compatibility) try { for (Part part : request.getParts()) { if (request.getFile(part.getName()) != null) { part.delete(); } } } catch (Throwable ex) { LogFactory.getLog(getClass()).warn("Failed to perform cleanup of multipart items", ex); } } } // 2. CommonsMultipartResolver 的清理實現:基於map結構的文件枚舉刪除 // org.springframework.web.multipart.commons.CommonsMultipartResolver#cleanupMultipart @Override public void cleanupMultipart(MultipartHttpServletRequest request) { if (!(request instanceof AbstractMultipartHttpServletRequest) || ((AbstractMultipartHttpServletRequest) request).isResolved()) { try { cleanupFileItems(request.getMultiFileMap()); } catch (Throwable ex) { logger.warn("Failed to perform multipart cleanup for servlet request", ex); } } } /** * Cleanup the Spring MultipartFiles created during multipart parsing, * potentially holding temporary data on disk. * <p>Deletes the underlying Commons FileItem instances. * @param multipartFiles a Collection of MultipartFile instances * @see org.apache.commons.fileupload.FileItem#delete() */ protected void cleanupFileItems(MultiValueMap<String, MultipartFile> multipartFiles) { for (List<MultipartFile> files : multipartFiles.values()) { for (MultipartFile file : files) { if (file instanceof CommonsMultipartFile) { CommonsMultipartFile cmf = (CommonsMultipartFile) file; cmf.getFileItem().delete(); LogFormatUtils.traceDebug(logger, traceOn -> "Cleaning up part '" + cmf.getName() + "', filename '" + cmf.getOriginalFilename() + "'" + (traceOn ? ", stored " + cmf.getStorageDescription() : "")); } } } }
所以,同樣的事情,我們的做法往往是多種的。所以,千萬不要拘泥於某一種實現無法自拔,更多的,是需要我們有一個全局框架思維。從而不至於迷失自己。
5. tomcat清理文件原理
如上,spring在某些情況下是不會做清理動作的,那麼如果此時我們的業務程式碼出現了問題,這些臨時文件又當如何呢?難道就任其佔用我們的磁碟空間?實際上,spring僅是一個應用框架,在其背後還需要有應用容器,如tomcat, netty, websphere…
那麼,在應用框架沒有完成一些工作時,這些背後的容器是否應該有所作為呢?這應該是必須的,同樣,是一個好的應用容器該有的樣子。那麼,我們看下tomcat是如何實現的呢?
然而事實上,tomcat並不會主動清理這些臨時文件,因為不知道業務,不知道清理時機,所以不敢輕舉妄動。但是,它會在重新部署的時候,去清理這些臨時文件喲(java.io.tmpdir 配置值)。也就是說,這些臨時文件,至多可以保留到下一次重新部署的時間。
// org.apache.catalina.startup.ContextConfig#beforeStart /** * Process a "before start" event for this Context. */ protected synchronized void beforeStart() { try { fixDocBase(); } catch (IOException e) { log.error(sm.getString( "contextConfig.fixDocBase", context.getName()), e); } antiLocking(); } // org.apache.catalina.startup.ContextConfig#antiLocking protected void antiLocking() { if ((context instanceof StandardContext) && ((StandardContext) context).getAntiResourceLocking()) { Host host = (Host) context.getParent(); String docBase = context.getDocBase(); if (docBase == null) { return; } originalDocBase = docBase; File docBaseFile = new File(docBase); if (!docBaseFile.isAbsolute()) { docBaseFile = new File(host.getAppBaseFile(), docBase); } String path = context.getPath(); if (path == null) { return; } ContextName cn = new ContextName(path, context.getWebappVersion()); docBase = cn.getBaseName(); if (originalDocBase.toLowerCase(Locale.ENGLISH).endsWith(".war")) { antiLockingDocBase = new File( System.getProperty("java.io.tmpdir"), deploymentCount++ + "-" + docBase + ".war"); } else { antiLockingDocBase = new File( System.getProperty("java.io.tmpdir"), deploymentCount++ + "-" + docBase); } antiLockingDocBase = antiLockingDocBase.getAbsoluteFile(); if (log.isDebugEnabled()) { log.debug("Anti locking context[" + context.getName() + "] setting docBase to " + antiLockingDocBase.getPath()); } // 清理臨時文件夾 // Cleanup just in case an old deployment is lying around ExpandWar.delete(antiLockingDocBase); if (ExpandWar.copy(docBaseFile, antiLockingDocBase)) { context.setDocBase(antiLockingDocBase.getPath()); } } } // org.apache.catalina.startup.ExpandWar#delete public static boolean delete(File dir) { // Log failure by default return delete(dir, true); } public static boolean delete(File dir, boolean logFailure) { boolean result; if (dir.isDirectory()) { result = deleteDir(dir, logFailure); } else { if (dir.exists()) { result = dir.delete(); } else { result = true; } } if (logFailure && !result) { log.error(sm.getString( "expandWar.deleteFailed", dir.getAbsolutePath())); } return result; }
嗨,tomcat不幹這活。自己干吧!默認把臨時文件放到系統的臨時目錄,由作業系統去輔助清理該文件夾,何其輕鬆。