发布于 

【Node专栏】上传文件

一、简介

平常项目经常会遇到需要做文件上传的需求,这边记录几个常见的场景。

二、普通文件上传

在 Node.js 中进行文件上传,通常可以使用以下两种方式:

1、使用 Node.js 内置的 httphttps 模块来创建 HTTP(s) 服务器,然后使用 formidablemulter 等中间件来处理文件上传。
2、使用 Express 或 Koa 等 Node.js Web 框架来处理文件上传。

2.1 方法一 原生+formidable中间件

以下是一个使用 formidable 中间件进行文件上传的示例代码:

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
const http = require('http');
const formidable = require('formidable');

const server = http.createServer((req, res) => {
if (req.method === 'POST') {
const form = formidable({ multiples: true });

form.parse(req, (err, fields, files) => {
if (err) {
console.error(err);
res.statusCode = 500;
res.end('Error');
return;
}

console.log(files);
res.statusCode = 200;
res.end('File uploaded');
});

return;
}

res.statusCode = 404;
res.end('Not found');
});

server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});

在这个示例中,我们使用 http 模块创建了一个 HTTP 服务器,并使用 formidable 中间件来处理文件上传。当客户端通过 POST 方法提交一个包含文件的表单时,formidable 会将文件保存到临时文件夹中,并在解析完成后调用回调函数,将解析结果传递给回调函数。

2.2 方式二 koa、express+中间件

以下是一个使用 Express 框架进行文件上传的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const express = require('express');
const multer = require('multer');

const app = express();
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('file'), (req, res) => {
console.log(req.file);
res.send('File uploaded');
});

app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});

在这个示例中,我们使用 Express 框架创建了一个 HTTP 服务器,并使用 multer 中间件来处理文件上传。当客户端通过 POST 方法提交一个包含文件的表单时,multer 会将文件保存到指定目录中,并将文件信息添加到 req.file 对象中,供后续处理函数使用。

koa的代码:

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
const Koa = require('koa');
const Router = require('koa-router');
const koaBody = require('koa-body');
const fs = require('fs');
const app = new Koa();
const router = new Router();

// 注册 koa-body 中间件
app.use(koaBody({
multipart: true, // 开启文件上传支持
formidable: {
// 设置上传文件大小的上限,默认为 2MB
maxFileSize: 200 * 1024 * 1024, // 200MB
// 保留上传文件的扩展名
keepExtensions: true
}
}));

// 处理文件上传请求
router.post('/api/upload', async (ctx, next) => {
// 如果请求不是 POST 或文件上传请求,则直接跳过
if (ctx.method !== 'POST' || !ctx.request.files || Object.keys(ctx.request.files).length === 0) {
return await next();
}

const file = ctx.request.files.file; // 获取上传的文件对象
// 对上传的文件进行处理,例如保存到磁盘或数据库
console.log(file.name, file.type, file.size, file.path);

ctx.body = '文件上传成功';
});

// 注册路由中间件
app.use(router.routes()).use(router.allowedMethods());

// 启动服务器
app.listen(3000, () => {
console.log('服务器已启动,监听端口 3000');
});

无论您选择哪种方式,都需要注意安全性,例如对上传的文件进行类型、大小、格式等方面的验证,以防止恶意文件上传攻击。

加上一段基础的类型、大小、格式等的检验的koa代码:

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
const Koa = require('koa');
const koaBody = require('koa-body');
const Router = require('koa-router');
const path = require('path');
const fs = require('fs');

const app = new Koa();
const router = new Router();

router.post('/api/upload', koaBody({
multipart: true,
formidable: {
// 设置上传文件大小限制
maxFileSize: 20 * 1024 * 1024, // 最大20MB
// 设置上传文件类型
type: ['image/jpeg', 'image/png'] // 只允许jpeg和png格式的图片
}
}), async (ctx) => {
const file = ctx.request.files.file;

// 检查文件类型
if (file.type.indexOf('image') === -1) {
ctx.throw(400, '上传的文件必须是图片');
}

// 检查文件大小
if (file.size > 20 * 1024 * 1024) {
ctx.throw(400, '上传的文件不能超过20MB');
}

// 检查文件格式
const extname = path.extname(file.name);
if (extname !== '.jpg' && extname !== '.jpeg' && extname !== '.png') {
ctx.throw(400, '上传的文件格式必须为jpg、jpeg或png');
}

// 将文件保存到磁盘上
const reader = fs.createReadStream(file.path);
const stream = fs.createWriteStream(path.join(__dirname, 'uploads', file.name));
reader.pipe(stream);

ctx.body = '文件上传成功';
});

app.use(router.routes());

app.listen(3000, () => {
console.log('Server is running at http://localhost:3000');
});

在上述代码中,我们使用koa-body中间件来解析上传的文件,并使用formidable选项设置了最大文件大小和文件类型。接下来,我们在路由处理程序中使用throw方法来验证文件类型、大小和格式,如果验证失败,将抛出错误并返回错误信息给客户端。如果验证通过,我们将文件保存到磁盘上。

