SpringMVC源碼分析:POST請求中的文件處理
小編:啊南 31閱讀 2020.11.19
本章我們來一起閱讀和分析SpringMVC的部分源碼,看看收到POST請求中的二進制文件后,SpingMVC框架是如何處理的;
使用了SpringMVC框架的web應用中,接收上傳文件時,一般分以下三步完成:
- 在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>
- pom.xml中添加apache的commons-fileupload庫的依賴:
<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.3.1</version> </dependency>
- 開發業務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方法即可將上傳的文件創建到業務所需的位置;
三個疑問雖然業務代碼簡單,以上幾步即可完成對上傳文件的接收和處理,但是有幾個疑問想要弄清楚:
- 為什么要配置名為multipartResolver的bean;
- 為什么要依賴apache的commons-fileupload庫;
- 從客戶端的POST到Controller中的file.transferTo方法調用,具體做了哪些文件相關的操作?
接下來我們就一起來看看SpringMVC的源碼,尋找這幾個問題的答案;
Spring版本本文涉及的Spring相關庫,例如spring-core、spring-web、spring-webmvc等,都是4.0.2.RELEASE版本;
SpringMVC源碼- 先來看下入口類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框架的過程中,希望本文能對您有所幫助,如果發現文中有錯誤,也真誠的期待您能留下意見;
相關推薦
- VC連接MySql VC連接MySql一丶MySql 需要了解的知識VC連接MySql 需要了解幾個關鍵的API:MYSQL * stdcall mysql init (MYSQL *mysql): 初始化一個數據庫.如果傳NULL.則返回一個數據庫對象mysql_real connect(); 與MySql 數據庫創建連接mySql_close() 關閉連接釋放對象.如果自…
- Java向Oracle數據庫表中插入CLOB、BLOB字段 在需要存儲較長字符串到數據庫中時往往需要使用一些特殊類型的字段,在Oracle中即blob和clob字段,一般而言:Clob字段存儲字符信息,比如較長的文字、評論,Blob字段存儲字節信息,比如圖像的base64編碼。注意,上述字段的使用均可以用其他方式替代,比如用Mon…