使用 koa-body 完成文件上传,并简单阅读下源码

3,313 阅读5分钟

序言

最近在倒腾一个有关文件上传的小项目,使用到了 koa 搭建服务器, vue3 来完成页面开发。过程中磕磕绊绊,但最后总算是完成了功能实现,并且对 node 环境下的文件上传流程有了一定了解。故此,写下这篇笔记以记录摸索的过程。

本文是后端篇,前端部分请点击下方链接

前端篇

接收上传文件

当前端通过 二进制流 将文件发送到服务器的时候,我们需要正确地读取文件流然后保存到服务器中。

想要完成这一步,有个相当不错的插件 koa-body 可以帮助我们完成,只需寥寥几行代码:

import Router = require("@koa/router")
import KoaBody = require("koa-body")
import { resolve } from 'path'
import { publicPath } from "../common"; 

const router = new Router()
const savePath = resolve(publicPath, 'assets') // 文件资源存放的路径

router.post(
  "/upload",
  KoaBody({
    multipart: true,
    formidable: {
      keepExtensions: true,
      maxFieldsSize: 10 * 1024 * 1024,
      uploadDir: savePath
    }
  }),
  (ctx) => {
    console.log(ctx.req.files)
    ctx.response.body = 'done'
  }
)

在上述代码当中,我们定义了一个 upload 的路由,然后使用了添加了 KoaBody 的中间件,而我们的主要逻辑代码只是给这次请求添加了一个响应内容而已。

当然,还有打印了一下我们介绍的所有文件的详细数据。没错,经过 koa-body 的加工之后,我们可以在 ctx.req.files 这个对象身上拿到具体上传的文件的数据。如果想对文件再进行其它处理,那就可以在这里着手。

这里简单介绍一下我们传递给 KoaBody 的配置参数:

  • multipart 这个配置项用于开启 KoaBody 对 content-type 以 multipart 打头,也就是 二进制流 请求的解析
  • formidable.maxFieldsSize 用于限制最大使用的内存大小
  • formidable.uploadDir 上传文件要保存的文件夹路径,注意,如果这个文件夹不存在便会报错
  • formidable.keepExtensions 保存的文件是否要保留后缀名,默认是 false ,也就是说你上传的文件默认会被保存为没有任何后缀名的纯二进制文件

当然,除了这些配置项以外,还有其它的配置项,具体还是看 官网 吧。

如果你在前端发送一个文件流到这个接口,你可以看到你的文件正确地被保存到了 ${publicPath}/assets 的文件夹下。

到此,其实我们已经实现了文件上传服务器的功能。但我觉得,作为一个技术人,在学习的过程中不该止步于对一个工具的了解,还是该对实现的原理有一些探究与思考,所以,下文会从 koa-body 这个插件的底层实现原理,简单地进行剖析。

koa-body

requestbody 处理请求头部

首先,我们发到 koa-body 的入口文件 index.js ,可以看到如下导出:

// 省略部分代码
module.exports = requestbody;

function requestbody(opts) {
  // ...
  // 这里是对配置参数的初始化,省略

  opts.parsedMethods = 'parsedMethods' in opts ? opts.parsedMethods : ['POST', 'PUT', 'PATCH']
  opts.parsedMethods = opts.parsedMethods.map(function (method) { return method.toUpperCase() })

  return function (ctx, next) {
    var bodyPromise;
    // only parse the body on specifically chosen methods
    if (opts.parsedMethods.includes(ctx.method.toUpperCase())) {
      try {
        if (opts.json && ctx.is(jsonTypes)) {
          // ......
        } else if (opts.urlencoded && ctx.is('urlencoded')) {
          // ......
        } else if (opts.text && ctx.is('text/*')) {
          // ......
        } else if (opts.multipart && ctx.is('multipart')) {
          bodyPromise = formy(ctx, opts.formidable);
        }
      } catch (parsingError) {
        // ......
      }
    }

    bodyPromise = bodyPromise || Promise.resolve({});
    return bodyPromise.catch(function(parsingError) {
      // ......
      return next();
    })
    .then(function(body) {
        if (isMultiPart(ctx, opts)) {
          ctx.req.body = body.fields;
          ctx.req.files = body.files;
        } else if (opts.includeUnparsed) {
          // ......
        } else {
          // ......
        }
      return next();
    })
  };
}