请注意,这只是最基本的安全验证,实际情况中您可能需要根据应用程序的需求进行更复杂的验证。例如,您可能需要验证文件是否包含恶意代码、文件名是否包含非法字符等。

举例如何过滤文件名的非法字符:

1
2
3
4
5
6
7
8
9
10
function filterFilename(filename) {
// 过滤掉文件名中的非法字符
const pattern = /[\s\\/:*?"<>|]/g;
return filename.replace(pattern, '');
}

// 例子
const filename = 'example / file.txt';
const filteredFilename = filterFilename(filename);
console.log(filteredFilename); // 输出 "examplefile.txt"

在上面的代码中,filterFilename函数接收一个文件名作为参数,并返回过滤掉非法字符后的文件名。其中,pattern变量是一个正则表达式,用于匹配文件名中的非法字符。在这个例子中,我们过滤掉了空格、反斜杠、冒号、星号、问号、双引号、小于号、大于号和竖线等字符。在实际应用中,你可能需要根据具体情况修改这个正则表达式,以适应你的需求。

文件名非法字符可能会导致路径遍历攻击(Path Traversal Attack)的安全问题。攻击者可以在文件名中加入特殊字符,导致服务器解析路径时出现错误,从而访问到服务器上的敏感文件。攻击者可以利用这种漏洞来窃取敏感信息、修改文件内容,甚至执行系统命令等。因此,在上传文件时,需要对文件名进行安全检查和过滤,确保文件名不包含非法字符。

2.3 前端文件上传

前端可以使用 FormData 和 fetch API 来进行文件上传。FormData 对象可以方便地将表单数据和文件数据打包成一个表单对象,而 fetch API 可以用于发送网络请求和处理响应数据。下面是一个示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const inputEl = document.querySelector('input[type="file"]');
const file = inputEl.files[0];
const formData = new FormData();
formData.append('file', file);

fetch('http://example.com/upload', {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));

有时候,我们文件上传的时候,并不会在表单提交的时候进行上传文件,而是在文件变化的时候,去上传,然后表单的时候,表单对应字段直接是那个url了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const inputEl = document.querySelector('input[type="file"]');
const formEl = document.querySelector('form');

inputEl.addEventListener('change', () => {
const file = inputEl.files[0];
const formData = new FormData();
formData.append('file', file);

fetch('http://example.com/upload', {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
});

在上面的代码中,首先获取包含文件数据的 input 元素,然后通过监听 change 或者 submit 事件,获取文件数据,并创建一个新的 FormData 对象,将文件数据添加到表单对象中。接下来,使用 fetch API 发送一个 POST 请求,请求体为表单对象,请求头需要设置为 multipart/form-data 类型。最后,通过 Promise 对象处理响应数据。

需要注意的是,fetch API 的返回值是一个 Promise 对象,因此需要使用 then 方法处理响应数据,同时需要注意跨域问题。在进行文件上传时,需要注意请求体的大小限制和请求头的设置。

另外,我们也能用FormData 对象和 XMLHttpRequest 对象来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const inputEl = document.querySelector('input[type="file"]');
const formEl = document.querySelector('form');

inputEl.addEventListener('change', () => {
const file = inputEl.files[0];
const formData = new FormData();
formData.append('file', file);

const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://example.com/upload');
xhr.send(formData);
});

formEl.addEventListener('submit', (event) => {
event.preventDefault();
const file = inputEl.files[0];
const formData = new FormData();
formData.append('file', file);

const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://example.com/upload');
xhr.send(formData);
});

2.4 服务端Nodejs作为客户端文件上传

使用 node-fetch 库进行客户端的文件上传,需要将文件数据以 FormData 的形式打包,并设置相应的请求头信息。下面是一个示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fetch = require('node-fetch');
const fs = require('fs');

const formData = new FormData();
formData.append('file', fs.createReadStream('/path/to/file'));

fetch('http://example.com/upload', {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
'Content-Length': formData.getLengthSync()
}
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));

在上面的代码中,首先使用 FormData 构造函数创建一个新的表单对象,并将文件数据添加到表单对象中。然后使用 node-fetch 库发送一个 POST 请求,请求体为表单对象,请求头需要设置为 multipart/form-data 类型,并且需要设置 Content-Length 头信息为表单对象的长度。最后,通过 Promise 对象处理响应数据。

需要注意的是,node-fetch 库并不支持将 FormData 直接传递给请求体,需要通过设置请求头和请求体长度的方式来实现文件上传。

三、流式上传

当我们的文件比较大的时候,我们会采用流式上传:

3.1 koa作为服务端

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
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-body');
const fs = require('fs');
const path = require('path');

const app = new Koa();
const router = new Router();
const uploadPath = path.join(__dirname, 'uploads');

// 创建上传目录
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath);
}

