時(shí)進(jìn)度條實(shí)現(xiàn)指南)
做文件上傳功能很多人第一反應(yīng)是“這有什么難的form里放個(gè)input后端request.files接一下就行”。但真到實(shí)際項(xiàng)目里尤其是要傳大文件、要展示實(shí)時(shí)進(jìn)度、要支持多用戶并發(fā)上傳的時(shí)候事情就完全不是那么回事了。我這次用Flask做了一個(gè)帶多線程處理和實(shí)時(shí)進(jìn)度展示的文件上傳頁(yè)面前后踩了不少坑把整個(gè)實(shí)現(xiàn)思路和關(guān)鍵代碼整理出來(lái)希望能幫到正在做類似需求的人。先講一下這個(gè)功能是干什么的用戶通過(guò)瀏覽器選擇一個(gè)或多個(gè)文件點(diǎn)擊上傳后前端頁(yè)面能實(shí)時(shí)顯示每個(gè)文件的上傳百分比和處理狀態(tài)后端接收到文件后不會(huì)只做簡(jiǎn)單的保存而是會(huì)丟到一個(gè)多線程任務(wù)里做后續(xù)處理比如解析、轉(zhuǎn)碼、壓縮、入庫(kù)等處理進(jìn)度同樣會(huì)反饋到前端。整個(gè)過(guò)程中頁(yè)面不需要刷新用戶能直觀看到“上傳中→處理中→完成”的完整鏈路。這套方案適合誰(shuí)參考如果你是剛接觸Flask的開發(fā)者或者正在做內(nèi)部工具、數(shù)據(jù)平臺(tái)、自動(dòng)化運(yùn)維系統(tǒng)這一類需要文件上傳并且要看得見進(jìn)度條的場(chǎng)景這篇內(nèi)容可以直接照著抄。我會(huì)把前端交互、后端多線程任務(wù)管理、進(jìn)度數(shù)據(jù)共享、接口設(shè)計(jì)全部拆開講包括我在實(shí)際開發(fā)中遇到的幾個(gè)典型問(wèn)題比如進(jìn)度丟失、線程風(fēng)暴、調(diào)試模式下reloader導(dǎo)致的詭異行為等。1. 整體設(shè)計(jì)與方案選型1.1 為什么不用現(xiàn)成的上傳組件而要自己實(shí)現(xiàn)市面上有不少現(xiàn)成的文件上傳組件比如Dropzone.js、webuploader、layui的upload模塊甚至Flask也有flask-uploads、flask-reuploaded這類擴(kuò)展。那為什么還要自己手寫一套原因有三點(diǎn)。第一現(xiàn)成組件大多只解決“上傳”這一步也就是把文件從瀏覽器送到服務(wù)器。但很多業(yè)務(wù)場(chǎng)景里真正的重頭戲是上傳之后的服務(wù)端處理比如圖片壓縮、Excel解析、視頻轉(zhuǎn)碼、數(shù)據(jù)清洗等這些組件的進(jìn)度條在文件落盤之后就結(jié)束了服務(wù)端處理階段完全是個(gè)黑盒。這個(gè)項(xiàng)目要的是“上傳處理”全流程的實(shí)時(shí)進(jìn)度這是現(xiàn)成組件給不了的。第二自定義實(shí)現(xiàn)意味著可以完全掌控交互細(xì)節(jié)。比如多文件同時(shí)上傳時(shí)每個(gè)文件要顯示獨(dú)立的進(jìn)度條某個(gè)文件處理失敗時(shí)要能單獨(dú)重試而不用重新傳整個(gè)文件處理過(guò)程中要允許用戶取消任務(wù)。這些需求在不同組件里的實(shí)現(xiàn)方式差別很大與其去讀別人的源碼做適配不如自己寫一套簡(jiǎn)單的反而更可控。第三從學(xué)習(xí)角度來(lái)說(shuō)自己實(shí)現(xiàn)一遍能讓你真正理解HTTP上傳機(jī)制、多線程協(xié)作、前后端通信這些基礎(chǔ)原理。這些東西搞明白了以后換任何框架都能快速上手。1.2 技術(shù)棧選擇Flask XHR 多線程 輪詢這套方案的核心技術(shù)選型并不復(fù)雜但每個(gè)選擇背后都有它的理由。后端用Flask這個(gè)沒(méi)什么好說(shuō)的輕量、靈活、上手快特別適合這種單機(jī)內(nèi)部工具類的應(yīng)用。如果項(xiàng)目規(guī)模大了可以換FastAPI但核心思想是完全一樣的。前端沒(méi)有用fetch而是用XMLHttpRequestXHR對(duì)象為什么因?yàn)閒etch雖然支持上傳文件但它的上傳進(jìn)度事件支持得不如XHR成熟。XHR有現(xiàn)成的xhr.upload.onprogress事件能拿到當(dāng)前上傳了多少字節(jié)做進(jìn)度條幾乎是零成本。fetch想要實(shí)現(xiàn)同樣的效果得自己用ReadableStream去包裝請(qǐng)求體復(fù)雜度一下子高很多。所以在這個(gè)場(chǎng)景下XHR反而是更務(wù)實(shí)的選擇。進(jìn)度展示這塊我用的是前端顯示“上傳進(jìn)度”瀏覽器→服務(wù)器后端用內(nèi)存字典保存“處理進(jìn)度”服務(wù)器內(nèi)部任務(wù)前端通過(guò)輪詢接口獲取處理進(jìn)度。為什么不直接用WebSocket或者SSE因?yàn)檫M(jìn)度信息的更新頻率并不高每秒鐘輪詢一次足夠了用WebSocket有點(diǎn)殺雞用牛刀的意味而且Flask原生要支持WebSocket還得額外裝Flask-SocketIO依賴變重了。多線程用來(lái)做什么兩個(gè)地方。第一后端接完文件后把耗時(shí)的處理任務(wù)丟到后臺(tái)線程里執(zhí)行主請(qǐng)求線程立刻返回避免用戶一直等著HTTP響應(yīng)第二一個(gè)上傳請(qǐng)求進(jìn)來(lái)時(shí)Flask本身會(huì)開一個(gè)線程去處理這個(gè)請(qǐng)求天然支持并發(fā)上傳。所以這里的“多線程”其實(shí)是兩層含義一個(gè)是Flask的請(qǐng)求并發(fā)模型另一個(gè)是任務(wù)層的自定義多線程。2. 前端頁(yè)面與交互實(shí)現(xiàn)2.1 頁(yè)面結(jié)構(gòu)和基礎(chǔ)樣式前端頁(yè)面我先做了一個(gè)簡(jiǎn)潔的上傳區(qū)域包含一個(gè)拖拽區(qū)、一個(gè)文件選擇按鈕、一個(gè)文件列表。文件列表里的每一項(xiàng)都會(huì)顯示文件名、文件大小、當(dāng)前狀態(tài)等待上傳/上傳中/處理中/已完成/失敗、進(jìn)度條、以及一個(gè)取消按鈕。HTML部分核心代碼是這樣的div classupload-container div classupload-dropzone iddropZone input typefile idfileInput multiple p拖拽文件到這里或點(diǎn)擊選擇文件/p /div ul classupload-list iduploadList/ul /div這里的multiple屬性一定要記得加否則用戶一次只能選一個(gè)文件體驗(yàn)會(huì)比較差。dropZone是整個(gè)可拖拽區(qū)域點(diǎn)擊它時(shí)觸發(fā)隱藏的fileInput的click事件實(shí)現(xiàn)“點(diǎn)擊選擇拖拽上傳”兩種方式。文件列表項(xiàng)是動(dòng)態(tài)生成的每個(gè)文件對(duì)應(yīng)一個(gè)li元素結(jié)構(gòu)如下li>const dropZone document.getElementById(dropZone); const fileInput document.getElementById(fileInput); dropZone.addEventListener(dragover, (e) { e.preventDefault(); dropZone.classList.add(dragging); }); dropZone.addEventListener(dragleave, () { dropZone.classList.remove(dragging); }); dropZone.addEventListener(drop, (e) { e.preventDefault(); dropZone.classList.remove(dragging); const files e.dataTransfer.files; for (const file of files) { uploadFile(file); } }); dropZone.addEventListener(click, () { fileInput.click(); }); fileInput.addEventListener(change, () { const files fileInput.files; for (const file of files) { uploadFile(file); } fileInput.value ; });注意最后一行fileInput.value 如果不重置input的值用戶連續(xù)選擇同一個(gè)文件時(shí)change事件不會(huì)觸發(fā)這個(gè)坑我踩過(guò)一次。2.3 XHR上傳與進(jìn)度監(jiān)聽核心的上傳函數(shù)這樣寫function uploadFile(file) { const taskId generateTaskId(); const listItem createListItem(taskId, file); const formData new FormData(); formData.append(file, file); formData.append(task_id, taskId); const xhr new XMLHttpRequest(); xhr.open(POST, /upload, true); // 上傳進(jìn)度監(jiān)聽 xhr.upload.onprogress (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); updateProgress(taskId, percent, upload); } }; xhr.onload () { if (xhr.status 200) { // 上傳完成開始輪詢處理進(jìn)度 startPolling(taskId); } else { updateStatus(taskId, failed, xhr.statusText); } }; xhr.onerror () { updateStatus(taskId, failed, 網(wǎng)絡(luò)錯(cuò)誤); }; xhr.upload.onload () { updateStatus(taskId, processing, 處理中); }; xhr.send(formData); }xhr.upload.onprogress事件里拿到的是上傳階段的數(shù)據(jù)量這個(gè)進(jìn)度是從瀏覽器到服務(wù)器的傳輸進(jìn)度跟后端的處理進(jìn)度是兩碼事。當(dāng)xhr.upload.onload觸發(fā)時(shí)說(shuō)明文件已經(jīng)完整到達(dá)服務(wù)器這時(shí)候就應(yīng)該把狀態(tài)切成“處理中”然后通過(guò)輪詢獲取后端處理進(jìn)度。這里要提一個(gè)細(xì)節(jié)FormData里除了文件本身我還附帶了一個(gè)task_id字段。這個(gè)task_id是前端生成的用來(lái)把后續(xù)的進(jìn)度查詢請(qǐng)求和當(dāng)前上傳的這一個(gè)文件關(guān)聯(lián)起來(lái)。你也可以讓后端在響應(yīng)里返回一個(gè)任務(wù)ID但那樣就得多等一個(gè)請(qǐng)求而且處理起來(lái)稍微麻煩一點(diǎn)。2.4 進(jìn)度輪詢機(jī)制上傳完成后前端要定時(shí)去問(wèn)后端“這個(gè)任務(wù)處理到百分之多少了”這就是輪詢。我用setInterval實(shí)現(xiàn)每500毫秒查詢一次function startPolling(taskId) { const pollTimer setInterval(async () { const response await fetch(/progress/${taskId}); const data await response.json(); if (data.status done) { clearInterval(pollTimer); updateStatus(taskId, done, 已完成); updateProgress(taskId, 100, process); showSuccessMessage(taskId, data.result); } else if (data.status failed) { clearInterval(pollTimer); updateStatus(taskId, failed, data.error || 處理失敗); } else { const percent data.progress || 0; updateProgress(taskId, percent, process); updateStatus(taskId, processing, 處理中 ${percent}%); } }, 500); }輪詢邏輯不復(fù)雜但有幾個(gè)點(diǎn)要注意第一輪詢的終止條件要明確。狀態(tài)變成done或failed時(shí)必須clearInterval否則會(huì)一直請(qǐng)求下去浪費(fèi)資源。第二輪詢的狀態(tài)機(jī)要和前端的展示狀態(tài)保持一致。文件上傳周期里的狀態(tài)包括等待上傳、上傳中、處理中、已完成、失敗每一個(gè)狀態(tài)都要有對(duì)應(yīng)的UI表現(xiàn)不要出現(xiàn)“上傳完了狀態(tài)還卡在上傳中”的尷尬情況。第三輪詢間隔不要設(shè)得太短。500毫秒已經(jīng)算是比較激進(jìn)的頻率了如果服務(wù)器壓力大建議拉長(zhǎng)到1秒。實(shí)時(shí)進(jìn)度這玩意兒肉眼能區(qū)分的變化閾值大概也就是每秒幾個(gè)百分點(diǎn)太頻繁的輪詢反而加重服務(wù)器負(fù)擔(dān)。3. 后端核心多線程任務(wù)處理3.1 Flask接收文件與基礎(chǔ)保存后端最基礎(chǔ)的接口是接收上傳文件。這個(gè)邏輯本身不難import os import uuid import threading from flask import Flask, request, jsonify, render_template app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 500 * 1024 * 1024 # 500MB UPLOAD_FOLDER os.path.join(os.path.dirname(__file__), uploads) os.makedirs(UPLOAD_FOLDER, exist_okTrue) # 全局進(jìn)度字典保存所有任務(wù)的進(jìn)度信息 # 結(jié)構(gòu)task_id - {progress: int, status: str, error: str, result: dict} TASKS {} TASKS_LOCK threading.Lock() app.route(/) def index(): return render_template(index.html) app.route(/upload, methods[POST]) def upload(): if file not in request.files: return jsonify({error: 沒(méi)有文件}), 400 file request.files[file] task_id request.form.get(task_id, str(uuid.uuid4())) original_filename file.filename if original_filename : return jsonify({error: 文件名為空}), 400 # 安全處理文件名防止路徑穿越 safe_filename os.path.basename(original_filename) # 給文件加一個(gè)唯一前綴避免重名覆蓋 saved_filename f{task_id}_{safe_filename} file_path os.path.join(UPLOAD_FOLDER, saved_filename) # 先保存文件 file.save(file_path) # 注冊(cè)任務(wù)進(jìn)度 with TASKS_LOCK: TASKS[task_id] { progress: 0, status: processing, error: , result: {}, filename: safe_filename } # 啟動(dòng)后臺(tái)線程處理文件 thread threading.Thread( targetprocess_file, args(task_id, file_path, safe_filename), daemonTrue ) thread.start() return jsonify({task_id: task_id, message: 上傳成功開始處理})這里有幾個(gè)安全細(xì)節(jié)值得強(qiáng)調(diào)。文件名必須用os.path.basename處理否則用戶上傳一個(gè)路徑為../../etc/passwd的文件名直接拼接路徑就可能造成文件覆蓋或路徑穿越問(wèn)題。這是文件上傳里最常見也最嚴(yán)重的安全隱患之一。MAX_CONTENT_LENGTH限制了請(qǐng)求體大小超過(guò)這個(gè)限制Flask會(huì)直接報(bào)413錯(cuò)誤。這個(gè)限制可以根據(jù)業(yè)務(wù)需要調(diào)整但不建議取消。3.2 任務(wù)處理函數(shù)與多線程模型process_file是真正的業(yè)務(wù)處理函數(shù)模擬了一個(gè)耗時(shí)的處理流程。實(shí)際項(xiàng)目中這個(gè)函數(shù)可能是解析Excel、生成縮略圖、壓縮視頻等等。我用一個(gè)簡(jiǎn)單的循環(huán)來(lái)模擬方便展示進(jìn)度更新的模式def process_file(task_id, file_path, original_filename): 后臺(tái)處理文件模擬一個(gè)耗時(shí)的操作并實(shí)時(shí)更新進(jìn)度 try: # 模擬處理步驟 steps [ (讀取文件內(nèi)容, 20), (數(shù)據(jù)解析與校驗(yàn), 40), (業(yè)務(wù)邏輯處理, 70), (生成結(jié)果文件, 90), ] for step_name, target_progress in steps: # 模擬每一步的子過(guò)程 time.sleep(1.5) update_task_progress(task_id, target_progress) # 完成 with TASKS_LOCK: TASKS[task_id][progress] 100 TASKS[task_id][status] done TASKS[task_id][result] { output_file: fresult_{task_id}.csv, rows: 1024 } except Exception as e: with TASKS_LOCK: TASKS[task_id][status] failed TASKS[task_id][error] str(e)理解了流程之后應(yīng)該能get到這套多線程模型的核心思路Flask請(qǐng)求線程負(fù)責(zé)接收文件并立即返回自定義的worker線程負(fù)責(zé)耗時(shí)處理并周期性地更新一個(gè)共享字典。這樣用戶不用守著瀏覽器等一個(gè)超長(zhǎng)的HTTP響應(yīng)。為什么不用Flask的stream_with_context或生成器來(lái)做這個(gè)事因?yàn)槟莻€(gè)是服務(wù)端主動(dòng)推流還是要保持HTTP連接在多用戶場(chǎng)景下連接數(shù)很容易打滿。后臺(tái)線程輪詢的方式雖然看起來(lái)“土”但勝在簡(jiǎn)單可靠服務(wù)器資源占用也小。3.3 共享進(jìn)度字典的線程安全設(shè)計(jì)多線程操作共享數(shù)據(jù)最核心的問(wèn)題就是“競(jìng)態(tài)條件”。比如兩個(gè)線程同時(shí)讀取并修改TASKS[task_id][progress]就有可能出現(xiàn)數(shù)據(jù)不一致的情況。Python里保證線程安全最常用的方式就是threading.Lock。在上面的代碼里所有對(duì)TASKS字典的寫操作都通過(guò)with TASKS_LOCK:包裹了起來(lái)確保同一時(shí)間只有一個(gè)線程能修改共享數(shù)據(jù)。為什么用鎖而不是用queue.Queue因?yàn)檫M(jìn)度查詢接口需要“隨機(jī)讀取”任意任務(wù)ID的當(dāng)前進(jìn)度用隊(duì)列的話很難實(shí)現(xiàn)這種隨機(jī)訪問(wèn)。字典天然支持按鍵查找配合鎖做同步剛好滿足需求。鎖這個(gè)東西用的時(shí)候要注意粒度。鎖的范圍越小越好只包裹真正需要同步的操作不要在鎖里面做耗時(shí)的I/O操作否則會(huì)阻塞其他線程。在上面的代碼里文件處理本身的耗時(shí)邏輯time.sleep和業(yè)務(wù)處理都在鎖外面只有更新進(jìn)度那一下才進(jìn)鎖這個(gè)設(shè)計(jì)是合理的。3.4 進(jìn)度查詢接口進(jìn)度查詢接口很簡(jiǎn)單就是查字典然后返回JSONapp.route(/progress/task_id) def get_progress(task_id): with TASKS_LOCK: task TASKS.get(task_id) if task is None: return jsonify({status: notfound, error: 任務(wù)不存在}), 404 return jsonify({ status: task[status], progress: task[progress], error: task[error], result: task[result] })這個(gè)接口就是給前端輪詢用的。返回的JSON結(jié)構(gòu)固定前端拿到之后就能更新UI。關(guān)于進(jìn)度字典的清理我單獨(dú)說(shuō)一下。如果不做清理TASKS字典會(huì)無(wú)限膨脹跑幾天就積累幾千上萬(wàn)條記錄內(nèi)存遲早吃不消。解決方案是定期清理已完成或失敗的任務(wù)。我用了一個(gè)簡(jiǎn)單的方式在查詢接口里如果任務(wù)狀態(tài)是done或failed且創(chuàng)建時(shí)間超過(guò)一定閾值就順手刪掉。更系統(tǒng)的做法是單獨(dú)起一個(gè)定時(shí)清理線程或者用帶過(guò)期時(shí)間的緩存比如cachetools的TTLCache。這個(gè)根據(jù)自己的需求來(lái)。4. 調(diào)試模式下的神秘問(wèn)題4.1 為什么進(jìn)度條會(huì)“抽風(fēng)”我在開發(fā)過(guò)程中遇到過(guò)幾個(gè)特別詭異的現(xiàn)象進(jìn)度條跳到一半突然跳回0或者后臺(tái)線程明明執(zhí)行完了但進(jìn)度死活停在90%再或者一個(gè)文件上傳后出現(xiàn)了兩條進(jìn)度記錄。排查了很久才發(fā)現(xiàn)根因是Flask的調(diào)試模式。Flask在debugTrue時(shí)Werkzeug服務(wù)器會(huì)自動(dòng)啟動(dòng)一個(gè)reloader來(lái)監(jiān)聽代碼變化代碼一變就自動(dòng)重啟服務(wù)。關(guān)鍵問(wèn)題在于reloader會(huì)啟動(dòng)兩個(gè)進(jìn)程一個(gè)是監(jiān)控進(jìn)程一個(gè)是實(shí)際運(yùn)行的應(yīng)用進(jìn)程。如果你直接運(yùn)行app.py代碼里的threading.Thread會(huì)在兩個(gè)進(jìn)程里都啟動(dòng)一遍然后兩個(gè)進(jìn)程各自維護(hù)一份TASKS字典互不共享進(jìn)度數(shù)據(jù)自然就亂了。更麻煩的是你的請(qǐng)求可能被負(fù)載均衡到兩個(gè)進(jìn)程里的任何一個(gè)處理導(dǎo)致上傳請(qǐng)求進(jìn)的是進(jìn)程A進(jìn)度查詢被進(jìn)程B處理了查詢結(jié)果永遠(yuǎn)是“任務(wù)不存在”。4.2 解決方案最直接的解決方案日常開發(fā)調(diào)試時(shí)用debugFalse。如果你確實(shí)需要自動(dòng)重載功能可以用use_reloaderFalseapp.run(debugTrue, use_reloaderFalse)這樣既能保留debug模式的錯(cuò)誤頁(yè)面和交互式調(diào)試器又不會(huì)啟動(dòng)雙進(jìn)程。不過(guò)我還是建議開發(fā)階段就使用debugFalse加手動(dòng)重啟的方式避免產(chǎn)生依賴reloader的壞習(xí)慣也省得哪天忘記關(guān)閉導(dǎo)致生產(chǎn)環(huán)境出問(wèn)題。如果項(xiàng)目真的部署到了生產(chǎn)環(huán)境千萬(wàn)不要用Flask自帶的開發(fā)服務(wù)器用gunicorn或uWSGI這類正式的WSGI服務(wù)器它們對(duì)多進(jìn)程、多線程的管理要成熟得多。只不過(guò)生產(chǎn)環(huán)境的進(jìn)度共享方案就得升級(jí)了因?yàn)槎噙M(jìn)程之間內(nèi)存不共享TASKS字典得換成Redis之類的外部存儲(chǔ)才行。5. 完整代碼與實(shí)操調(diào)優(yōu)5.1 后端完整代碼把上面的幾個(gè)片段拼起來(lái)完整的后端代碼如下import os import time import uuid import threading from flask import Flask, request, jsonify, render_template app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 500 * 1024 * 1024 UPLOAD_FOLDER os.path.join(os.path.dirname(__file__), uploads) os.makedirs(UPLOAD_FOLDER, exist_okTrue) TASKS {} TASKS_LOCK threading.Lock() def update_task_progress(task_id, progress): with TASKS_LOCK: TASKS[task_id][progress] progress def process_file(task_id, file_path, original_filename): try: steps [ (讀取文件內(nèi)容, 20), (數(shù)據(jù)解析與校驗(yàn), 40), (業(yè)務(wù)邏輯處理, 70), (生成結(jié)果文件, 90), ] for step_name, target_progress in steps: time.sleep(1.5) update_task_progress(task_id, target_progress) with TASKS_LOCK: TASKS[task_id][progress] 100 TASKS[task_id][status] done TASKS[task_id][result] { output_file: fresult_{task_id}.csv, rows: 1024 } except Exception as e: with TASKS_LOCK: TASKS[task_id][status] failed TASKS[task_id][error] str(e) app.route(/) def index(): return render_template(index.html) app.route(/upload, methods[POST]) def upload(): if file not in request.files: return jsonify({error: 沒(méi)有文件}), 400 file request.files[file] task_id request.form.get(task_id, str(uuid.uuid4())) original_filename file.filename if original_filename : return jsonify({error: 文件名為空}), 400 safe_filename os.path.basename(original_filename) saved_filename f{task_id}_{safe_filename} file_path os.path.join(UPLOAD_FOLDER, saved_filename) file.save(file_path) with TASKS_LOCK: TASKS[task_id] { progress: 0, status: processing, error: , result: {}, filename: safe_filename } thread threading.Thread( targetprocess_file, args(task_id, file_path, safe_filename), daemonTrue ) thread.start() return jsonify({task_id: task_id, message: 上傳成功開始處理}) app.route(/progress/task_id) def get_progress(task_id): with TASKS_LOCK: task TASKS.get(task_id) if task is None: return jsonify({status: notfound, error: 任務(wù)不存在}), 404 return jsonify({ status: task[status], progress: task[progress], error: task[error], result: task[result] }) if __name__ __main__: app.run(debugTrue, use_reloaderFalse, host0.0.0.0, port5000)5.2 前端完整代碼前端把HTML、CSS、JavaScript放在templates目錄下的index.html里。我這里只貼核心JS部分樣式和網(wǎng)頁(yè)骨架可以根據(jù)自己的審美來(lái)let taskCounter 0; function generateTaskId() { taskCounter; return task_ Date.now() _ taskCounter; } function formatSize(size) { if (size 1024) return size B; if (size 1024 * 1024) return (size / 1024).toFixed(1) KB; if (size 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) MB; return (size / (1024 * 1024 * 1024)).toFixed(1) GB; } function createListItem(taskId, file) { const list document.getElementById(uploadList); const li document.createElement(li); li.dataset.taskId taskId; li.innerHTML div classfile-info span classfile-name${escapeHtml(file.name)}/span span classfile-size${formatSize(file.size)}/span /div div classprogress-wrapper div classprogress-bar/div /div span classfile-status等待上傳/span button classbtn-cancel取消/button ; list.appendChild(li); const cancelBtn li.querySelector(.btn-cancel); cancelBtn.addEventListener(click, () { // 取消邏輯可以根據(jù)需要實(shí)現(xiàn) li.remove(); }); return li; } function escapeHtml(str) { const div document.createElement(div); div.appendChild(document.createTextNode(str)); return div.innerHTML; } function updateProgress(taskId, percent, type) { const li document.querySelector(li[data-task-id${taskId}]); if (!li) return; const bar li.querySelector(.progress-bar); bar.style.width percent %; } function updateStatus(taskId, status, text) { const li document.querySelector(li[data-task-id${taskId}]); if (!li) return; const statusEl li.querySelector(.file-status); statusEl.textContent text; li.className status; }前端有個(gè)小細(xì)節(jié)要提一下escapeHtml函數(shù)很重要。用戶上傳的文件名可能包含script之類的HTML標(biāo)簽如果直接拼接進(jìn)innerHTML就存在XSS注入的風(fēng)險(xiǎn)。這個(gè)風(fēng)險(xiǎn)在文件上傳場(chǎng)景里特別容易被忽視。用escapeHtml先把文件名里的HTML字符轉(zhuǎn)義掉再拼進(jìn)DOM就安全了。5.3 啟動(dòng)與測(cè)試啟動(dòng)服務(wù)python app.py然后打開瀏覽器訪問(wèn)http://localhost:5000選擇一個(gè)大文件上傳最好選一個(gè)幾百M(fèi)B的文件這樣上傳進(jìn)度條變化比較明顯觀察上傳完成后的處理階段進(jìn)度是否符合預(yù)期。我這里用一個(gè)小技巧來(lái)方便測(cè)試開一個(gè)幾百M(fèi)B的虛擬文件# Linux / macOS dd if/dev/zero oftest_file.bin bs1M count200 # WindowsPowerShell fsutil file createnew test_file.bin 2097152006. 常見問(wèn)題與排查技巧6.1 上傳大文件超時(shí)現(xiàn)象上傳一個(gè)很大的文件請(qǐng)求在傳輸中途斷開或者頁(yè)面一直轉(zhuǎn)圈最終報(bào)錯(cuò)??赡茉蚝徒鉀Q辦法第一Nginx或反向代理的client_max_body_size限制不夠大默認(rèn)一般是1MB。需要在Nginx配置里加上client_max_body_size 500m;。第二Flask的MAX_CONTENT_LENGTH設(shè)置得不夠大請(qǐng)求體超過(guò)限制時(shí)被拒。這個(gè)在上面已經(jīng)說(shuō)過(guò)了。第三請(qǐng)求超時(shí)時(shí)間太短。如果是通過(guò)Nginx代理的proxy_read_timeout和proxy_send_timeout也要相應(yīng)調(diào)大。6.2 前端進(jìn)度條亂跳現(xiàn)象進(jìn)度條已經(jīng)到90%突然跳回10%然后再慢慢爬到100%。原因大概率是開啟了Flask的debug模式reloader雙進(jìn)程導(dǎo)致請(qǐng)求被打到不同的進(jìn)程上兩個(gè)進(jìn)程各自維護(hù)了一份進(jìn)度數(shù)據(jù)。最簡(jiǎn)單有效的解決辦法就是開發(fā)時(shí)用use_reloaderFalse生產(chǎn)環(huán)境用gunicorn或uWSGI并且把共享進(jìn)度存儲(chǔ)替換成Redis。6.3 Thread線程里拋異常進(jìn)度卡住不動(dòng)現(xiàn)象后臺(tái)線程在處理文件時(shí)報(bào)錯(cuò)了但前端進(jìn)度條永遠(yuǎn)停在最后更新的那個(gè)百分比狀態(tài)也不是failed。原因后臺(tái)線程的process_file函數(shù)里沒(méi)有做異常捕獲線程直接崩潰退出TASKS[task_id][status]一直停留在processing。解決辦法process_file函數(shù)一定要在最外層包一個(gè)try...except在except里把任務(wù)狀態(tài)更新為failed并記錄錯(cuò)誤信息。我上面的代碼已經(jīng)做了這個(gè)處理但實(shí)際項(xiàng)目里還是要根據(jù)業(yè)務(wù)需求細(xì)化異常分支。6.4 多線程并發(fā)更新進(jìn)度數(shù)據(jù)錯(cuò)亂現(xiàn)象多個(gè)文件同時(shí)上傳A文件的進(jìn)度條偶爾會(huì)顯示B文件的數(shù)據(jù)。原因所有線程共享一個(gè)TASKS字典如果更新操作沒(méi)有加鎖保護(hù)兩個(gè)線程同時(shí)寫同一個(gè)鍵值時(shí)就可能互相覆蓋。解決辦法對(duì)TASKS字典的所有讀寫操作加鎖。如果并發(fā)量非常大鎖的競(jìng)爭(zhēng)會(huì)很激烈這時(shí)候可以把進(jìn)度字典按task_id分片用多個(gè)鎖減少?zèng)_突。6.5 上傳文件保存路徑不對(duì)現(xiàn)象文件上傳成功后后端找不到保存的文件或者文件保存在了一個(gè)莫名其妙的位置。原因相對(duì)路徑問(wèn)題。開發(fā)時(shí)用相對(duì)路徑uploads/但當(dāng)前工作目錄可能不是你項(xiàng)目所在的目錄尤其當(dāng)你在IDE里或通過(guò)其他工具啟動(dòng)項(xiàng)目時(shí)工作目錄往往是設(shè)置文件所在目錄而不是項(xiàng)目根目錄。解決辦法路徑用絕對(duì)路徑基于__file__來(lái)拼接UPLOAD_FOLDER os.path.join(os.path.dirname(os.path.abspath(__file__)), uploads)6.6 前端XHR CORS跨域問(wèn)題現(xiàn)象前端頁(yè)面在一個(gè)域名后端接口在另一個(gè)域名上傳時(shí)瀏覽器報(bào)CORS錯(cuò)誤。解決辦法開發(fā)時(shí)用Flask-CORS擴(kuò)展或者手動(dòng)加響應(yīng)頭app.after_request def add_cors_headers(response): response.headers[Access-Control-Allow-Origin] * response.headers[Access-Control-Allow-Headers] Content-Type response.headers[Access-Control-Allow-Methods] POST, GET, OPTIONS return response生產(chǎn)環(huán)境更推薦用Nginx做同域反向代理從根上規(guī)避CORS問(wèn)題。6.7 上傳過(guò)程中文件數(shù)過(guò)多導(dǎo)致列表性能問(wèn)題現(xiàn)象一次上傳幾十上百個(gè)文件DOM里插入大量li元素頁(yè)面滾動(dòng)和操作明顯卡頓。解決辦法如果單次上傳的文件數(shù)很多建議做分頁(yè)展示或者虛擬滾動(dòng)只渲染當(dāng)前可視區(qū)域內(nèi)的文件項(xiàng)。如果上傳量不大用原生DOM操作已經(jīng)夠用不必上框架。7. 實(shí)際體驗(yàn)與進(jìn)一步優(yōu)化方向這套方案我現(xiàn)在已經(jīng)用在幾個(gè)內(nèi)部工具上了整體穩(wěn)定性不錯(cuò)日常使用沒(méi)有出過(guò)什么幺蛾子。但如果你想讓它在更嚴(yán)苛的環(huán)境下跑還有幾個(gè)方向可以擴(kuò)展。支持?jǐn)帱c(diǎn)續(xù)傳。當(dāng)前方案里上傳中斷后就得重新傳整個(gè)文件對(duì)大文件來(lái)說(shuō)成本太高。斷點(diǎn)續(xù)傳的標(biāo)準(zhǔn)做法是前端先把文件切片然后分片上傳后端記錄每個(gè)分片的上傳狀態(tài)最后合并。這會(huì)讓代碼量翻倍但體驗(yàn)提升也是明顯的。用Redis替換內(nèi)存字典做進(jìn)度共享。當(dāng)部署方式從單進(jìn)程變成多進(jìn)程或多機(jī)負(fù)載均衡后內(nèi)存字典的共享方案就失效了。用Redis的話各進(jìn)程都能讀寫同一個(gè)進(jìn)度存儲(chǔ)還能天然地處理過(guò)期清理算是一步到位的方案。支持取消任務(wù)。目前取消按鈕只是把前端的列表項(xiàng)刪了后端線程還在后臺(tái)跑著。真要實(shí)現(xiàn)取消需要一個(gè)threading.Event之類的信號(hào)機(jī)制讓后臺(tái)線程在處理過(guò)程中定期檢查這個(gè)信號(hào)收到取消通知就提前退出。線程本身不能被強(qiáng)制kill只能靠協(xié)作式取消。增加文件類型校驗(yàn)和病毒掃描。文件上傳是安全重災(zāi)區(qū)建議在保存文件前做MIME類型校驗(yàn)、擴(kuò)展名白名單校驗(yàn)再結(jié)合內(nèi)容檢測(cè)工具做進(jìn)一步掃描。這部分對(duì)內(nèi)部工具可能不是必需但只要是面向外部用戶的服務(wù)一定要重視。最后說(shuō)一個(gè)我個(gè)人的小體會(huì)文件上傳這功能看著簡(jiǎn)單一旦跟多線程、進(jìn)度反饋、并發(fā)這些詞沾上邊復(fù)雜度就指數(shù)上升了。好在這套“前端XHR 后端線程 內(nèi)存字典 輪詢”的組合幾乎覆蓋了所有內(nèi)部工具類場(chǎng)景代碼又少又直觀出問(wèn)題也好排查。如果你正在做類似的功能直接照這個(gè)思路來(lái)就行后面根據(jù)自己的業(yè)務(wù)往里填處理邏輯會(huì)比從零開始捋清思路快很多。