首先, 在 requestbody 这个函数使用了闭包保存了我们提供的配置参数,然后返回了真正作为 koa中间件 的方法。

在这个方法中,如果 http 请求的 method 是我们期望类型之一(默认是 POST、PUT、PATCH),就会进一步判断请求的 content-type 。

当请求的 content-type 是 multipart 的,就调用 formy 这个方法,并且把执行结果赋值给 bodyPromise ,我们暂时先不管 formy 具体干了什么,但根据上下文,我们不难猜到 formy 的执行结果是一个 Promise 。

接着,有对 bodyPromise 进行了异常捕获,以及正常回调后的一些数据处理:为 multipart 请求的情况下的 ctx.req 对象挂载上 body 和 files 两个属性。

此时,我们就明白了,在上文中,我们可以在 ctx.req.files 拿到所有文件的数据,这是 koa-body 在这里挂载上的。

formy 完成请求流的处理

我们把注意力放到 formy 这个函数身上:

// 。。。
// 省略部分代码
const forms = require('formidable');
/**
 * Donable formidable
 *
 * @param  {Stream} ctx
 * @param  {Object} opts
 * @return {Promise}
 * @api private
 */
function formy(ctx, opts) {
  return new Promise(function (resolve, reject) {
    var fields = {};
    var files = {};
    var form = new forms.IncomingForm(opts);
    form.on('end', function () {
      return resolve({
        fields: fields,
        files: files
      });
    }).on('error', function (err) {
      return reject(err);
    }).on('field', function (field, value) {
      if (fields[field]) {
        if (Array.isArray(fields[field])) {
          fields[field].push(value);
        } else {
          fields[field] = [fields[field], value];
        }
      } else {
        fields[field] = value;
      }
    }).on('file', function (field, file) {
      if (files[field]) {
        if (Array.isArray(files[field])) {
          files[field].push(file);
        } else {
          files[field] = [files[field], file];
        }
      } else {
        files[field] = file;
      }
    });
    if (opts.onFileBegin) {
      form.on('fileBegin', opts.onFileBegin);
    }
    form.parse(ctx.req);
  });
}

如我们所料,这个函数返回了一个 Promise 对象,而其中,使用 forms.IncomingForm 创建了一个 form 变量,接着链式调用了 form 这个变量的 on 方法。

在 node 环境中看到 on 这个方法,实际上我们的猜测没有错,这个 on 方法就是来自于 node 的事件模型。后面我们会验证这一点。

这里简单介绍一下 node 的事件模型,其本质就是我们常说的 发布订阅模式 ,可以使用 on 这个方法来订阅指定的事件,也可以使用 emit 来发布对应的事件以触发对应的方法的执行。如果 emit 的事件名有绑定的对应的监听器函数,那么这些监听器函数就会被一一调用。

所以,在这里,我们可以先猜测一下, form 变量会发射 end、error、field、file、fileBegin 多个事件。

从名字上,我们应该大致能猜出每个事件对应的状态,例如,error 是对异常的处理、end 流程处理完成后的回调,而 file 和 field 这两个事件中则是将 form 传递过来的一些数据收集到到 定义好的 files 和 fields 这两个变量中(可以回顾一下,在 requestbody 中可以 formy 返回的 Promise 的 then 方法中拿到这两个对象,然后挂载到 koa 的 req 对象上)

这些都不是对请求数据的处理的核心逻辑,并且,绑定事件只是将回调函数添加到了对应事件的回调队列当中去,交由事件中心异步地调用,也就是说,这一连串的 on 函数执行完毕后,并没有阻塞 formy 函数的继续执行,也就是说,代码很快就跑到了 parse 这个函数的执行。

接下来,我们就到 formidable 这个库中看看 parse 这个函数的具体做了些什么。

Formidable

parse 函数干了什么

先来看看 formidable 的入口文件