// 处理文件上传
router.post('/upload', bodyParser({
multipart: true,
formidable: {
uploadDir: uploadPath,
},
}), async (ctx) => {
const { file } = ctx.request.files;
const { chunk, chunks } = ctx.request.body;

// 文件上传完成,将所有块合并为完整文件
if (parseInt(chunk) === parseInt(chunks) - 1) {
const filePath = path.join(uploadPath, file.name);
const writeStream = fs.createWriteStream(filePath, { flags: 'a' });

for (let i = 0; i < chunks; i++) {
const chunkFilePath = path.join(uploadPath, `${file.name}-${i}`);
const chunkReadStream = fs.createReadStream(chunkFilePath);
chunkReadStream.pipe(writeStream);
}

// 删除所有块
for (let i = 0; i < chunks; i++) {
const chunkFilePath = path.join(uploadPath, `${file.name}-${i}`);
fs.unlinkSync(chunkFilePath);
}

ctx.body = 'File uploaded successfully';
} else {
ctx.body = 'Chunk uploaded successfully';
}
});

app.use(router.routes());

app.listen(3000, () => {
console.log('Server started at http://localhost:3000');
});

在这个示例中,我们使用 Koa 框架和 koa-body 中间件处理文件上传,并将文件块保存到磁盘上。与前面的示例类似,在每次上传文件块时,我们检查是否已经上传了所有块。如果是,则将所有块拼接为完整的文件,并将其保存到磁盘上。在文件拼接完成后,我们还需要删除所有块,以释放磁盘空间。

需要注意的是,Koakoa-body 中间件默认使用 formidable 库来处理文件上传。在上面的示例中,我们设置了 formidable.uploadDir 选项来指定上传文件块的目录。在实际生产环境中,你可能需要根据自己的需要来配置 formidable 库。

3.2 node作为客户端进行流式上传

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
const FormData = require('form-data');
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');

const CHUNK_SIZE = 10*1024 * 1024; // 10MB

async function uploadFile(filePath, url) {
const stream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
const fileSize = fs.statSync(filePath).size;
let offset = 0;

for await (const chunk of stream) {
const form = new FormData();
form.append('chunk', chunk, {
filename: path.basename(filePath),
contentType: 'application/octet-stream',
knownLength: chunk.length
});
form.append('offset', offset);
form.append('filesize', fileSize);

const res = await fetch(url, {
method: 'POST',
body: form
});

if (!res.ok) {
throw new Error(`Failed to upload chunk (offset: ${offset}, status: ${res.status})`);
}

offset += chunk.length;
}
}

(async () => {
const filePath = '/path/to/file';
const url = 'http://example.com/upload';

try {
await uploadFile(filePath, url);
console.log('File uploaded successfully');
} catch (err) {
console.error('Failed to upload file:', err);
}
})();

在上面的示例中,首先使用 fs.createReadStream() 方法创建一个可读流,同时指定 highWaterMark 选项为每个分片的大小,以便进行文件分片上传。然后,在循环中使用 form-data 模块创建一个新的表单对象,并将分片数据添加到表单对象中。接着,使用 node-fetch 模块发送一个 POST 请求,请求体为表单对象,并在响应中检查上传是否成功。最后,使用 offset 变量记录当前已上传的字节数,并在下一次循环时更新该变量,直到上传完成。

需要注意的是,该示例中没有考虑并发上传和重试机制等问题,这些问题需要根据具体情况进行处理。同时,由于分片大小的设定会影响上传效率和网络带宽的利用率,因此需要根据实际情况进行调整。

3.3 前端进行流式上传

前端进行流式文件上传的实现方式与 Node.js 类似,可以使用 FormData 对象和 fetch() 函数来实现。下面是一个示例代码,演示了如何使用 fetch() 函数进行流式上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function uploadFile(file, url) {
const CHUNK_SIZE = 1024 * 1024; // 1MB
const fileSize = file.size;
let offset = 0;

while (offset < fileSize) {
const chunk = file.slice(offset, offset + CHUNK_SIZE);
const form = new FormData();
form.append('chunk', chunk, file.name);
form.append('offset', offset);
form.append('filesize', fileSize);

const res = await fetch(url, {
method: 'POST',
body: form
});

if (!res.ok) {
throw new Error(`Failed to upload chunk (offset: ${offset}, status: ${res.status})`);
}

offset += chunk.size;
}
}

在上面的代码中,我们首先定义了一个 CHUNK_SIZE 变量,表示每次上传的分片大小。然后,使用 file.slice() 方法将文件分成多个分片,并在循环中逐个上传。每个分片上传时,我们都创建一个新的 FormData 对象,并将分片数据添加到表单对象中。接着,使用 fetch() 函数发送一个 POST 请求,请求体为表单对象,并在响应中检查上传是否成功。最后,使用 offset 变量记录当前已上传的字节数,并在下一次循环时更新该变量,直到上传完成。

需要注意的是,在实际开发中,我们可能需要考虑并发上传和重试机制等问题,这些问题需要根据具体情况进行处理。同时,由于分片大小的设定会影响上传效率和网络带宽的利用率,因此需要根据实际情况进行调整。

四、结束语

文件上传是一个非常常用的功能,这块做了一个常用小结。


如果你有什么意见和建议,可以点击: 反馈地址 进行反馈。