LiteHub之文件下载与视频播放
文件下载
前端请求
箭头函数
//这个箭头函数可以形象理解为,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是不可行的)
可以看到这里返回的是文件的最大大小。