multipart/form-data 请求端详情

881 阅读6分钟

以下是根据解析库 form-data.js 的结果。

这里不说使用,而是说 multipart/form-data 的传输的格式。

1. 简单使用 form-data 上传文件

假如我使用的是如下的操作:

var form = new FormData();
form.append('fileName', '不知道');
form.append('my_file', fs.createReadStream('./test.txt'));

那么在传输中的格式如下:

----------------------------887941523663108122966319
Content-Disposition: form-data; name="fileName"

test.txt
----------------------------887941523663108122966319
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

这是测试文件传输。
----------------------------887941523663108122966319--

2. 聊聊 form-data 格式

其中每个属性,也就是定义的 name="fileName" 两边都有 -- 。也就是上面可以换成弄成下面这样:

-- 这是开头的 --
--------------------------887941523663108122966319
Content-Disposition: form-data; name="fileName"

test.txt
-- 这是结尾的 --

每个属性都包含分隔符,也就是 --------------------------887941523663108122966319 。当然也可以换成其他的,比如:test,这样也是可以的,由于这个是可以自定义的,那对于接收端来说,怎样知道分隔符是什么样子的嘛,首先我们得知道分隔符的名称叫 boundary 。而在发送端在指定 Content-Type 的时候我们需要同时指定 boundary 。也就是填写 Content-Type 的时候像下面这样:

multipart/form-data; boundary=--------------------------887941523663108122966319

除了上面的,中间的包含头部和内容,其中头部就是:

Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

其中 Content-Disposition 第一个固定值为 form-dataname 代表属性名, filename 代表文件名称。 Content-Type 代表这个文件的类型。像我给出的这个是文件,下面是文件内容:

这是测试文件传输。

现在我们写出 form-data 格式的模板了:

--${boundary}
Content-Disposition: form-data;name="${attrName}"; ${other}
${contentType}
${content}
--
...

--${boundary}--

在最后还有 ${boundary}-- 。 对应的值说明如下:

  • ${boundary} 代表 boundary 的取值。
  • ${attrName} 代表定义的属性名。
  • ${other} 如果是文件的话,这里还会有文件名 filename 等属性。
  • ${contentType} 代表这个属性对应值的 Content-Type 。
  • ${content} 代表内容,如果是普通值,那么代表属性名称;如果是文件则是文件中的内容。

3. 大文件说明

一般文件会特别的大,比如图片,里面有大量的二进制数据,不可能一次性就全部放入内容中,因为文件太大的话,内存不一定够用,这里的内存并不是硬盘的内存,而是处理器的内存,所以大的文件我们不可能先全部加载,然后再一起装入目标中,说白了我们计算机不一定有那么大的桶来装给定的水。

这个时候就需要使用到流 Stream 。 那么流是什么意思呢,就相当于水管,或者是一个小桶,先将大桶里面的水倒入小桶里面,小桶装满后再将小桶的水倒入目标中。也可以理解在目标桶和源桶之间用一根管子相连,这样水就可以源源不断的流入目标中。

4. form-data 源码分析

通过 form-data.js 源码得知,其继承关系如下:

FormData -> CombinedStream -> Stream

发现最终继承至 Stream 。 所以当我们进行 form.pipe(req) 时,也就是将我们定义好的数据流入到 req 中,其中 req 的继承关系如下:

http.ClientRequest -> Stream

所以这行代码也就是从 form -> req 中 。所以我们的数据即便很大,也是会慢慢流入到 req 中,并不是一次性全部装入 req 中。

4.1 append 函数分析

FormData.prototype.append = function(field, value, options) {
  // 如果没有传入 options,默认为空对象
  options = options || {};
  // options 可以传入字符串,如果传入字符串就当做属性 filename 的值
  if (typeof options == 'string') {
    options = {filename: options};
  }
  // 使用 CombinedStream.prototype.append 方法
  var append = CombinedStream.prototype.append.bind(this);
  // 如果传入的 value 是 number ,那么将其转换成字符串
  if (typeof value == 'number') {
    value = '' + value;
  }
  // 不支持数组
  if (util.isArray(value)) {
    this._error(new Error('Arrays are not supported.'));
    return;
  }
  /* 
  处理每一个属性的头部的,包含文件的类型,路径啥的信息,像:
  Content-Disposition: form-data; name="file"; filename="test.txt"
  Content-Type: text/plain
  */
  var header = this._multiPartHeader(field, value, options);
  var footer = this._multiPartFooter();
  // 每一个属性都是由三部分组成
  // header value footer
  append(header);
  append(value);
  append(footer);
  this._trackLength(header, value, options);
};

我们发现每一个属性都由三部分组成: header , value 和 footer 。

4.2 _multiPartHeader 函数

header :

/* 
处理每个属性的头部,也就是像这样的:
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain
*/
FormData.prototype._multiPartHeader = function(field, value, options) {
  if (typeof options.header == 'string') {
    return options.header;
  }
  // 得到每一个属性的 Content-Disposition 值
  var contentDisposition = this._getContentDisposition(value, options);
  /* 
   得到每一个属性的 Content-Type 值,如果自己要自定义,那么需要在 options 中定义
   contentType 的属性。
   1. 对于普通文本,也就是 value 是普通字符串,如:测试,那么得到的 contentType 就是 options 中的
   contentType 属性,如果 contentType 也没有设置,那么返回 [];
   2. 对于文件来说,由于传入的是 fs.createReadStream(path) ,所以通过阅读下面的几个属性:
    - name
    - path
    - readable
    - filepath
    - filename
    使用 fs.createReadStream 就可以达到想要的目的。
  */
  var contentType = this._getContentType(value, options);
  var contents = '';
  var headers  = {
    'Content-Disposition': ['form-data', 'name="' + field + '"'].concat(contentDisposition || []),
    'Content-Type': [].concat(contentType || [])
  };
  // allow custom headers. 允许自定义头部
  // 也就是如果 options 传入了 header ,那么合并两个头部
  if (typeof options.header == 'object') {
    // 合并原则是以 options.header 为遍历对象,
    // 如果 headers 中有的属性将就这个
    // 其余的采用 options.header
    populate(headers, options.header);
  }
  var header;
  for (var prop in headers) {
    if (!headers.hasOwnProperty(prop)) continue;
    header = headers[prop];
    if (header == null) {
      continue;
    }
    if (!Array.isArray(header)) {
      header = [header];
    }
    if (header.length) {
      contents += prop + ': ' + header.join('; ') + FormData.LINE_BREAK;
    }
  }
  return '--' + this.getBoundary() + FormData.LINE_BREAK + contents + FormData.LINE_BREAK;
};

我们发现这里的头部包含分隔符在内。

value :也就是传入的值。

footer :在这里就是 \n\r

5. 传输

我们知道只要涉及到传输不管数据原本是什么样子,到达物理层都会变成二进制,所以只要是没有经过处理的数据,接收到的时候都是二进制。而二进制数据到底是什么内容,就要看拿到数据的那一端怎么处理数据了,假如把图片的数据处理成文本,那么拿到的文本也都是乱码,至少是我们不能识别的内容。

那接收端怎样才能拿到正确的数据呢,这就需要双方的协议了,发送端发送内容的同时会告诉接收端,自己发送的内容是什么格式的,所以发送端都要指定 Content-Type ,来告诉接收端发送的是啥。

所以我们上传图片什么的,其实可以直接上传图片,假如接收端不额外发送端的其他信息的话;接收端接收到信息以后就信息放在一个文件中,然后使用图片解析工具去解析,看到的就是发送端发送的图片了。