MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js 文件上传与下载功能的实现

2021-07-022.8k 阅读

Node.js 文件上传功能的实现

理解文件上传原理

在 Web 开发中,文件上传是将本地文件传输到服务器端的过程。当用户在前端页面选择文件并提交表单时,浏览器会将文件数据以特定格式发送到服务器。在 Node.js 环境下处理文件上传,我们需要解析这些数据,提取出文件内容,并将其保存到服务器的指定位置。

HTTP 协议中,文件上传通常使用 multipart/form - data 格式。这种格式会将表单数据(包括文件)分割成多个部分,每个部分都有自己的边界标识。每个部分包含头部信息(如文件名、文件类型等)和数据内容。Node.js 服务器需要正确解析这些部分,才能成功处理文件上传。

使用 Express 框架处理文件上传

Express 是 Node.js 中最流行的 Web 应用框架之一,它提供了丰富的中间件来简化各种 Web 开发任务,包括文件上传。为了处理文件上传,我们需要使用 multer 中间件,它是专门为 Express 设计的文件上传中间件。

  1. 安装依赖 首先,确保项目目录下有 package.json 文件。如果没有,可以通过 npm init -y 命令快速初始化一个。然后安装 expressmulter
npm install express multer
  1. 编写上传代码 以下是一个简单的 Express 应用示例,展示如何使用 multer 实现文件上传:
const express = require('express');
const multer = require('multer');
const app = express();

// 设置存储引擎
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ storage: storage });

app.post('/upload', upload.single('file'), (req, res) => {
  res.send('文件上传成功');
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在上述代码中:

  • 我们首先引入了 expressmulter 模块。
  • 创建了一个 multer 的存储引擎 storagedestination 函数指定了文件上传后的存储目录为 uploads/,如果该目录不存在,需要手动创建。filename 函数定义了上传文件的命名规则,这里使用当前时间戳加上原始文件名。
  • 使用 multer 创建了一个 upload 实例,并指定使用 single 方法处理单个文件上传,参数 'file' 表示前端表单中文件字段的名称。
  • /upload 路由中,通过 upload.single('file') 中间件处理文件上传,上传成功后返回简单的成功信息。

处理多个文件上传

如果需要处理多个文件上传,multer 同样提供了方便的方法。我们可以使用 arrayfields 方法。

  1. 使用 array 方法 array 方法用于处理多个具有相同字段名的文件上传。例如:
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ storage: storage });

app.post('/uploadMultiple', upload.array('files', 5), (req, res) => {
  res.send('多个文件上传成功');
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在这个例子中,upload.array('files', 5) 表示处理字段名为 files 的多个文件上传,最多允许上传 5 个文件。

  1. 使用 fields 方法 fields 方法用于处理多个不同字段名的文件上传。假设前端表单有两个文件字段 avatarresume
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ storage: storage });

app.post('/uploadFields', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'resume', maxCount: 1 }
]), (req, res) => {
  res.send('多个字段文件上传成功');
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

这里 upload.fields 接受一个数组,数组中的每个对象指定了字段名和该字段允许上传的最大文件数。

自定义文件过滤

有时候,我们可能需要对上传的文件进行过滤,只允许特定类型或大小的文件上传。multer 提供了 fileFilter 选项来实现这一点。

  1. 按文件类型过滤 以下示例只允许上传图片文件(.jpg.png.jpeg):
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const fileFilter = (req, file, cb) => {
  if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/png' || file.mimetype === 'image/jpg') {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片文件'));
  }
};

const upload = multer({ storage: storage, fileFilter: fileFilter });

app.post('/uploadImage', upload.single('image'), (req, res) => {
  if (req.file) {
    res.send('图片上传成功');
  } else {
    res.status(400).send('上传失败,只允许上传图片文件');
  }
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

fileFilter 函数中,我们检查文件的 mimetype,如果是允许的图片类型,则调用 cb(null, true) 表示通过过滤;否则,调用 cb(new Error('错误信息')) 拒绝上传。

  1. 按文件大小过滤 假设我们只允许上传小于 1MB 的文件:
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const fileFilter = (req, file, cb) => {
  if (file.size < 1024 * 1024) {
    cb(null, true);
  } else {
    cb(new Error('文件大小超过限制'));
  }
};

const upload = multer({ storage: storage, fileFilter: fileFilter });

app.post('/uploadSmallFile', upload.single('file'), (req, res) => {
  if (req.file) {
    res.send('文件上传成功');
  } else {
    res.status(400).send('上传失败,文件大小超过限制');
  }
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

这里通过检查 file.size 来判断文件大小是否符合要求。

处理文件上传错误

在文件上传过程中,可能会出现各种错误,如文件类型不允许、文件大小超过限制、存储目录不可写等。我们需要妥善处理这些错误,给用户提供友好的反馈。

  1. 全局错误处理 在 Express 应用中,可以使用全局错误处理中间件来捕获所有路由中的错误。结合 multer 的错误处理:
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const fileFilter = (req, file, cb) => {
  if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/png' || file.mimetype === 'image/jpg') {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片文件'));
  }
};

const upload = multer({ storage: storage, fileFilter: fileFilter });

app.post('/uploadImage', upload.single('image'), (req, res) => {
  if (req.file) {
    res.send('图片上传成功');
  }
});

// 全局错误处理中间件
app.use((err, req, res, next) => {
  console.error(err);
  res.status(400).send(err.message);
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在这个例子中,当 multer 中间件抛出错误(如文件类型不允许)时,全局错误处理中间件会捕获并返回错误信息给客户端。

  1. 特定路由错误处理 也可以在特定路由中处理错误,例如:
const express = require('express');
const multer = require('multer');
const app = express();

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const fileFilter = (req, file, cb) => {
  if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/png' || file.mimetype === 'image/jpg') {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片文件'));
  }
};

