文件下載
前端請求
箭頭函數
//這個箭頭函數可以形象理解為,x流入(=>)x*x,
//自然而然=>前面的就是傳入參數,=>表示函數體
x => x * x//相當于
function (x) {return x * x;
}//如果參數不是一個,就需要用括號()括起來:
(x, y) => x * x + y * y
本項目的請求下載前端代碼為:
function downloadFile(resourceId, filename, progressBar, statusText) {fetch('/resource/download', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ resourceId }) //通過post方式將要下載的文件路徑發送給后端}).then(response => {if (!response.ok) {throw new Error('下載失敗');}const contentLength = response.headers.get('Content-Length');const total = contentLength ? parseInt(contentLength, 10) : 0;//返回內容長度const reader = response.body.getReader(); //這個可以逐塊提供bodyconst chunks = [];let received = 0;const pump = () => reader.read().then(({ done, value }) => {if (done) {//如果讀取完成,整個文件已下載const blob = new Blob(chunks);//將所有小段chunks轉換成一個完成的blob(binary large object)const url = window.URL.createObjectURL(blob);//瀏覽器創建一個臨時的URL地址來獲取這個數據//如blob:http://localhost/17dfc4b1-df34-4a93-a6a7-6df9f1e85e0cconst a = document.createElement('a');a.href = url;a.download = filename;document.body.appendChild(a);a.click();//模擬點擊瀏覽器的下載行為document.body.removeChild(a);window.URL.revokeObjectURL(url);//避免內存泄露progressBar.style.width = '100%';statusText.textContent = '下載完成';return;}chunks.push(value);received += value.length;//更新下載進度if (total > 0) {const percent = Math.floor((received / total) * 100);progressBar.style.width = percent + '%';progressBar.textContent = percent + '%';statusText.textContent = `下載中 ${percent}%`;} else {statusText.textContent = `下載中(未知大小)`;}//遞歸調用 pump(繼續讀取下一段)return pump();});return pump();}).catch(error => {console.error('下載出錯:', error);progressBar.style.backgroundColor = 'red';statusText.textContent = '下載失敗';});}//類比
// 后端:用水龍頭一點點把水流出來
// 前端:接水并灌到瓶子里(Blob)
// createObjectURL:給這瓶水貼個標簽(blob URL)
// 點擊下載:把瓶子交給你下載
// revokeObjectURL:把標簽撕掉,清理內存
對于pump
函數的理解,結合箭頭函數和promise
- reader.read()
○ 返回一個 Promise<{ done: boolean, value: Uint8Array }>。
○ done: true 表示讀取完了;
○ value 是當前讀取的一段數據(Uint8Array 格式)。 - 箭頭函數 () => reader.read().then(…)
○ 這是一個返回 Promise 的函數。
○ done: true 表示讀取完了;
○ value 是當前讀取的一段數據(Uint8Array 格式)。 - 箭頭函數 () => reader.read().then(({ done, value }) => { return dump()}
■ ()=>reader.read(),無參數傳入,執行reader.read(),返回reader.read()執行的結果{done,value}。
■ .then({ done, value })通過上一步接收這兩個數據,然后通過這兩個執行相應內容;
■ 如果done為false,表示還沒執行完成,chunks.push(value):把這一段加入緩存 ,更新進度條, 遞歸調用自身,繼續下一段讀取 (return pump())。
后端響應
FileUtil file(filePath);
if (!file.isValid()) //判斷請求的文件是否有效
{LOG_WARN << filePath << "not exist.";resp->setStatusLine(req.getVersion(), http::HttpResponse::k404NotFound, "Not Found");resp->setContentType("text/plain");std::string resp_info="File not found";resp->setContentLength(resp_info.size());resp->setBody(resp_info);
}
//設置相應頭
resp->setStatusLine(req.getVersion(), http::HttpResponse::k200Ok, "OK");
resp->setCloseConnection(false);
resp->setContentType("application/octet-stream");std::string filename = std::filesystem::path(filePath).filename().string();
LOG_INFO<<"filename:"<<filename;
resp->addHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
//設置響應格式為文件類型,并添加文件的路徑
resp->setContentLength(file.size());
resp->setisFileResponse(filePath);
設計亮點
在HttpResponse.h
頭文件中
public:bool isFileResponse() const {return isFileResponse_;}std::string getFilePath() {return filePath_;}void setisFileResponse(const std::string& path){isFileResponse_ = true;filePath_ = path;}
private:
bool isFileResponse_; //判斷是否是文件,如果是,采用流式發送
std::string filePath_;
在httpserver的請求函數中判斷,如果是文件類型,就調用tcpconnection先將響應頭發送出去,然后將消息體分小塊發送,這里設置的是8kb;如果不是文件類型,直接將整個響應發送出去
在HttpServer::onRequest
函數中
// 給response設置一個成員,判斷是否請求的是文件,如果是文件設置為true,并且存在文件位置在這里send出去。
if (!response.isFileResponse())
{ //不是文件類型muduo::net::Buffer buf;response.appendToBuffer(&buf);conn->send(&buf);
}
else
{// 1. 構造響應頭muduo::net::Buffer headerBuf;response.appendToBuffer(&headerBuf); // 只添加狀態行和頭部,不包含 bodyconn->send(&headerBuf); // 先發 header// 2. 發送文件內容(分塊)const std::string filePath = response.getFilePath();std::ifstream file(filePath, std::ios::binary);// 以二進制模式打開文件if (file) {const size_t bufferSize = 8192; // 8KB 緩沖區char buffer[bufferSize]; // 棧上分配緩沖區while (file) { // 循環直到文件讀取結束或出錯file.read(buffer, bufferSize); // 讀取最多 bufferSize 字節到 bufferstd::streamsize bytesRead = file.gcount(); // 實際讀取的字節數if (bytesRead > 0) {conn->send(muduo::StringPiece(buffer, bytesRead));// 發送數據塊}}} else {// 文件打不開,補償錯誤提示muduo::net::Buffer errBuf;errBuf.append("HTTP/1.1 500 Internal Server Error\r\n\r\nFile open failed");conn->send(&errBuf);}
}
之所以是在httpserver上分塊發送數據流,是為了保證代碼較好的層次性,httpserver負責管理多個tcp連接,包括發送消息和接收消息等。
視頻播放
// 從請求中獲取 Range 頭,例如 "bytes=1000-2000"std::string rangeHeader = req.getHeader("Range");LOG_INFO << "Range Header: " << rangeHeader;// 默認起始字節 start=0,結束字節 end=文件大小-1,表示完整文件std::streamsize start = 0, end = fileSize - 1;// 標記是否是分塊響應bool isPartial = false;if (!rangeHeader.empty()) {// 如果客戶端帶了 Range,則標記為分塊傳輸isPartial = true;long s = 0, e = -1;// 使用 sscanf 解析格式 bytes=<start>-<end>// 注意:用戶可能只寫了起始,沒有寫結束,所以要判斷 sscanf 返回值int n = sscanf(rangeHeader.c_str(), "bytes=%ld-%ld", &s, &e);start = s;if (n == 1 || e == -1) {// 如果只解析到 1 個數,或者結束為 -1,則表示讀到文件末尾end = fileSize - 1;} else {// 解析到兩個數,且結束不能超過文件大小end = std::min((std::streamsize)e, fileSize - 1);}// 合法性檢查:start 必須小于等于 end 且小于文件大小if (start > end || start >= fileSize) {// 如果不合法,返回 416 狀態碼(Requested Range Not Satisfiable)resp->setStatusLine(req.getVersion(), http::HttpResponse::k416RequestedRangeNotSatisfiable, "Requested Range Not Satisfiable");char rangeValue[64];// Content-Range 必須帶 "*/總大小"snprintf(rangeValue, sizeof(rangeValue), "bytes */%ld", fileSize);resp->addHeader("Content-Range", rangeValue);resp->setCloseConnection(true);resp->setContentType("text/plain");resp->setBody("Invalid Range");return;}}// 計算需要讀取的 chunkSizestd::streamsize chunkSize = end - start + 1;std::vector<char> buffer(chunkSize);// 如果需要分塊,最好這里限制一下 chunkSize,防止內存過大// 定位到要讀的起始位置file.seekg(start, std::ios::beg);// 從文件讀出 chunkSize 大小的數據到 bufferfile.read(buffer.data(), chunkSize);// === 構造響應 ===if (isPartial) {resp->setStatusLine(req.getVersion(), http::HttpResponse::k206PartialContent, "Partial Content");char rangeHeaderValue[128];snprintf(rangeHeaderValue, sizeof(rangeHeaderValue),"bytes %ld-%ld/%ld", start, end, fileSize);resp->addHeader("Content-Range", rangeHeaderValue);} else {resp->setStatusLine(req.getVersion(), http::HttpResponse::k200Ok, "OK");}resp->addHeader("Accept-Ranges", "bytes");// 無論是否分塊,都要告知支持分塊resp->setContentType("video/mp4"); // 設置內容類型為 mp4 視頻resp->setContentLength(buffer.size()); // 設置 Content-Lengthresp->setBody(std::string(buffer.begin(), buffer.end())); // 把讀取的文件塊設置到響應體}
后端涉及對請求體中的range字段進行解析,判斷range字段的合法性,隨后根據range字段請求內容決定是返回部分內容還是全部內容。
請求所有內容:
依次拖動播放進度條,range字段發生改變,格式為–字段,這里是請求從某一時刻到視頻結束。
請求部分內容:
這里請求的是從字節6000-18000大小的數據,返回的響應為
這里的響應頭字段為206 partial content
,表示響應返回的只是視頻的一部分數據。
range的合法性校驗
這里我手動指定range的范圍為6000-18000000000000
,實際是超出了請求視頻的最大范圍,看看最后返回的什么。使用curl(這里因為是測試,所以去掉了權限的判定,實際上運行的時候使用curl是不可行的)
可以看到這里返回的是文件的最大大小。