【Node专栏】上传文件
一、简介
平常项目经常会遇到需要做文件上传的需求,这边记录几个常见的场景。
二、普通文件上传
在 Node.js 中进行文件上传,通常可以使用以下两种方式:
1、使用 Node.js
内置的 http
或 https
模块来创建 HTTP(s)
服务器,然后使用 formidable
或 multer
等中间件来处理文件上传。
2、使用 Express 或 Koa 等 Node.js Web 框架来处理文件上传。
以下是一个使用 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();
app.use(koaBody({ multipart: true, formidable: { maxFileSize: 200 * 1024 * 1024, keepExtensions: true } }));
router.post('/api/upload', async (ctx, next) => { 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, type: ['image/jpeg', 'image/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);
|
在上面的代码中,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
中间件处理文件上传,并将文件块保存到磁盘上。与前面的示例类似,在每次上传文件块时,我们检查是否已经上传了所有块。如果是,则将所有块拼接为完整的文件,并将其保存到磁盘上。在文件拼接完成后,我们还需要删除所有块,以释放磁盘空间。
需要注意的是,Koa
的 koa-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;
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; 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
变量记录当前已上传的字节数,并在下一次循环时更新该变量,直到上传完成。
需要注意的是,在实际开发中,我们可能需要考虑并发上传和重试机制等问题,这些问题需要根据具体情况进行处理。同时,由于分片大小的设定会影响上传效率和网络带宽的利用率,因此需要根据实际情况进行调整。
四、结束语
文件上传是一个非常常用的功能,这块做了一个常用小结。