SpringMVC源碼分析:POST請求中的文件處理

小編:啊南 31閱讀 2020.11.19

本章我們來一起閱讀和分析SpringMVC的部分源碼,看看收到POST請求中的二進制文件后,SpingMVC框架是如何處理的;

使用了SpringMVC框架的web應用中,接收上傳文件時,一般分以下三步完成:

  1. 在spring配置文件中配置一個bean:
<bean id="multipartResolver"
      class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="defaultEncoding" value="utf-8" />
    <property name="maxUploadSize" value="10485760000" />
    <property name="maxInMemorySize" value="40960" />
</bean>
  1. pom.xml中添加apache的commons-fileupload庫的依賴:
<dependency>
	<groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.3.1</version>
</dependency>
  1. 開發業務Controller的響應方法,以下代碼是將POST的文件存儲到應用所在的電腦上:
@RequestMapping(value="/upload",method= RequestMethod.POST)
    public void upload(HttpServletRequest request,
                       HttpServletResponse response,
                         @RequestParam("comment") String comment,
                         @RequestParam("file") MultipartFile file) throws Exception {


        logger.info("start upload, comment [{}]", comment);


        if(null==file || file.isEmpty()){
            logger.error("file item is empty!");
            responseAndClose(response, "文件數據為空");
            return;
        }


        //上傳文件路徑
        String savePath = request.getServletContext().getRealPath("/WEB-INF/upload");


        //上傳文件名
        String fileName = file.getOriginalFilename();


        logger.info("base save path [{}], original file name [{}]", savePath, fileName);


        //得到文件保存的名稱
        fileName = mkFileName(fileName);


        //得到文件保存的路徑
        String savePathStr = mkFilePath(savePath, fileName);


        logger.info("real save path [{}], real file name [{}]", savePathStr, fileName);


        File filepath = new File(savePathStr, fileName);


        //確保路徑存在
        if(!filepath.getParentFile().exists()){
            logger.info("real save path is not exists, create now");
            filepath.getParentFile().mkdirs();
        }


        String fullSavePath = savePathStr + File.separator + fileName;


        //存本地
        file.transferTo(new File(fullSavePath));


        logger.info("save file success [{}]", fullSavePath);


        responseAndClose(response, "Spring MVC環境下,上傳文件成功");
    }

如上所示,方法入參中的MultipartFile就是POST的文件對應的對象,調用file.transferTo方法即可將上傳的文件創建到業務所需的位置;

三個疑問

雖然業務代碼簡單,以上幾步即可完成對上傳文件的接收和處理,但是有幾個疑問想要弄清楚:

  1. 為什么要配置名為multipartResolver的bean;
  2. 為什么要依賴apache的commons-fileupload庫;
  3. 從客戶端的POST到Controller中的file.transferTo方法調用,具體做了哪些文件相關的操作?

接下來我們就一起來看看SpringMVC的源碼,尋找這幾個問題的答案;

Spring版本

本文涉及的Spring相關庫,例如spring-core、spring-web、spring-webmvc等,都是4.0.2.RELEASE版本;

SpringMVC源碼
  1. 先來看下入口類DispatcherServlet的源碼,在應用初始化的時候會調用initMultipartResolver方法:
this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class);
...

所以,如果配置了名為multipartResolver的bean,就會DispatcherServlet的multipartResolver保存下來;

2. 再來看一下處理POST請求時候的調用鏈:

FrameworkServlet.doPost
->
FrameworkServlet.processRequest
->
DispatcherServlet.doService
->
DispatcherServlet.doDispatch
->
DispatcherServlet.checkMultipart
->
multipartResolver.resolveMultipart(request)

因此,應用收到上傳文件的請求時,最終會調用multipartResolver.resolveMultipart;

第一個疑問已經解開:SpringMVC框架在處理POST請求時,會使用名為multipartResolver的bean來處理文件;

3. CommonsMultipartResolver.resolveMultipart方法中會調用parseRequest方法,我們看parseRequest方法的源碼:

String encoding = this.determineEncoding(request);
FileUpload fileUpload = this.prepareFileUpload(encoding);


try {
	List<FileItem> fileItems = ((ServletFileUpload)fileUpload).parseRequest(request);
    return this.parseFileItems(fileItems, encoding);
} catch (SizeLimitExceededException var5) {
	throw new MaxUploadSizeExceededException(fileUpload.getSizeMax(), var5);
} catch (FileUploadException var6) {
	throw new MultipartException("Could not parse multipart servlet request", var6);
}

