檔案上傳踩坑記及檔案清理原理探究
目錄
- 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 '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不幹這活。自己幹吧!預設把臨時檔案放到系統的臨時目錄,由作業系統去輔助清理該資料夾,何其輕鬆。
相關文章
- 【Hadoop踩坑】HDFS上傳、刪除檔案失敗Hadoop
- HTTP檔案上傳原理HTTP
- .NET Core 如何上傳檔案及處理大檔案上傳
- Jmeter之讀取csv檔案踩坑記JMeter
- Netty接收HTTP檔案上傳及檔案下載NettyHTTP
- 檔案上傳原理和實現
- 阿里雲 oss 檔案上傳 小坑阿里
- JSP筆記-檔案上傳JS筆記
- 單個檔案上傳和批量檔案上傳
- 檔案上傳
- 【轉】大檔案上傳原理及C#實現方案C#
- Java SE 檔案上傳和檔案下載的底層原理Java
- SpringMVC檔案上傳下載(單檔案、多檔案)SpringMVC
- django+jquery 用post方式上傳檔案採坑記錄DjangojQuery
- 檔案上傳之三基於flash的檔案上傳
- Linux伺服器上傳檔案傳送檔案Linux伺服器
- 【Hadoop踩雷】無法上傳檔案?有辦法!Hadoop
- minio上傳檔案
- 檔案上傳漏洞
- JavaScript 檔案上傳JavaScript
- SpringBoot上傳檔案Spring Boot
- Flask——檔案上傳Flask
- Linux上傳檔案Linux
- Git上傳檔案Git
- PHP上傳檔案PHP
- 檔案上傳概述
- ajaxfileupload 檔案上傳
- Java大檔案上傳、分片上傳、多檔案上傳、斷點續傳、上傳檔案minio、分片上傳minio等解決方案Java斷點
- Laravel Homestead踩坑記3——資料庫與配置檔案Laravel資料庫
- MinIO上傳和下載檔案及檔案完整性校驗.
- VSCode 檔案清理VSCode
- 檔案包含漏洞(本地包含配合檔案上傳)
- PHP實現單檔案、多檔案上傳 封裝 物件導向實現檔案上傳PHP封裝物件
- 檔案上傳下載
- Linux——拖拽上傳檔案Linux
- WEB漏洞——檔案上傳Web
- SpringMVC之檔案上傳SpringMVC
- 上傳檔案的陷阱