// 省略部代码
const Formidable = require('./Formidable');

// make it available without requiring the `new` keyword
// if you want it access `const formidable.IncomingForm` as v1
const formidable = (...args) => new Formidable(...args);

module.exports = Object.assign(formidable, {
  // 省略部分代码
  Formidable,
  formidable,

  // alias
  IncomingForm: Formidable,
  // 省略部分代码
});

我们可以看到,我们拿到的默认导出是一个返回值为一个 Formidable 示例的函数,不过后面对这个函数进行了扩展,把一些属性和方法再挂载到这个函数身上。

因此,上文 formy 函数中,我们可以使用 new forms.IncomingForm(opts) 的来创建 form 对象,也可以直接执行 forms(opts) 来创建。当然这不是重点。

找到 IncomingForm 的定义位置,可以看到,其继承了 EventEmitter 这个类, EventEmitter 这个类是 node 提供的事件模型,所以我们上文猜测 form.on 的方法来自于 node 的事件模型的猜测在此处得到了验证。

class IncomingForm extends EventEmitter {
  // 暂时省略内容代码
}

module.exports = IncomingForm;

接着我们进入上文提及的 parse 方法的实现:

parse(req, cb) {
    // this.pause = ......

    // this.resume = ......

    // Setup callback first, so we don't miss anything from data events emitted immediately.
    if (cb) {
      // .....
      // formy 中没有传递 callback ,所以这个条件分支并不会执行
    }

    // Parse headers and setup the parser, ready to start listening for data.
    this.writeHeaders(req.headers);

    // Start listening for data.
    req
      .on('error', (err) => {
        // 错误捕获
      })
      .on('aborted', () => {
        // 取消的回调
      })
      .on('data', (buffer) => {
        try {
          this.write(buffer);
        } catch (err) {
          this._error(err);
        }
      })
      .on('end', () => {
        // 请求处理完成的回调,不是重点。
      });

    return this;
  }

依旧是省略了一些无关的代码, parse 方法的重点是对 req 对象(这个是 node 的 request 对象)又添加了一些事件监听器。

我们重点需要关注的是 data 对应的事件回调。

可见,在 data 的回调中,接收的参数定义为了 buffer (这便是我们上传的文件流,后面会详细说明),然后又将 buffer 作为了 write 方法的参数传递。

再来看下 write 方法的代码:


const DummyParser = require('./parsers/Dummy');

write(buffer) {
  // 省略一些代码

  this._parser.write(buffer);

  return this.bytesReceived;
}

_parseContentType() {
  if (this.bytesExpected === 0) {
    // 不是这个!!
    this._parser = new DummyParser(this, this.options);
    return;
  }
}

write 中,又调用了 this._parser 上的 write 方法。

那么这个 _parser 又是什么呢?如果我们在当前源码中直接查找 _parser 我们只能在 _parseContentType 这个方法中找到 _parser 的初始化,但我们要注意,这个位置的初始化是有判断条件的,并且,在我们目前的请求类型的状态下,是无法触发这个分支的,所以, _parser 的值并不是 DummyParser 的实例对象。

那么, _parser 究竟是在赋值的呢?

寻找 _parser 的真身

这个找起来其实有点麻烦,我们需要关注一下 IncomingForm 的构造函数:

const DEFAULT_OPTIONS = {
  // 省略其它配置项
  enabledPlugins: ['octetstream', 'querystring', 'multipart', 'json']
};

constructor(options = {}) {
  // 省略一些代码
  this.options.enabledPlugins = []
    .concat(this.options.enabledPlugins)
    .filter(Boolean);

  if (this.options.enabledPlugins.length === 0) {
    throw new FormidableError(
      'expect at least 1 enabled builtin plugin, see options.enabledPlugins',
      errors.missingPlugin,
    );
  }

  this.options.enabledPlugins.forEach((pluginName) => {
    const plgName = pluginName.toLowerCase();
    // eslint-disable-next-line import/no-dynamic-require, global-require
    this.use(require(path.join(__dirname, 'plugins', `${plgName}.js`)));
  });
}

