笔记:表单提交中的 x-www-form-urlencoded 和 multipart/form-data

4,971 阅读5分钟
原文链接: moxo.io

演示文件放在了 Github 上:form-submit-demo,两个分支 urlencodeformdata (区别只在于 server.js<form>encrypt 属性的不同)对应两种不同的提交方式。

演示文件的由来是整理、翻查资料的时候找到了 Stack Overflow 上的这个问题 《What does enctype=‘multipart/form-data’ mean》的这个 213 票赞同的答案 ,虽然不是最高票,但提供了相当直观的演示。我提供的演示文件是用 NodeJs 写的,原理完全一致。

表单中 enctype 属性

HTML<form/> 表单的提交,当使用 methodPOST 时,可以指定属性 enctype,可能的值为以下三个;文档同时指出:method 中的 GETPOST 分别代表 HTTP 协议中的 GETPOST 方法,而 enctype 三个值分别都是是 MINE TYPE

  1. application/x-www-form-urlencoded;
  2. multipart/form-data;
  3. text/plain

提交表单及其 enctype 值与 HTTP 协议的基本关系

POSTHTTP 协议中可以理解为从客户端像服务端提交数据最简单、常用的方法,比如在网页上输入姓名电话信息交给服务器去做再次的发送、信息处理或者直接保存。

POST 方法起初是用来向服务器输入数据的。 实际上,通常会用它来支持 HTML 的表单。表单中填好的数据通常会被送给服务器,然后由服务器将其发送到它要去的地方(比如,送到一个服务器网关程序中,然后由这个程序对其进行处理)。

参考《HTTP 权威指南》中的 《3.3.5 POST》

enctype 的值在表单提交的时候一般会对应 HTTP 报文首部中的 Content-Type 值,但因为 HTTP 协议对请求主体的编码方式并没有规定,所以这里的值又不一定对应请求主体内容的编码方式。

HTTP 报文(在这里是请求报文)的组成分别是对报文进行描述的起始行 (start line)、包含属性的首部(header)块, 以及可选的、包含数据的主体(body)部分。

参考《HTTP 权威指南》的 《3.2 报文的组成部分》

请求报文的组成

Content-Type 的位置就位于 <headers> 部分,其本身属于「实体首部」范畴,用来描述有关实体及其内容的信息,从有关对象类型的信息,到能够对资源使用的各种有效的请求方法。

application/x-www-form-urlencoded

对应演示文件的 urlencode 分支

application/x-www-form-urlencoded 应该是最最常见的表单提交方式:

  1. 如果表单中的 enctype 不指定值,则表单默认采用的提交方式就是这种;
  2. 如果使用 jQuery 的 $.ajax() 方法,请求报文中的 Content-Type 默认也会采用这个值。

运行 node server.js 然后在终端中输入 while :; do clear; nc -l localhost 1333; sleep 2; done,回到浏览器在打开 localhost:1333,别在三个 input 选择 a.htmla.txtbw_5by5.jpg 三个文件,提交,观察终端中的输出。


请求体是: text1=text+default&text2=a%CF%89b&file1=a.txt&file2=binary&file3=bw_5by5.jpg

请求报文的首部:

请求报文主体:

不难看出这里的表单提交是将 <form/> 下所有表单控件的 namevalue 进行了组合然后提交给服务端做后续处理。同时也很明显,之前准备的属于文件类型的 a.txta.htmlbinary 的实际内容并没有被提交,被提交的只是一个文件名的字符串,需要使这些二进制文件得意提交,需要使用另一个 enctype 的值 multipart/form-data,之后会讨论到。

对内容拼接和转译的规范

The control names/values are listed in the order they appear in the document. The name is separated from the value by ‘=’ and name/value pairs are separated from each other by ‘&‘.

1.application/x-www-form-…

namevalue 间使用 =,多个 name=value 键值对间使用 & 连接的组合形式的由来,可以参考 w3.org 关于表单(forms)的文档中的 《17.13.3 Processing form data》

