设计思路

没啥思路,纯体力活,就是用NodeJS来实现一把媒体文件上传的 rfc1867/rfc2388 协议。 NodeJS上可以搜罗到的 multipart 实现,大部分是解释器,装载器引用最多的是form-data这个包,接续上一版的实现,广度支持 nodejs http client,不挑食了。

node-fetch用来探测请求体是不是FormData函数片段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//refer: https://github.com/node-fetch/node-fetch/blob/master/src/utils/is.js#L47-L67

/**
 * Check if `obj` is a spec-compliant `FormData` object
 *
 * @param {*} object
 * @return {boolean}
 */
export function isFormData(object) {
    return (
        typeof object === 'object' &&
        typeof object.append === 'function' &&
        typeof object.set === 'function' &&
        typeof object.get === 'function' &&
        typeof object.getAll === 'function' &&
        typeof object.delete === 'function' &&
        typeof object.keys === 'function' &&
        typeof object.values === 'function' &&
        typeof object.entries === 'function' &&
        typeof object.constructor === 'function' &&
        object[Symbol.toStringTag] === 'FormData'
    );
}

axios用来探测代码片段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//refer: https://github.com/axios/axios/blob/master/lib/utils.js#L50-L58
/**
 * Determine if a value is a FormData
 *
 * @param {Object} val The value to test
 * @returns {boolean} True if value is an FormData, otherwise false
 */
function isFormData(val) {
  return (typeof FormData !== 'undefined') && (val instanceof FormData);
}

这个函数在浏览器环境上是可以的,但在NodeJS上是有问题的,FormData不是全局对象,探测失败,退而求其次,使用如下探测函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// https://github.com/axios/axios/blob/master/lib/utils.js#L161-L169
/**
 * Determine if a value is a Stream
 *
 * @param {Object} val The value to test
 * @returns {boolean} True if value is a Stream, otherwise false
 */
function isStream(val) {
  return isObject(val) && isFunction(val.pipe);
}

实现

在coding过程中,遇到了一个比较大的挑战就是对 delete 的实现。按照MDN的文档介绍,delete是要删除同名所有值,这里啃了好久,也妥妥的搞定了。总体实现就是在上一版的同步代码模型上,增加了10几个方法,覆盖住了MDN FormData上罗列的所有方法,并且支持了 stream 流动模式。

核心代码就是以下4个函数,均是对内置的data BufferList 进行编排:

 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
54
55
56
57
58
59
  append(name, value, filename = '') {
    const {
      data, dashDash, boundary, CRLF, indices,
    } = this;

    data.splice(...(data.length ? [-2, 1] : [0, 0, dashDash, boundary, CRLF]));
    indices.push([name, data.push(...this.formed(name, value, filename)) - 1]);
    data.push(CRLF, dashDash, boundary, dashDash, CRLF);

    return this;
  }

  formed(name, value, filename = '') {
    const { mimeTypes, CRLF, EMPTY } = this;
    const isBufferOrStream = Buffer.isBuffer(value) || (value instanceof ReadStream);
    return [
      Buffer.from(`Content-Disposition: form-data; name="${name}"${filename && isBufferOrStream ? `; filename="${filename}"` : ''}`),
      CRLF,
      ...(
        filename || isBufferOrStream
          ? [Buffer.from(`Content-Type: ${mimeTypes[extname(filename).substring(1).toLowerCase()] || 'application/octet-stream'}`), CRLF]
          : [EMPTY, EMPTY]
      ),
      CRLF,
      isBufferOrStream ? value : Buffer.from(String(value)),
    ];
  }

  set(name, value, filename = '') {
    if (this.has(name)) {
      this.indices.filter(([field]) => field === name).forEach(([field, index]) => {
        this.data.splice(index - 5, 6, ...this.formed(field, value, filename));
      });
    } else {
      this.append(name, value, filename);
    }

    return this;
  }

  delete(name) {
    this.indices = Object.values(this.indices.filter(([field]) => field === name).reduceRight((mapper, [, index]) => {
      this.data.splice(index - 8, 10);
      Reflect.deleteProperty(mapper, `${index}`);
      Object.entries(mapper).filter(([fixed]) => +fixed > index).forEach(([fixed, [field, idx]]) => {
        Reflect.set(mapper, `${fixed}`, [field, idx - 10]);
      });
      return mapper;
    }, this.indices.reduce((des, [field, value]) => {
      Reflect.set(des, value, [field, value]);
      return des;
    }, {})));

    if (!this.indices.length) {
      this.data.splice(0, this.data.length);
    }

    return this;
  }

应用

同步模式

1
2
3
4
5
6
7
(new Multipart())
  .append('a', 1)
  .append('b', '2')
  .append('c', Buffer.from('31'))
  .append('d', JSON.stringify({}), 'any.json')
  .append('e', require('fs').readFileSync('/path/your/file.jpg'), 'file.jpg')
  .getBuffer();

异步(流动)模式

1
2
3
(new Multipart())
  .append('f', require('fs').createReadStream('/path/your/file2.jpg'), 'file2.jpg')
  .pipe(require('fs').createWriteStream('./file3.jpg'));

示例上传代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const {readFileSync} = require('fs')

const {Wechatpay, Multipart, Hash: { sha256 }} = require('wechatpay-axios-plugin');

const wxpay = new Wechatpay({ mchid, secret, serial, privateKey, certs });

const file = readFileSync('./hellowechatpay.png');
const meta = {filename: 'hellowechatpay.png', sha256: sha256(file)};

const form = new Multipart();
form.append('file', file, 'hellowechatpay.png');
form.append('meta', JSON.stringify(meta), 'meta.json');

wxpay
  .v3.marketing.favor.media.imageUpload(form, { meta, headers: form.getHeaders() })
  .then(({data: { media_id }}) => media_id)
  .then(console.info)
  .catch(console.error);

更高级的stream流动模式下,一条龙从浏览器客户端至微信服务端的媒体直接上传,用js来实现,就变得很有可能了,这个留给喜欢钻研的同学搞一搞了。

最后

附上 源码地址测试用例地址,如果用着还不放心,可以再装一下 form-data包,通过 new FormData 来使用。

希望本文能解决你的困惑,如果喜欢,欢迎 Star

应用