use(plugin) {
  if (typeof plugin !== 'function') {
    throw new FormidableError(
      '.use: expect `plugin` to be a function',
      errors.pluginFunction,
    );
  }
  this._plugins.push(plugin.bind(this)); // 注意 bind(this) 
  return this;
}

在 IncomingForm 的构造函数当中,根据 DEFAULT_OPTIONS 上的 enabledPlugins 中包含的插件名称,使用 use 方法注册了对应的插件。此时,我们观察 enabledPlugins 可以发现,这些插件的名称就是对应 · 的一些类型,而我们上传文件使用的是 multipart ,那么应该就是又 multipart 对应插件来处理了我们的请求,是否如此呢?

打开对应的文件:

module.exports = function plugin(formidable, options) {
  // the `this` context is always formidable, as the first argument of a plugin
  // but this allows us to customize/test each plugin

  /* istanbul ignore next */
  const self = this || formidable;

  // NOTE: we (currently) support both multipart/form-data and multipart/related
  const multipart = /multipart/i.test(self.headers['content-type']);

  if (multipart) {
    const m = self.headers['content-type'].match(
      /boundary=(?:"([^"]+)"|([^;]+))/i,
    );
    if (m) {
      const initMultipart = createInitMultipart(m[1] || m[2]);
      initMultipart.call(self, self, options); // lgtm [js/superfluous-trailing-arguments]
    } else {
      // 。。。
    }
  }
};

// Note that it's a good practice (but it's up to you) to use the `this.options` instead
// of the passed `options` (second) param, because when you decide
// to test the plugin you can pass custom `this` context to it (and so `this.options`)
function createInitMultipart(boundary) {
  return function initMultipart() {
    this.type = 'multipart';

    const parser = new MultipartParser(this.options);
    let headerField;
    let headerValue;
    let part;

    parser.initWithBoundary(boundary);

    // eslint-disable-next-line max-statements, consistent-return
    parser.on('data', ({ name, buffer, start, end }) => {
      //  。。。
      // 文件流进一步处理,暂时先省略
    });

    this._parser = parser;
  };
}

看来 multipart 对应的是一个函数,而我们仔细看,这里在最开始判断了 content-type 是否以 multipart 开头,如果是,就会执行 createInitMultipart ,在 createInitMultipart 的最下面,正是对 _parser 进行了赋值。

至于为何这里可以对 _parser 进行赋值,可以回顾一下上面的 use 方法,其中在注册 plugin 的时候,对 plugin 使用了 bind 函数,指定了上下文为 IncomingForm !

但我们也知道, bind 函数只是返回了一个修改了 this 指向的函数,并没有去执行它,而实际上,在上文提及的由 write 调用的 _parseContentType 这个函数中,除了在某个条件下会对 _parser 进行初始化以外,还有一段代码是这样的:

_parseContentType() {
  // eslint-disable-next-line no-plusplus
  for (let idx = 0; idx < this._plugins.length; idx++) {
    const plugin = this._plugins[idx];

    let pluginReturn = null;

    try {
      pluginReturn = plugin(this, this.options) || this;
    } catch (err) {
      // directly throw from the `form.parse` method;
      // there is no other better way, except a handle through options
      const error = new FormidableError(
        `plugin on index ${idx} failed with: ${err.message}`,
        errors.pluginFailed,
        500,
      );
      error.idx = idx;
      throw error;
    }

    Object.assign(this, pluginReturn);

    // todo: use Set/Map and pass plugin name instead of the `idx` index
    this.emit('plugin', idx, pluginReturn);
    results.push(pluginReturn);
  }
}

可以看到,不管如何,都会走一段循环,遍历所有的 plugin 并且执行。

也就说, multipart 必定会被执行,那么在我们上传文件的时候,使用到了 multipart 的头部,因此, _parser 必定会在 multipart 对应的插件中被初始化,也就是初始化为了 MultipartParser 的实例。

MultipartParser 做了些什么

class MultipartParser extends Transform {
  // 。。。。
}

MultipartParser 这个类,继承自 node 的 stream 对象中的 Tranform 这个类。 Transform 是一个可读可写流的类(详细看 官网),要求继承的子类实现 _tranform 的方法,来处理读入的数据:


// eslint-disable-next-line max-statements
_transform(buffer, _, done) {
  let i = 0;
  let prevIndex = this.index;
  let { index, state, flags } = this;
  const { lookbehind, boundary, boundaryChars } = this;
  const boundaryLength = boundary.length;
  const boundaryEnd = boundaryLength - 1;
  this.bufferLength = buffer.length;
  let c = null;
  let cl = null;

  // 省略一些代码

  for (i = 0; i < this.bufferLength; i++) {
    c = buffer[i];
    switch (state) {
      // 代码过长,这里简单概括一下:
      // 根据各种状态执行不同的处理,会直接或间接调用 _handleCallback 
      // 详细可以自行阅读源码
    }
  }

  return this.bufferLength;
}
// eslint-disable-next-line max-params
_handleCallback(name, buf, start, end) {
  if (start !== undefined && start === end) {
    return;
  }
  this.push({ name, buffer: buf, start, end });
}

而再 _transform 在进行各种状态的数据处理后,会直接或间接地触发 _handleCallback 这个方法,其中会执行 this.push 。而继承了 Transform 对象如果执行了 push 的方法, 就会发射 data 对应的事件。

而 MultipartParser 触发的 on 事件,会被 createInitMultipart 中被捕获(上文省略了这部分代码):

this.type = 'multipart';

const parser = new MultipartParser(this.options);
let headerField;
let headerValue;
let part;

parser.on('data', ({ name, buffer, start, end }) => {
  if (name === 'partBegin') {
    part = new Stream();
    part.readable = true;
    part.headers = {};
    part.name = null;
    part.originalFilename = null;
    part.mimetype = null;

    part.transferEncoding = this.options.encoding;
    part.transferBuffer = '';

    headerField = '';
    headerValue = '';
  } else if (name === 'headerField') {
    headerField += buffer.toString(this.options.encoding, start, end);
  } else if (name === 'headerValue') {
    headerValue += buffer.toString(this.options.encoding, start, end);
  } else if (name === 'headerEnd') {
    // ...
  } else if (name === 'headersEnd') {
    switch (part.transferEncoding) {
      case 'binary':
      case '7bit':
      case '8bit':
      case 'utf-8': {
        // 截取 buffer 流,并写入到 part 中
      }
      case 'base64': {
        // 进行 编码转换 后再截取 buffer 流以及写入到 part
      }
      default:
        // 错误捕获
    }

    this.onPart(part);
  } 
});

代码逻辑其实也很简单,就是多个条件判断,这里介绍重点的两个条件:

  • partBegin 在开始阶段,初始化 part 对象为一个 Stream 对象,并配置一些基本信息;
  • headersEnd 解析完一段数据流,将数据流编码进行统一,暂存到 part 对象中。

最后,再将 part 传递给 onPart 执行:

onPart(part) {
  // this method can be overwritten by the user
  this._handlePart(part);
}

_handlePart(part) {
  // 省略一些代码
  const file = this._newFile({ // 该方法会根据文件配置项创建一个写入流
    newFilename,
    filepath,
    originalFilename: part.originalFilename,
    mimetype: part.mimetype,
  });

  file.open();
  this.openedFiles.push(file);

  part.on('data', (buffer) => {
    this._fileSize += buffer.length;
    // 对文件大小等一些判断
    this.pause();
    file.write(buffer, () => {
      this.resume();
    });
  });

  // 省略一些代码
}

onPart 再调用 _handlePart 。其中,使用 _newFile 创建了一个文件对象,然后将接收的 buffer 再写入到该文件中。

到此,一个文件上传的过程结束。

当然,这其中还有其它的一些状态流转,感兴趣的同学可以自行查看源码。

总结

  • koa接收文件上传-使用 koa-body 这个插件即可
  • koa-body 的调用逻辑 requestbody -> formy ,去调用 formidable ,在 formidable 中,按照 parse -> _parser -> MultipartParser -> onPart 的顺序,完成上传的文件流的处理,以及写入到指定的位置。