non-alphanumeric characters are replaced by `%HH’, a percent sign and two hexadecimal digits representing the ASCII code of the character。

除了 name 或者 value 中出现的所有空格必须使用 + 代替之外,还需要了对于内容中非数字、字母部分的转译,对应的浏览器 API 可以是 encodeURIComponent() 方法,使用这个方法对内容作转译处理,完成之后才能提交。


「空格需要被转义为 +」

w3 文档里有一句:Control names and values are escaped. Space characters are replaced by ‘+’ … 意思是「空格」需要被转移为 +,但是通过 encodeURIComponent() 方法转译得到的「空格」实际上是 %20

读了几个问题和分别指向的参考文章,似乎原因是有一定历史遗留问题 1,一个比较简单的答案在这,另外一个试图说清楚历史的答案在这

大概可以理解为,比如说有这么一条链接:http://www.example.com/some/path/to/resource?param1=value1,那么 ? 之前的「空格」必须被转译成 %20,而问号之后的部分则可以转译为两者之一:%20 或者 +,而如果的的确确需要一个 +,则使用 %2B 代替。

在例子中对应的可以观察第二个 input[type="text"] 的值 aωb, 一个 non-alphanumeric characters,需要被转译成为 % 和 ACSII 码结合的形式来表示。

前端中的数据提交

对前端来说一定非常熟悉 jQuery,其 .serialize() 方法其中就包含了这一部分的处理(具体实现参考源码的这里),单独处理的话可以参考以下这段伪代码,和一个叫做 form-serialize 的库。

multipart/form-data

对应演示文件的 formdata 分支, 操作同上。

表单提交的部分请求首部

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8ESPaucUYQl5HWa6,首部中 Content-Type 如期变成 multipart/form-data,但还跟上了一个尾巴:boundary=---

表单请求的主体内容

请求主体中则完全不同于之前的 x-www-form-urlencoded 生成的以 & 连接的键值对字符串。

请求主体中则完全不同于之前的 x-www-form-urlencoded 生成的以 & 连接的键值对字符串。这就不得不牵扯出了一个问题:multiplart/form-data 是什么?

multipart/form-data 是什么?

首先,名称 multipart/form-data 本身是 MIME 的一种。

In the case of multipart entities, in which one or more different sets of data are combined in a single body, a “multipart” media type field must appear in the entity’s header.

The body must then contain one or more body parts, each preceded by a boundary delimiter line, and the last one followed by a closing boundary delimiter line.

After its boundary delimiter line, each body part then consists of a header area, a blank line, and a body area.

5.1 Multipart Media Type,《RFC 2048: Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types》

其最早的由来可以追溯到 《RFC 1867: Form-based File Upload in HTML》 这个文档,1995 年的时候第一次为 HTML 中上传文件做的实验性(Experimental)规范,似乎一经发布就被广泛采用沿用至今。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

1. Abstract,《RFC 1867: Form-based File Upload in HTML》

同时从这个文档的标题就可以看出,multipart/form-data 是用来在 HTML 文档中上传二进制文件的。如其名 multipart/form-data 的意思,指传输的数据本身有很多部分,不管是多个文件的上传,还是既有字符串、又包含二进制文件的表单提交。

但这个文档中对 multipart/form-data 的具体格式并没有写的非常详细,只在第六部分的《Examples》当中给了一个很基本的范例,所以 1998 年又有了一份新的《RFC 2388:Returning Values from Forms: multipart/form-data 》来阐明 multipart/form-data 中的各个部分的具体格式。

后来,最近到了 2015 年 6 月,又被更加详细的 《RFC 7578 Returning Values from Forms: multipart/form-data》 代替。

multipart/form-data 的组成

以文档中提到最简单的例子来对 multipart/form-data 的组成格式来做一个基本的梳理

范例

参考 《RFC 1867: Form-based File Upload in HTML》 中的《第六部分 Example》。

关于组成各部分内容的一般说明

(对应范例)

  1. boundary:首先在请求首部中,Content-Type 指定为 multipart/form-data 之外,需要制定一个额外的值:boundaryboundary 的内容就是一串自定义字符串,可以直接写成 FormDataBoundary 或者随便什么。在上面的示例中,boundary 就是 AaB03x
  2. separator:定义了 boundary 之后,具体作为分隔符(separator)的是 -- + boundary 的格式;
  3. CRLF\r\n
  4. Content-disposition: form-data; name="field_name" 2
    1. multipart/form-data 传输的内容中有很多个部分,每个部分的开头都必须(must)要申明 content-disposition: form-data
    2. 并且指定当前的部分的 name3;
  5. Content-TypeCharset,针对每个部分可选(optional)的首部信息:
    1. Content-Type 可以不设置,默认值是 text/plain 4
    2. Charset 则可以通过设置为 _charset_ 来指定为 HTML 自身的编码5;
  6. CRLF\r\n
  7. CRLF\r\n(连续两个 newline);
  8. 这里就到了当前部分的内容本身。
  9. 内容结束之后这一部分结束,以一个 separator-- + boundary)结束;
  10. 下一部分重新开始。
  11. 整个 multipart/form-data 结束,最后需要一个结束标识:-- + boundary + --

multipart/form-data 的手动拼接

伪代码实现

知道了其格式的组成方式,就可以用代码手动来实现。虽然不多,在项目中也遇到过需要手动来发送 multipart/form-data 格式作为 ajax 发送的主题内容。以下就是一个最基本的伪代码实现,不过需要注意这里只是针对一个 field 中键值对的实现,实际中,更多时候需要对一个表单中的所有控件作循环处理。

前端开发中文件的 content 部分可以通过使用 FileReader.readAsArrayBuffer()new Blob(new Uint8Array(arrayBuffer)) 结合获得。

multipart/form-data 和 HTML5 中的 FromData 对象

HTML5 中提供的 FormData 对象实际上就浏览器提供将页面内的 Form 打包成 multipart/form-data 格式的 API。

The FormData object lets you compile a set of key/value pairs to send using XMLHttpRequest.

It is primarily intended for use in sending form data, but can be used independently from forms in order to transmit keyed data.

The transmitted data is in the same format that the form’s submit() method would use to send the data if the form’s encoding type were set to multipart/form-data.

《Using FormData Objects》

抛开浏览器兼容性,FormData 的使用,绝大部分时候都是和 ajax 一起:

提一下使用 jQuery 中 $.ajax() 方法提交 FormData 需要注意的两点–需要声明的两个参数:

  • contentType: false:上面提到 jQuery 会默认将 $.ajax 提交表单的请求首部中 Content-Type 值设定为 application/x-www-form-urlencoded,这个参数就是告诉 jQuery 不需要那样设置;
  • processData: false:和上面其实同理,jQuery 的 ajax 默认认为传入的 data 是一个键值对对象,需要转换成 urlencoded& 连接并且转译,传入 FormData 对象时候的格式就有别于此,不需要对 FormData 对象进行拼接和转译。

multipart/form-data 在后端的解析(使用 Nodejs)

可以拼接自然就可以解析,原理上相同:拼接的时候首先在报文头里指定 boundary,在报文主体中对以指定的 boundary 对内容做拼接,解析的时候自然就是反过来,从报文头里读取指定好了的 boundary,配合结构中 \r\n 等固定元素对整个流进行拆分。

不过说起来容易、做起来坑多:首先,获取到的文件是流,所以接受的时候,chunk 是在 req.on('data') 的时候不断拼接,单个文件可能非常巨大,所以在一个 chunk 中可能不会得到完整的文件进行存储。假设已经有一个针对 multipart/form-data 的解析方法,那么在这个流的不断接收的过程中需要对其中的字节流不断的读取,同时读取回来的内容需要放在一个暂存的位置 6

一直好奇为啥对于 formdata 的处理不被包含在标准库的功能中,不过还好由社区的力量。社区中常用的应该是 node-formiablemulter 两个包来处理请求中的 formdata 数据,前者可以配合 Express 框架使用也可以单独使用,相对灵活;后者则完全是一个 Express 的 middleware,调用的方法主要是 app.use(new mutler(option)) 这样。

node-formiable

multer


  1. 上面说的历史遗留中的历史可以参考以下两个文档:RFC 1738 - Uniform Resource Locators (URL)w3.org 的 URI spec 其中 Recommendations 下的 Query Stirng 部分。
  2. 《RFC 7585》 中的 《4.3 Multiple Files for One Form Field》,特别针对 <input type="file" multiple/>,也就是一个 name 对应多个文件的情况作了说明:To match widely deployed implementations, multiple files MUST be sent by supplying each file in a separate part but all with the same “name” parameter. 即使用多个部分,和同一个 name 来对应一个 input 上传的多个文件。
  3. 参考 《RFC 7578 》中的 4.2 章节
  4. 参考 《RFC 7585》中的 4.4 章节
  5. 参考 《RFC 7585》中的 4.5 章节
  6. 这里有一篇记录了遇到类似问题时比较详细的解决思路《Node.js如何解析Form上传? 》,可以参考。

感谢阅读

你们好, 2018 年初把小站从 Jekyll 迁移到 Hugo 的过程中,删除了评论区放的 Disqus 插件,考虑有二:首先无论评论、还是对笔记内容的进一步讨论,读者们更喜欢通过邮件、或者 Twitter 私信的方式来沟通;其次一年多以来 Disqus 后台能看到几乎都是垃圾留言(spam),所以这里直接贴一下邮件、以及 Twitter 账户 地址。

技术发展迭代很快,所以这些笔记内容也有类似新闻的时效性,不免有过时、或者错误的地方,欢迎指正 ^_^。

BEST
Lien(A.K.A 胡椒)