大文件上传如何做断点续传 - 后端篇

核心思想

  1. 接收前端传输的分片
  2. 根据传输的 md5 值来确认是否已上传
  3. 如果未上传完成,前端将继续传输分片
  4. 合并传输完成的分片
1
2
3
4
5
6
7
8
9
10
11
# 你的目录工作时,看起来是这样
├── imgs
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_10240
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_20480
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_30720
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_40960
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_51200
│ ├── 487f7b22f68312d2c1bbc93b1aea445b_61440
│ └── 487f7b22f68312d2c1bbc93b1aea445b_71680
├── index.html
└── index.js

代码实例

下面可直接复制运行, 需要注意地方较多,请仔细阅读
在实际应用上,还需改造。。。用到了许多原生 nodejs 方法,其中一些可用 npm 库代替

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// node -v v18.5.0
const http = require("http");
const fs = require("fs");
const url = require("url");
// 存储分片文件夹
const tempDir = "./imgs";

// buffer切割方法
Buffer.prototype.split = function (sep) {
let sepLength = sep.length,
arr = [],
offset = 0,
currentIndex = 0;
while ((currentIndex = this.indexOf(sep, offset)) !== -1) {
arr.push(this.slice(offset, currentIndex));
offset = currentIndex + sepLength;
}
arr.push(this.slice(offset));
return arr;
};
// 解析 formData 方法
function handlePart(part) {
const [head, body] = part.split("\r\n\r\n"); // buffer 分割
const headStr = head.toString();
const key = headStr.match(/name="(.+?)"/)[1];
const match = headStr.match(/filename="(.+?)"/);
if (!match) {
const value = body.toString().slice(0, -2); // 把末尾的 \r\n 去掉
return { key, value };
}
const filename = match[1];
return { key, value: filename === "blob" ? body : filename };
}
// 读取文件,写文件
const pipeStream = (path, writeStream) =>
new Promise((resolve) => {
const readStream = fs.createReadStream(path);
readStream.on("end", () => {
fs.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
// 创建http服务
const server = http.createServer((req, res) => {
// 处理 url.query
const uri = url.parse(req.url, true);

// 返回目录下的 index.html
if (uri.pathname === "/" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "text-plain" });
res.end(fs.readFileSync("./index.html"));
return;
}
// 查看是否已经上传
if (uri.pathname === "/check_uploaded" && req.method === "GET") {
if (!uri.query.md5code) {
res.end("没有md5code");
return;
}
//TODO: 应该对应 MD5 存入一个 Map 类型的数据在服务中,对应是否 boolean,如果走了 merge,就为true,判断是否上传完成,这里只做粗略判断
const hasSplitFile = fs
.readdirSync(tempDir)
.some((f) => f.includes(uri.query.md5code));
res.end(
JSON.stringify(
hasSplitFile
? { code: 200, data: "还未上传完成" }
: { code: 404, data: "该文件已上传完成" }
)
);
return;
}
// 上传/存储分片
if (uri.pathname === "/big_files" && req.method === "POST") {
let currentBuffer;

req.on("data", (data) => {
// 如果传输的是 formData 会传输过来 Buffer 类型,传输的 json 就是 字符串
currentBuffer = data;
});
req.on("end", () => {
// 下面获取formdata value的部分,可以用 multiparty 这个库,这里应用原生方法展示
// 获取 contentType: string
const contentType = req.headers["content-type"];
// 截取 header 里面的 boundary 部分 : --WebKitFormBoundarycBh5zHV8PGTFb5LA
const headBoundary = contentType.slice(contentType.lastIndexOf("=") + 1);
// 前面加两个 - 才是 body 里面真实的分隔符
const bodyBoundary = `--${headBoundary}`;

const obj = {};
// 转化formdata 涉及到 buffer 分割
const parts = Buffer.concat([currentBuffer])
.split(bodyBoundary)
.slice(1, -1);
for (let i = 0; i < parts.length; i++) {
// formData key, formData value
const { key, value } = handlePart(parts[i]);
obj[key] = value;
}

const exist = fs.existsSync(tempDir);
if (!exist) {
fs.mkdirSync(tempDir);
}
// 开始写文件
fs.writeFileSync(`${tempDir}/${obj.hash}`, obj.file);

res.end("ok");
});
return;
}
// 合并文件
if (uri.pathname === "/big_files_merge" && req.method === "POST") {
// 每片的大小, 与前端保持一致
const size = 10 * 1024;
// @type {filename: string; hashname: string}
const postData = {};
req.on("data", (data) => {
Object.assign(postData, JSON.parse(data.toString()));
});
req.on("end", () => {
const files = fs.readdirSync(tempDir);
// 排序,必须按顺序读取
const fileGroup = files
.filter((filename) => filename.includes(postData.hashname))
.sort((a, b) => a.split(`_`)[1] - b.split(`_`)[1]);
const streams = fileGroup.map((filename, index) =>
pipeStream(
`${tempDir}/${filename}`,
// 写入同一个文件
fs.createWriteStream(`./${postData.filename}`, {
start: index * size,
})
)
);
// 并发,读取文件后返回
Promise.all(streams).then(() => {
fs.rmdirSync(tempDir);
fs.readFile(postData.filename, (err, buffer) => {
if (err) return;
res.end(buffer);
});
});
});
return;
}
});
// 启动
server.listen(5500);
作者

Huasun47

发布于

2020-04-22

更新于

2020-04-22

许可协议