const upload = multer({ storage: storage, fileFilter: fileFilter });

app.post('/uploadImage', upload.single('image'), (req, res, next) => {
  if (req.file) {
    res.send('图片上传成功');
  } else {
    next(new Error('上传失败'));
  }
}, (err, req, res, next) => {
  console.error(err);
  res.status(400).send(err.message);
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

这里在 /uploadImage 路由中,通过 next 函数将错误传递给后续的错误处理函数进行处理。

Node.js 文件下载功能的实现

简单文件下载

在 Node.js 中实现文件下载,最基本的方式是使用 fs(文件系统)模块读取文件内容,并通过 HTTP 响应将文件内容发送给客户端。以下是一个简单的示例,实现下载服务器上的一个文本文件:

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.get('/download', (req, res) => {
  const filePath = path.join(__dirname, 'example.txt');
  const stat = fs.statSync(filePath);
  res.setHeader('Content-Length', stat.size);
  res.setHeader('Content-Type', 'text/plain');
  res.setHeader('Content-Disposition', 'attachment; filename="example.txt"');
  const readStream = fs.createReadStream(filePath);
  readStream.pipe(res);
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在上述代码中:

  • 我们首先引入了 expressfspath 模块。path 模块用于处理文件路径,确保在不同操作系统上路径的正确性。
  • /download 路由中,通过 path.join 构建文件的绝对路径。然后使用 fs.statSync 获取文件的状态信息,包括文件大小。
  • 设置 HTTP 响应头:Content-Length 表示文件的大小,Content-Type 设置为 text/plain 表示文件类型为纯文本,Content-Disposition 中的 attachment 指示浏览器将文件作为附件下载,filename 指定下载后的文件名。
  • 使用 fs.createReadStream 创建一个可读流来读取文件内容,并通过 pipe 方法将可读流连接到响应对象,实现文件内容的传输。

动态文件下载

有时候,我们需要根据用户请求动态生成或选择要下载的文件。例如,根据用户传递的文件名参数下载不同的文件。

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.get('/download/:filename', (req, res) => {
  const fileName = req.params.filename;
  const filePath = path.join(__dirname, 'files', fileName);
  try {
    const stat = fs.statSync(filePath);
    res.setHeader('Content-Length', stat.size);
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
    const readStream = fs.createReadStream(filePath);
    readStream.pipe(res);
  } catch (err) {
    res.status(404).send('文件不存在');
  }
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在这个例子中,:filename 是路由参数。通过 req.params.filename 获取用户请求的文件名,然后构建文件路径。如果文件存在,则设置响应头并进行文件下载;如果文件不存在,捕获错误并返回 404 错误信息。

下载大文件的优化

当下载大文件时,直接将整个文件读入内存再发送可能会导致内存溢出。为了优化大文件下载,可以采用以下几种方法:

  1. 分块读取和传输 在前面的示例中,我们已经使用了 fs.createReadStreampipe 方法,它们默认就是分块读取和传输数据的。createReadStream 会以小块(通常是 64KB)的方式读取文件,通过 pipe 直接将数据写入响应流,而不需要将整个文件加载到内存中。

  2. 设置适当的缓冲区大小 createReadStream 可以接受一个 options 对象,其中 highWaterMark 属性可以设置缓冲区大小。例如:

const readStream = fs.createReadStream(filePath, { highWaterMark: 16384 }); // 16KB 缓冲区
readStream.pipe(res);

适当调整缓冲区大小可以平衡内存使用和传输效率。较小的缓冲区占用内存少,但可能会导致更多的系统调用;较大的缓冲区可能会提高传输效率,但会占用更多内存。

  1. 使用 HTTP 范围请求(Range Requests) HTTP 范围请求允许客户端请求文件的部分内容,这在网络不稳定或需要断点续传的场景中非常有用。在 Node.js 中处理范围请求需要解析 Range 请求头,并相应地调整文件读取的起始和结束位置。

以下是一个简单处理范围请求的示例:

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.get('/downloadLargeFile', (req, res) => {
  const filePath = path.join(__dirname, 'largeFile.txt');
  const stat = fs.statSync(filePath);
  const { range } = req.headers;
  if (range) {
    const parts = range.replace(/bytes=/, '').split('-');
    const start = parseInt(parts[0], 10);
    const end = parts[1]? parseInt(parts[1], 10) : stat.size - 1;
    const chunksize = (end - start) + 1;
    const file = fs.createReadStream(filePath, { start, end });
    res.writeHead(206, {
      'Content-Range': `bytes ${start}-${end}/${stat.size}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': chunksize,
      'Content-Type': 'text/plain',
      'Content-Disposition': 'attachment; filename="largeFile.txt"'
    });
    file.pipe(res);
  } else {
    res.writeHead(200, {
      'Content-Length': stat.size,
      'Content-Type': 'text/plain',
      'Content-Disposition': 'attachment; filename="largeFile.txt"'
    });
    fs.createReadStream(filePath).pipe(res);
  }
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在这个示例中:

  • 首先检查请求头中的 range 字段。如果存在 range,则解析出起始和结束位置,计算要读取的块大小。
  • 设置 206 Partial Content 状态码,并设置 Content-Range 等响应头,告知客户端返回的是文件的部分内容。
  • 使用 fs.createReadStream 并指定 startend 选项,读取文件的指定部分并通过管道传输给客户端。
  • 如果没有 range 字段,则按常规方式返回整个文件。

下载不同类型文件的处理

不同类型的文件在下载时可能需要设置不同的 Content-Type。例如:

  • 图片文件image/jpegimage/png 等。
  • PDF 文件application/pdf
  • ZIP 文件application/zip

以下是一个根据文件扩展名动态设置 Content-Type 的示例:

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

const contentTypeMap = {
  '.jpg': 'image/jpeg',
  '.png': 'image/png',
  '.pdf': 'application/pdf',
  '.zip': 'application/zip'
};

app.get('/downloadFile/:filename', (req, res) => {
  const fileName = req.params.filename;
  const filePath = path.join(__dirname, 'files', fileName);
  try {
    const stat = fs.statSync(filePath);
    const ext = path.extname(fileName);
    const contentType = contentTypeMap[ext] || 'application/octet-stream';
    res.setHeader('Content-Length', stat.size);
    res.setHeader('Content-Type', contentType);
    res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
    const readStream = fs.createReadStream(filePath);
    readStream.pipe(res);
  } catch (err) {
    res.status(404).send('文件不存在');
  }
});

const port = 3000;
app.listen(port, () => {
  console.log(`服务器在端口 ${port} 上运行`);
});

在这个例子中,通过 path.extname 获取文件扩展名,然后在 contentTypeMap 中查找对应的 Content-Type。如果找不到,则使用默认的 application/octet-stream

处理文件下载错误

在文件下载过程中,也可能出现各种错误,如文件不存在、权限不足等。与文件上传类似,我们需要妥善处理这些错误,给用户提供友好的反馈。

  1. 文件不存在错误 在前面的动态文件下载示例中,我们已经处理了文件不存在的错误:
app.get('/download/:filename', (req, res) => {
  const fileName = req.params.filename;
  const filePath = path.join(__dirname, 'files', fileName);
  try {
    const stat = fs.statSync(filePath);
    // 下载文件相关代码
  } catch (err) {
    res.status(404).send('文件不存在');
  }
});

这里通过捕获 fs.statSync 可能抛出的错误,判断文件是否存在。如果不存在,返回 404 状态码和错误信息。

  1. 权限不足错误 当服务器进程没有足够权限读取文件时,fs.createReadStream 会抛出错误。可以在 pipe 方法的错误事件中处理:
app.get('/download/:filename', (req, res) => {
  const fileName = req.params.filename;
  const filePath = path.join(__dirname, 'files', fileName);
  try {
    const stat = fs.statSync(filePath);
    res.setHeader('Content-Length', stat.size);
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
    const readStream = fs.createReadStream(filePath);
    readStream.pipe(res);
    readStream.on('error', (err) => {
      if (err.code === 'EACCES') {
        res.status(403).send('权限不足,无法下载文件');
      } else {
        res.status(500).send('下载文件时发生错误');
      }
    });
  } catch (err) {
    res.status(404).send('文件不存在');
  }
});

在这个例子中,监听 readStreamerror 事件。如果错误码是 EACCES,表示权限不足,返回 403 状态码和相应错误信息;其他错误返回 500 状态码。

通过以上详细的介绍和代码示例,希望能帮助你全面掌握 Node.js 中文件上传与下载功能的实现。在实际应用中,需要根据具体需求进行进一步的优化和扩展。