檔案上傳踩坑記及檔案清理原理探究

Java架構師課代表 發表於 2020-11-22

目錄

  • 1. 糟糕的非同步儲存檔案實現
  • 2. 異常原因推理
  • 3. 問題解決方式
  • 4. spring清理檔案原理
  • 5. tomcat清理檔案原理

檔案上傳踩坑記及檔案清理原理探究

 

最近搞一個檔案上傳功能,由於檔案太大,或者說其中包含了比較多的內容,需要大量邏輯處理。為了優化使用者體驗,自然想到使用非同步來做這件事。也就是說,使用者上傳完檔案後,我就開啟另一個執行緒來處理具體邏輯,主執行緒就直接返回使用者成功資訊了。這樣就顯得非常快了,要看具體結果可以到結果頁進行檢視。看起來很棒!

然後,我踩坑了。表象就是系統報找不到檔案的錯誤。具體如下!

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.Tomact清理檔案原理

如上,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不幹這活。自己幹吧!預設把臨時檔案放到系統的臨時目錄,由作業系統去輔助清理該資料夾,何其輕鬆。