LiteHub之文件下载与视频播放

文件下载

前端请求

箭头函数

javascript 复制代码
//这个箭头函数可以形象理解为,x流入(=>)x*x,
//自然而然=>前面的就是传入参数,=>表示函数体
x => x * x

//相当于
function (x) {
    return x * x;
}

//如果参数不是一个,就需要用括号()括起来:
(x, y) => x * x + y * y

本项目的请求下载前端代码为:

javascript 复制代码
 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(); //这个可以逐块提供body
            const 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-6df9f1e85e0c
                const 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

  1. reader.read()
    ○ 返回一个 Promise<{ done: boolean, value: Uint8Array }>。
    ○ done: true 表示读取完了;
    ○ value 是当前读取的一段数据(Uint8Array 格式)。
  2. 箭头函数 () => reader.read().then(...)
    ○ 这是一个返回 Promise 的函数。
    ○ done: true 表示读取完了;
    ○ value 是当前读取的一段数据(Uint8Array 格式)。
  3. 箭头函数 () => 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())。

后端响应

cpp 复制代码
 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头文件中

cpp 复制代码
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函数中

cpp 复制代码
// 给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);  // 只添加状态行和头部,不包含 body
    conn->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 字节到 buffer
            std::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连接,包括发送消息和接收消息等。

视频播放

cpp 复制代码
      // 从请求中获取 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;
            }
        }
        // 计算需要读取的 chunkSize
        std::streamsize chunkSize = end - start + 1;
        std::vector<char> buffer(chunkSize);

        // 如果需要分块,最好这里限制一下 chunkSize,防止内存过大

        // 定位到要读的起始位置
        file.seekg(start, std::ios::beg);
        // 从文件读出 chunkSize 大小的数据到 buffer
        file.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-Length
        resp->setBody(std::string(buffer.begin(), buffer.end()));  // 把读取的文件块设置到响应体
    }

后端涉及对请求体中的range字段进行解析,判断range字段的合法性,随后根据range字段请求内容决定是返回部分内容还是全部内容。
请求所有内容:


依次拖动播放进度条,range字段发生改变,格式为--字段,这里是请求从某一时刻到视频结束。
请求部分内容:

这里请求的是从字节6000-18000大小的数据,返回的响应为

这里的响应头字段为206 partial content,表示响应返回的只是视频的一部分数据。


range的合法性校验

这里我手动指定range的范围为6000-18000000000000,实际是超出了请求视频的最大范围,看看最后返回的什么。使用curl(这里因为是测试,所以去掉了权限的判定,实际上运行的时候使用curl是不可行的)

可以看到这里返回的是文件的最大大小。