一、需求

egg.js 的文件上传个人觉得很一般,内置的 multipart 插件并不怎么好用。

egg-multipart 也是基于 co-busboy 实现的。

egg 官方给的文件上传的示例地址:

二、CSRF 校验

egg 文件的上传需要进行 csrf 校验,而且这个校验默认只支持放在 ctx.query._csrf 字段中,很不方便,每次文件上传都会携带。

习惯 laravel 或者某些 PHP 框架的人都知道,直接放在 <input type="hidden" name="_csrf" > 的字段中方便的多。(不过我默认试过放在 input 字段中,是不起作用的,其他方法我暂时没有尝试)

至于生成 csrf 的方式,可以在 cookie 中获取,cookie 中存放了 csrfToken=token,因此是可以通过前端获取的,这为通过 ajax 实现文件上传也提供了帮助。

如果在模板文件中使用的话,可以通过 {{ctx.csrf | safe}} 生成 token。

因为 token 必须放在 query._csrf 中,所以 form 的 html 如下:

<form action="/post/add?_csrf={{ctx.csrf | safe}}" method="POST" enctype="multipart/form-data">
</form>

1.jpg

三、通过 form 表单上传文件

表单 html 不过多描述,默认使用上面的 url 进行表单提交,路由也不具体说明。

文件上传的时候使用了额外的4个依赖,依赖如下:

依赖说明
stream-wormhole 将 stream 流消耗掉
await-stream-ready 文件读写流 ready 库,能够使用 await

await-stream-ready 主要是方便的使用 await 进行文件上传,而 stream-wormhole 是因为如果上传失败出现异常,那会导致浏览器响应崩溃,因此需要将 stream 消耗掉。

重点说下 controller 中的代码:

app/controller/post.js

  // 添加文章
  async add(){
    await this.ctx.render('post/add');
  }
  // 添加文章操作
  async addAction(){
    const {ctx} = this;
    // 获取 steam
    const stream = await ctx.getFileStream();
    // 生成文件名
    const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename);
    // 写入路径
    const target = path.join(this.config.baseDir, 'app/public/upload/', filename);
    const writeStream = fs.createWriteStream(target);
    try { 
      // 写入文件
      await awaitStreamReady(stream.pipe(writeStream));
    } catch (err) {
      // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
      await sendToWormhole(stream);
      throw err;
    }
    ctx.body = stream.fields;
  }

上面代码中,通过 await ctx.getFileStream() 获取到流,stream 是一个 FileStream 对象,有许多有用的属性,比如下面这些属性,其中 stream.filename 能获取文件的原始名称,上面 controller 代码中,便是使用 filename 获取到了文件的后缀。

当表单的 enctype 设置成 multipart/form-data 之后,便不能使用 ctx.request.body 获取其他字段,现在是个空对象。

要获取其他字段的名字,需要使用 stream.fields 来获取其他字段的值。

FileStream {
  fieldname: 'photo', // 字段名
  filename: '1.jpg', // 文件名
  encoding: '7bit', // 编码
  transferEncoding: '7bit',
  mime: 'image/jpeg', // 类型
  mimeType: 'image/jpeg', 
  fields: { title: '', description: '', author: '', content: '' } }

上传结果:

{"title":"","description":"","author":"","content":""}

四、通过 ajax 上传文件

ajax 文件上传后端代码基本不用变动,只是前端代码这边构建一个 formData 即可。

下面的代码是官方示例的给的,因为用 formData 进行 ajax 文件上传很多示例,不再重复,即使使用其他插件也是无所谓的,本质上 HTTP 请求的 Content-type 还是 multipart/form-data

关键点在于 _csrf 的获取,方法 getCsrf() 是通过 cookie 获取 token 的方式,当然如果页面还是在模板中,那我觉得通过一个隐藏的值存放 csrf token,然后再去获取更加方便。

后端代码没什么变动的,本质是一样的。

$('form').submit(function(e) {
    e.preventDefault();
    var formData = new FormData();
    formData.append('name', $('input[type=text]').val());
    // Attach file
    formData.append('image', $('input[type=file]')[0].files[0]);
    // console.log(formData);

    $.ajax({
      url: '/ajax?_csrf=' + getCsrf(),
      data: formData,
      method: 'POST',
      contentType: false, // NEEDED, DON'T OMIT THIS (requires jQuery 1.6+)
      processData: false, // NEEDED, DON'T OMIT THIS
      success: function(result) {
        console.log(result);
      },
      error: function(responseStr) {
        alert("error", responseStr);
      }
    });
    // 通过 cookie 获取 csrf token
    function getCsrf() {
      var keyValue = document.cookie.match('(^|;) ?csrfToken=([^;]*)(;|$)');
      return keyValue ? keyValue[2] : null;
    }
  });

五、自定义文件上传目录

按照我自己的习惯,我在上传文件的时候,自然是希望文件上传到 app/public/upload/20180707/xxxx.jpg,但是由于 writeStream 的限制,20180707 必然是需要确定文件目录存在的。

因此如果使用这种方式上传,则需要增加几行代码用来判断并且生成文件夹。

为了方便生成日期如 20180707 的目录,我用了 dayjs 库来格式化时间。

为了省事我直接用了 mkdirSync()

    // 上传基础目录
    const uplaodBasePath = 'app/public/upload/';
    // 生成文件名
    const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename);
    // 生成文件夹
    const dirName = dayjs(Date.now()).format('YYYYMMDD');
    // 判断文件夹是否存在,不存在则直接创建文件夹
    if(! fs.existsSync()) fs.mkdirSync(path.join(this.config.baseDir,uplaodBasePath,dirName));
    // 生成写入路径 
    const target = path.join(this.config.baseDir, uplaodBasePath, dirName, filename);
    // 写入流
    const writeStream = fs.createWriteStream(target);

最终结果:

2.jpg

最终的 controller 代码如下:

// 添加文章操作
  async addAction(){
    const {ctx} = this;
    // 获取 steam
    const stream = await ctx.getFileStream();
    // 上传基础目录
    const uplaodBasePath = 'app/public/upload/';
    // 生成文件名
    const filename = Date.now() + '' + Number.parseInt(Math.random() * 10000) + path.extname(stream.filename);
    // 生成文件夹
    const dirName = dayjs(Date.now()).format('YYYYMMDD');
    if(! fs.existsSync()) fs.mkdirSync(path.join(this.config.baseDir,uplaodBasePath,dirName));
    // 生成写入路径 
    const target = path.join(this.config.baseDir, uplaodBasePath, dirName, filename);
    // 写入流
    const writeStream = fs.createWriteStream(target);
    try { 
      // 写入文件
      await awaitStreamReady(stream.pipe(writeStream));
    } catch (err) {
      // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
      await sendToWormhole(stream);
      throw err;
    }
    ctx.body = stream.fields;
  }