從以上代碼可以發現,在調用prepareFileUpload方法的時候,相關的fileItemFactory和fileUpload對象都已經是commons-fileupload庫中定義的類型了,并且最終還是調用由commons-fileupload庫中的ServletFileUpload.parseRequest方法負責解析工作,構建FileItem對象;第二個疑問已經解開:SpringMVC框架在處理POST請求時,本質是調用commons-fileupload庫中的API來處理的;

4. 繼續關注CommonsMultipartResolver.parseRequest方法,里面調用了ServletFileUpload.parseRequest方法,最終由FileUploadBase.parseRequest方法來處理:

public List<FileItem> parseRequest(RequestContext ctx)
            throws FileUploadException {
        List<FileItem> items = new ArrayList<FileItem>();
        boolean successful = false;
        try {
            FileItemIterator iter = getItemIterator(ctx);
            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(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 (Throwable e) {
                        // ignore it
                    }
                }
            }
        }
    }

重點關注這一段:Streams.copy(item.openStream(), fileItem.getOutputStream(), true);,這是一次流的拷貝,將提交文件的inputstrem寫入到一個outputstream,我們再看看getOutputStream方法的源碼:

public OutputStream getOutputStream()
        throws IOException {
        if (dfos == null) {
            File outputFile = getTempFile();
            dfos = new DeferredFileOutputStream(sizeThreshold, outputFile);
        }
        return dfos;
    }

原來如此,會準備一個臨時文件,上傳的文件通過流拷貝寫入到臨時文件中了;等一下,事情沒那么簡單!!!上面的代碼中并沒有直接返回文件對象outputFile,而是創建了一個DeferredFileOutputStream對象,這是個什么東西?另外sizeThreshold這個參數是干啥用的?

為了搞清楚上面兩個問題,我們從Streams.copy方法開始看吧:

a. Streams.copy方法的關鍵代碼如下:

for (;;) {
                int res = in.read(buffer);
                if (res == -1) {
                    break;
                }
                if (res > 0) {
                    total += res;
                    if (out != null) {
                        out.write(buffer, 0, res);
                    }
                }
            }

上述代碼表明,steam的copy過程中會調用OutputStream的write方法;

b. DeferredFileOutputStream類沒有write方法,去看它的父類DeferredFileOutputStream的write方法:

public void write(byte b[]) throws IOException
    {
        checkThreshold(b.length);
        getStream().write(b);
        written += b.length;
    }

先調用checkThreshold方法,檢查***已寫入長度加上即將寫入的長度***是否達到threshold值,如果達到就會將thresholdExceeded設置為true,并調用thresholdReached方法;

c. thresholdReached方法源碼如下:

protected void thresholdReached() throws IOException
    {
        if (prefix != null) {
            outputFile = File.createTempFile(prefix, suffix, directory);
        }
        FileOutputStream fos = new FileOutputStream(outputFile);
        memoryOutputStream.writeTo(fos);
        currentOutputStream = fos;
        memoryOutputStream = null;
    }

真相大白:threshold是一個閾值,如果文件比threshold小,就將文件存入內存,如果文件比threshold大就寫入到磁盤中去,這顯然是個處理文件時的優化手段; 注意這一行代碼:currentOutputStream = fos;,原本currentOutputStream是基于內存的ByteArrayOutputStream,如果超過了threshold,就改為基于文件的FileOutputStream對象,后續再執行getStream().write(b)的時候,就不再寫入到內存,而是寫入到文件了; 5. 我們再回到主線:CommonsMultipartResolver,這里FileItem對象在parseFileItems方法中經過處理,被放入了CommonsMultipartFile對象中,再被放入MultipartParsingResult對象中,最后被放入DefaultMultipartHttpServletRequest對象中,返回到DispatcherServlet.doDispatch方法中,然后傳遞到業務的controller中處理; 6. 業務Controller的響應方法中,調用了file.transferTo方法將臨時文件寫入到業務指定的文件中,transferTo方法中有一行關鍵代碼:this.fileItem.write(dest);,我們打開DiskFileItem類,看看這個write方法的源碼:

public void write(File file) throws Exception {
        if (isInMemory()) {
            FileOutputStream fout = null;
            try {
                fout = new FileOutputStream(file);
                fout.write(get());
            } finally {
                if (fout != null) {
                    fout.close();
                }
            }
        } else {
            File outputFile = getStoreLocation();
            if (outputFile != null) {
                // Save the length of the file
                size = outputFile.length();
                /*
                 * The uploaded file is being stored on disk
                 * in a temporary location so move it to the
                 * desired file.
                 */
                if (!outputFile.renameTo(file)) {
                    BufferedInputStream in = null;
                    BufferedOutputStream out = null;
                    try {
                        in = new BufferedInputStream(
                            new FileInputStream(outputFile));
                        out = new BufferedOutputStream(
                                new FileOutputStream(file));
                        IOUtils.copy(in, out);
                    } finally {
                        if (in != null) {
                            try {
                                in.close();
                            } catch (IOException e) {
                                // ignore
                            }
                        }
                        if (out != null) {
                            try {
                                out.close();
                            } catch (IOException e) {
                                // ignore
                            }
                        }
                    }
                }
            } else {
                /*
                 * For whatever reason we cannot write the
                 * file to disk.
                 */
                throw new FileUploadException(
                    "Cannot write uploaded file to disk!");
            }
        }
    }

如上所示,依然是對DeferredFileOutputStream對象的操作,如果數據在內存中,就寫入到指定文件,否則就嘗試將臨時文件rename為指定文件,如果rename失敗,就會讀取臨時文件的二進制流,再寫到指定文件上去;

另外,DiskFileItem中出現的cachedContent對象,其本身也就是DeferredFileOutputStream的內存數據;

至此,第三個疑問也解開了:上傳的文件如果小于指定的閾值,就會被保存在內存中,否則就存在磁盤上,留給業務代碼用,業務代碼在使用時通過CommonsMultipartFile對象來操作;

似乎又有一個疑問了:這些臨時文件存在內存或者磁盤上,什么時候清理呢,不清理豈不是越來越多?

在DispatcherServlet.doDispatch方法中,有這么一段:

finally {
			if (asyncManager.isConcurrentHandlingStarted()) {
				// Instead of postHandle and afterCompletion
				mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
				return;
			}
			// Clean up any resources used by a multipart request.
			if (multipartRequestParsed) {
				cleanupMultipart(processedRequest);
			}
		}

關鍵代碼是cleanupMultipart(processedRequest);,進去跟蹤發現會調用CommonsFileUploadSupport.cleanupFileItems方法,最終調用DiskFileItem.delete方法,將臨時文件清理掉;

至此SpringMVC源碼分析就結束了,接下來列出一些web應用的源碼,作為可能用到的參考信息;

demo源碼下載

文中提到的demo工程,您可以在GitHub下載,地址和鏈接信息如下表所示:

名稱

鏈接

備注

項目主頁

https://github.com/zq2599/blog_demos

該項目在GitHub上的主頁

git倉庫地址(https)

https://github.com/zq2599/blog_demos.git

該項目源碼的倉庫地址,https協議

git倉庫地址(ssh)

git@github.com:zq2599/blog_demos.git

該項目源碼的倉庫地址,ssh協議

  • 這個git項目中有多個目錄,本次所需的資源放在springmvcfileserver,
  • 如果您想了解如何POST二進制文件到服務端,請下載uploadfileclient這個文件夾下的客戶端demo工程,
  • 如果您不想讓SpringMVC處理上傳的文件,而是自己去調用apache的commons-fileupload庫來做些更復雜的操作,您可以參考fileserverdemo這個文件夾下的demo工程,
  • 如果您的應用是基于springboot的,實現文件服務可以參考springbootfileserver這個文件夾下的demo工程,

至此,本次閱讀和分析實戰已全部完成,在您學習和理解SpringMVC框架的過程中,希望本文能對您有所幫助,如果發現文中有錯誤,也真誠的期待您能留下意見;

關聯標簽:
华东15选5彩票奖结果 大数据预测彩票软件 比特币收益计算 博马365娱乐城官方网站-点击进入 复式一等奖奖金计算 一分赛车投注技巧 币看比特币官网app 星悦云南麻将 大乐透300坐标走势 北京11选5基本走势图 硕士研究生毕业证图片 星力9代正规捕鱼平台 四川熊猫麻将手机版下载 3d计划八码 体彩顶呱刮金孔雀 体育比分什么比较好用 微信捕鱼提现金