美文网首页
文件上传踩坑记及文件清理原理探究

文件上传踩坑记及文件清理原理探究

作者: 码农奋斗之路 | 来源:发表于2020-11-22 20:50 被阅读0次

    目录

    • 1. 糟糕的异步存储文件实现
    • 2. 异常原因推理
    • 3. 问题解决方式
    • 4. spring清理文件原理
    • 5. tomcat清理文件原理

    image

    最近搞一个文件上传功能,由于文件太大,或者说其中包含了比较多的内容,需要大量逻辑处理。为了优化用户体验,自然想到使用异步来做这件事。也就是说,用户上传完文件后,我就开启另一个线程来处理具体逻辑,主线程就直接返回用户成功信息了。这样就显得非常快了,要看具体结果可以到结果页进行查看。看起来很棒!

    然后,我踩坑了。表象就是系统报找不到文件的错误。具体如下!

    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 'file=@uptest.txt' -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不干这活。自己干吧!默认把临时文件放到系统的临时目录,由操作系统去辅助清理该文件夹,何其轻松。

    相关文章

      网友评论

          本文标题:文件上传踩坑记及文件清理原理探究

          本文链接:https://www.haomeiwen.com/subject/kkrpiktx.html