LiteHub之文件下载与视频播放
Qingyh Lv2

文件下载

前端请求

箭头函数

1
2
3
4
5
6
7
8
9
10
11
//这个箭头函数可以形象理解为,x流入(=>)x*x,
//自然而然=>前面的就是传入参数,=>表示函数体
x => x * x

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
 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())。

后端响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 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头文件中

1
2
3
4
5
6
7
8
9
10
11
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函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 给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连接,包括发送消息和接收消息等。

视频播放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
  // 从请求中获取 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字段请求内容决定是返回部分内容还是全部内容。
请求所有内容:
image
image
image
依次拖动播放进度条,range字段发生改变,格式为字段,这里是请求从某一时刻到视频结束。
请求部分内容:
image
这里请求的是从字节6000-18000大小的数据,返回的响应为
image
这里的响应头字段为206 partial content,表示响应返回的只是视频的一部分数据。


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

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 31k