文件上傳踩坑記及文件清理原理探究
最近搞一個文件上傳功能,由于文件太大,或者說其中包含了比較多的內容,需要大量邏輯處理。為了優(yōu)化用戶體驗,自然想到使用異步來做這件事。也就是說,用戶上傳完文件后,我就開啟另一個線程來處理具體邏輯,主線程就直接返回用戶成功信息了。這樣就顯得非??炝?,要看具體結果可以到結果頁進行查看。看起來很棒!
然后,我踩坑了。表象就是系統(tǒng)報找不到文件的錯誤。具體如下!
1:糟糕的異步存儲文件實現(xiàn)
為快速起見,我將原來同步的事情,直接改為了異步。如下:
@RestController@RequestMapping("/hello")@Slf4jpublic 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 elseSleepUtil.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的時候,沒有問題。然后似乎,如果不去注意上傳后的結果,好像一切看起來都很美好。然而,線上預期就很骨感了。上傳處理失敗,十之八九。
所以,結果就是,處理得快,出錯得也快。尷尬不!具體原因,下節(jié)詳述。
2. 異常原因推理
為什么會出現(xiàn)異常?而且我們仔細看其異常信息,就會發(fā)現(xiàn),其報的是文件未找到的異常。
實際也很簡單,因為我們是開的異步線程去處理文件的,那么和外部的請求線程不是一起的。而當外部線程處理完業(yè)務后,其攜帶的文件就會被刪除。
為什么會被刪除呢?我還持有其引用啊,它不應該刪除的啊。這么想也不會有問題,因為GC時只會清理無用對象。沒錯,MultipartFile 這個實例我們仍然是持有有效引用的,不會被GC掉。但是,其中含有的文件,則不在GC的管理范疇了。它并不會因為你還持有file這個對象的引用,而不會將文件刪除。至少想做這一點是很難的。
所以,總結:請求線程結束后,上傳的臨時文件會被清理掉。而如果文件處理線程在文件被刪除掉之后,再進行處理的話,自然就會報文件找不到的異常了。
同時,也可以解釋,為什么我們在debug的時候,沒有報錯了。因為,這是巧合啊。我們在debug時,也許剛好遇到子線程先處理文件,然后外部線程才退出。so, 你贏了。
另有一問題:為什么請求線程會將文件刪除呢?回答這個問題,我們可以從反面問一下,如果請求線程不清理文件,會怎么樣呢?答案是,系統(tǒng)上可能存在的臨時文件會越來越多,從而將磁盤搞跨,而這不是一個完美的框架該有的表現(xiàn)。
好了,理解了可能是框架層面做掉了清理這一動作,那么到底是誰干了這事?又是如何干成的呢?我們稍后再講。附模擬請求curl命令:
curl -F '[email protected]' -F 'a=1' -F 'b=2' http://localhost:8081/hello/uploadFileWithParam3. 問題解決方式
ok, 找到了問題的原因,要解決起來就容易多了。既然異步處理有問題,那么就改成同步處理好了。如下改造:
@RestController@RequestMapping("/hello")@Slf4jpublic 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 elseSleepUtil.sleepMillis(10L);log.info("從文件:{} 中讀取數(shù)據(jù),處理業(yè)務", localFilePath);log.info("文件處理完成");// do sth else}).start();return "success";}}
也就是說,我們將文件存儲的這一步,移到了請求線程中去處理了,而其他的流程,則同樣在異步線程中處理。有同學可能會問了,你這樣做不就又會導致請求線程變慢了,從而回到最初的問題點上了嗎?實際上,同學的想法有點多了,對一個文件的轉存并不會耗費多少時間,大可不必擔心。之所以導致處理慢的原因,更多的是因為我們的業(yè)務邏輯太過復雜導致。所以將文件轉存放到外部線程,一點問題都沒有。而被存儲到其他位置的文件,則再不會受到框架管理的影響了。
不過,還有個問題需要注意的是,如果你將文件放在臨時目錄,如果代碼出現(xiàn)了異常,那么文件被框架清理掉,而此時你將其轉移走后,代碼再出異常,則只能自己承擔這責任了。所以,理論上,我們還有一個最終的文件清理方案,比如放在 try ... finnaly ... 進行處理。樣例如下:
// 原本同步的工作,使用異步完成new Thread(() -> {try {// do sth elseSleepUtil.sleepMillis(10L);log.info("從文件:{} 中讀取數(shù)據(jù),處理業(yè)務", localFilePath);log.info("文件處理完成");// do sth else}finally {FileUtils.deleteQuietly(new File(localFilePath));}}).start();
如此,問題解決。
本著問題需要知其然,知其所以然的搬磚態(tài)度,我們還需要更深入點。探究框架層面的文件清理實現(xiàn)!請看下節(jié)。
4. spring清理文件原理
很明顯,spring框架輕車熟路,所以必拿其開刀。spring 中清理文件的實現(xiàn)比較直接,就是在將請求分配給業(yè)務代碼處理完成之后,就立即進行后續(xù)清理工作?! ?/span>
其操作是在 org.springframework.web.servlet.DispatcherServlet 中實現(xiàn)的。具體如下:
/*** 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 封裝requestprocessedRequest = 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 afterCompletionif (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);}}}
值得一提的是,要觸發(fā)文件的清理動作,需要有兩個前提:1. 本次上傳的是文件且被正常解析;2. 配置了正確的文件解析器即 multipartResolver;否則,文件并不會被處理掉。說這事的原因是,在spring框架的低版本中,multipartResolver默認是不配置的,所以此時文件并不會被清理掉。而在高版本或者 springboot中,該值會被默認配置上。也就是說,如果你不小心踩到了這個坑,你可能是因為中途才配置了這個 resolver 導致。
下面我們再來看下真正的清理動作是如何運行的:
// 1. StandardServletMultipartResolver 的清理實現(xiàn):直接迭代刪除// org.springframework.web.multipart.support.StandardServletMultipartResolver#cleanupMultipart@Overridepublic 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 的清理實現(xiàn):基于map結構的文件枚舉刪除// org.springframework.web.multipart.commons.CommonsMultipartResolver#cleanupMultipart@Overridepublic 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() : ""));}}}}
所以,同樣的事情,我們的做法往往是多種的。所以,千萬不要拘泥于某一種實現(xiàn)無法自拔,更多的,是需要我們有一個全局框架思維。從而不至于迷失自己。
5. tomcat清理文件原理
如上,spring在某些情況下是不會做清理動作的,那么如果此時我們的業(yè)務代碼出現(xiàn)了問題,這些臨時文件又當如何呢?難道就任其占用我們的磁盤空間?實際上,spring僅是一個應用框架,在其背后還需要有應用容器,如tomcat, netty, websphere...
那么,在應用框架沒有完成一些工作時,這些背后的容器是否應該有所作為呢?這應該是必須的,同樣,是一個好的應用容器該有的樣子。那么,我們看下tomcat是如何實現(xiàn)的呢?
然而事實上,tomcat并不會主動清理這些臨時文件,因為不知道業(yè)務,不知道清理時機,所以不敢輕舉妄動。但是,它會在重新部署的時候,去清理這些臨時文件喲(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#antiLockingprotected 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 aroundExpandWar.delete(antiLockingDocBase);if (ExpandWar.copy(docBaseFile, antiLockingDocBase)) {context.setDocBase(antiLockingDocBase.getPath());}}}// org.apache.catalina.startup.ExpandWar#deletepublic static boolean delete(File dir) {// Log failure by defaultreturn 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不干這活。自己干吧!默認把臨時文件放到系統(tǒng)的臨時目錄,由操作系統(tǒng)去輔助清理該文件夾,何其輕松。

騰訊、阿里、滴滴后臺面試題匯總總結 — (含答案)
面試:史上最全多線程面試題 !
最新阿里內推Java后端面試題
JVM難學?那是因為你沒認真看完這篇文章

關注作者微信公眾號 —《JAVA爛豬皮》
了解更多java后端架構知識以及最新面試寶典


看完本文記得給作者點贊+在看哦~~~大家的支持,是作者源源不斷出文的動力
作者:等你歸去來
出處:https://www.cnblogs.com/yougewe/p/14020041.html
