文件上传

使用场景

文件上传,是 Web 应用的一个常见的功能。

框架内置了 Multipart 插件:

  • 解析浏览器上传的 multipart/form-data 的数据。
  • 提供 filestream 两种处理接口供开发者选择。
  • 默认提供了安全的限制。

获取到用户上传的数据后,开发者可以:

  • 存储为本地文件。
  • 提交给第三方服务,参见 通过 HttpClient 上传文件
  • 大部分情况下,我们会转存给云存储服务,在本文中我们也会一并介绍到。

File 模式

虽然在 Node.js 的世界里面,Stream 才是主流。

但对于一般开发者来说,Stream 并不是很容易掌握,尤其是错误处理环节。

因此,框架提供了 File 模式来简化开发。

相关的示例代码参见:eggjs/example/multipart-file-mode

配置

// config/config.default.js
config.multipart = {
  mode: 'file',
};

前端代码

前端可以通过 FormAJAX 等方式来上传文件。

譬如:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
  title: <input name="title" />
  file1: <input name="file1" type="file" />
  file2: <input name="file2" type="file" />
  <button type="submit">Upload</button>
</form>

注意事项

文件上传需要通过 POST 协议,因此会受到 CSRF 安全的管控,具体参见对应文档。

获取上传的文件

框架在 File 模式下,会把获取到的文件挂载到 ctx.request.files 数组上。

关键代码:

  • ctx.request.files: 获取到的文件列表。
  • ctx.oss.put():示例代码,此处为上传到 OSS 云存储,下文会介绍到。
  • ctx.cleanupRequestFiles():处理完毕后,清理临时文件。
// app/controller/upload.js
class UploadController extends Controller {
  async upload() {
    const { ctx } = this;
    console.log(ctx.request.body);
    console.log('got %d files', ctx.request.files.length);

    try {
      // 遍历处理多个文件
      for (const file of ctx.request.files) {
        console.log('field: ' + file.fieldname);
        console.log('filename: ' + file.filename);
        console.log('encoding: ' + file.encoding);
        console.log('mime: ' + file.mime);
        console.log('tmp filepath: ' + file.filepath);

        // 处理文件,比如上传到云端
        const result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath);
        console.log(result);
      }
    } finally {
      // 需要删除临时文件
      await ctx.cleanupRequestFiles();
    }
  }
};

Stream 模式

如果你对于 Node.js 中的 Stream 模式非常熟悉,那么你可以选择此模式。

相关的示例代码参见:eggjs/example/multipart

上传单个文件

框架同样提供了简化开发的语法糖:

  • ctx.getFileStream():获取上传的文件流,仅支持上传一个文件的情况。
  • stream.fields 获取其他表单字段。

注意事项

由于表单解析是有时序的,因此前端代码中,文件 fileds 必须在最后面。

否则在拿到文件流时,stream.fields 还没解析完,从而获取不到。

因此对应的前端代码:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
  title: <input name="title" />

  <!-- 只能有一个 File,且必须放在最后-->
  file: <input name="file" type="file" />
  <button type="submit">Upload</button>
</form>

对应的后端代码:

const path = require('path');
const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploadController extends Controller {
  async upload() {
    const ctx = this.ctx;
    const stream = await ctx.getFileStream();
    const name = 'egg-multipart-test/' + path.basename(stream.filename);
    // 文件处理,上传到云存储等等
    let result;
    try {
      result = await ctx.oss.put(name, stream);
    } catch (err) {
      // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
      await sendToWormhole(stream);
      throw err;
    }

    ctx.body = {
      url: result.url,
      // 所有表单字段都能通过 `stream.fields` 获取到
      fields: stream.fields,
    };
  }
}

上传多个文件

同时上传多个文件的场景,不能通过 ctx.getFileStream() 来获取,只能通过以下方式:

const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploadController extends Controller {
  async upload() {
    const ctx = this.ctx;
    const parts = ctx.multipart();
    let part;
    // parts() 返回 promise 对象
    while ((part = await parts()) != null) {
      if (part.length) {
        // 这是 busboy 的字段
        console.log('field: ' + part[0]);
        console.log('value: ' + part[1]);
        console.log('valueTruncated: ' + part[2]);
        console.log('fieldnameTruncated: ' + part[3]);
      } else {
        if (!part.filename) {
          // 这时是用户没有选择文件就点击了上传(part 是 file stream,但是 part.filename 为空)
          // 需要做出处理,例如给出错误提示消息
          return;
        }
        // part 是上传的文件流
        console.log('field: ' + part.fieldname);
        console.log('filename: ' + part.filename);
        console.log('encoding: ' + part.encoding);
        console.log('mime: ' + part.mime);
        // 文件处理,上传到云存储等等
        let result;
        try {
          result = await ctx.oss.put('egg-multipart-test/' + part.filename, part);
        } catch (err) {
          // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
          await sendToWormhole(part);
          throw err;
        }
        console.log(result);
      }
    }
    console.log('and we are done parsing the form!');
  }
}

错误处理

Stream 模式下,在异常处理里面,必须将上传的文件流消费掉,要不然浏览器响应会卡死

如上示例,你可以使用 stream-wormholemz-modules/pump 模块来处理。

友情提示

如果你对 Stream 没有足够了解的时候,建议直接使用 File 模式。

安全限制

文件大小

为了避免恶意的攻击,框架默认对文件上传接口,限制了 FileField 的个数和大小。

默认配置如下,开发者可以根据需求修改对应的配置。

config.multipart = {
  // 表单 Field 文件名长度限制
  fieldNameSize: 100,
  // 表单 Field 内容大小
  fieldSize: '100kb',
  // 表单 Field 最大个数
  fields: 10,

  // 单个文件大小
  fileSize: '10mb',
  // 允许上传的最大文件数
  files: 10,
};

其中,fileSize 支持 10mb 这种人性化的方式,具体参见 humanize-bytes 模块。

文件类型

为了保证文件上传的安全,框架限制了支持的文件格式。默认的后缀白名单参见源码

开发者可以通过配置 fileExtensions 来新增允许的类型:

module.exports = {
  multipart: {
    fileExtensions: [ '.apk' ] // 增加对 apk 扩展名的文件支持
  },
};

如果你希望覆盖框架内置的白名单,可以配置 whitelist 属性:

module.exports = {
  multipart: {
    // 覆盖整个白名单,只允许上传 '.png' 格式
    whitelist: [ '.png' ],
    // 也支持函数格式
    // whitelist: (filename) => [ '.png' ].includes(path.extname(filename) || ''),
  },
};

友情提示

当重写了 whitelist 时,fileExtensions 不生效。

云存储

当获得上传的文件之后,我们一般会转存到云存储服务,尤其是在集群的情况下。

常用的服务有:

OSS

框架内置了 egg-oss 插件,默认未开启。

配置

首先需要开启插件:

// config/plugin.js
exports.oss = true;

然后配置一下你的 OSSbucket, accessKeyId, accessKeySecret 等必要信息。

// config/config.default.js
config.oss = {
  client: {
    accessKeyId: 'your access key',
    accessKeySecret: 'your access secret',
    bucket: 'your bucket name',
    endpoint: 'oss-cn-hongkong.aliyun.com',
    timeout: '60s',
    // accessKeyId 和 accessKeySecret 是否经过 egg-bin 加密的
    // encryptPassword: false,
  },
};

然后通过 ctx.oss.put() 方法即可上传,支持 FileStream 两种模式。

File 模式

class UploadController extends Controller {
  async upload() {
    // ...

    // file 是拿到的上传的文件对象
    const { url } = await this.ctx.oss.put(name, file.filepath);
    console.info(url); // url 即为上传后的文件链接
  }
}

Stream 模式

class UploadController extends Controller {
  async upload() {
    // ...

    // stream 是拿到的上传的文件流对象
    const { url } = await this.ctx.oss.put(name, stream);
    console.info(url); // url 即为上传后的文件链接
  }
}

前端直接上传 OSS

还有一种常见的需求:前端直接上传文件到 OSS,不经过我们的 Web 应用。

OSS 提供了 STS 临时授权方式

上述的 egg-oss 插件的底层是 ali-oss 模块,也提供了对应的支持,具体参见文档。