nodejs-post文件上传原理详解
其中请求报文中的开始行和首部行包含了常见的各种信息,比如http协议版本,方法(GET/POST),accept-language,cookie等等。 而’实体主体’一般在post中使用,比如我们用表单上传文件,文件数据就是在这个’实体主体’当中.
总体上来说,对于post文件上传这样的过程,主要有以下几个部分:
-
获取http请求报文肿的头部信息,我们可以从中获得是否为POST方法,实体主体的总大小,边界字符串等,这些对于实体主体数据的解析都是非常重要的
-
获取POST数据(实体主体)
-
对POST数据进行解析
-
将数据写入文件
获取http请求报文头部信息
利用nodejs中的 http.ServerRequest中获取1):
-
request.method
用来标识请求类型
-
request.headers
其中我们关心两个字段:
content-type 包含了表单类型和边界字符串(下面会介绍)信息。
content-length post数据的长度
关于content-type
-
get请求的headers中没有content-type这个字段
-
post 的 content-type 有两种
-
application/x-www-form-urlencoded
这种就是一般的文本表单用post传地数据,只要将得到的data用querystring解析下就可以了 -
multipart/form-data
文件表单的传输,也是本文介绍的重点
获取POST数据
前面已经说过,post数据的传输是可能分包的,因此必然是异步的。post数据的接受过程如下:
var postData = ''; request.addListener("data", function(postDataChunk) { // 有新的数据包到达就执行 postData += postDataChunk; console.log("Received POST data chunk '"+ postDataChunk + "'."); }); request.addListener("end", function() { // 数据传输完毕 console.log('post data finish receiving: ' + postData ); });
注意,对于非文件post数据,上面以字符串接收是没问题的,但其实 postDataChunk 是一个 buffer 类型数据,在遇到二进制时,这样的接受方式存在问题。
POST数据的解析(multipart/form-data)
在解析POST数据之前,先介绍一下post数据的格式:
multipart/form-data类型的post数据
例如我们有表单如下
<FORM action="http://server.com/cgi/handle" enctype="multipart/form-data" method="post"> <P> What is your name? <INPUT type="text" name="submit-name"><BR> What files are you sending? <INPUT type="file" name="files"><BR> <INPUT type="submit" value="Send"> <INPUT type="reset"> </FORM>
若用户在text字段中输入‘Neekey’,并且在file字段中选择文件‘text.txt’,那么服务器端收到的post数据如下:
--AaB03x Content-Disposition: form-data; name="submit-name" Neekey --AaB03x Content-Disposition: form-data; name="files"; filename="file1.txt" Content-Type: text/plain ... contents of file1.txt ... --AaB03x--
若file字段为空:
--AaB03x Content-Disposition: form-data; name="submit-name" Neekey --AaB03x Content-Disposition: form-data; name="files"; filename="" Content-Type: text/plain --AaB03x--
若将file 的 input修改为可以多个文件一起上传:
<FORM action="http://server.com/cgi/handle" enctype="multipart/form-data" method="post"> <P> What is your name? <INPUT type="text" name="submit-name"><BR> What files are you sending? <INPUT type="file" name="files" multiple="multiple"><BR> <INPUT type="submit" value="Send"> <INPUT type="reset"> </FORM>
那么在text中输入‘Neekey’,并在file字段中选中两个文件’a.jpg’和’b.jpg’后:
--AaB03x Content-Disposition: form-data; name="submit-name" Neekey --AaB03x Content-Disposition: form-data; name="files"; filename="a.jpg" Content-Type: image/jpeg /* data of a.jpg */ --AaB03x Content-Disposition: form-data; name="files"; filename="b.jpg" Content-Type: image/jpeg /* data of b.jpg */ --AaB03x--// 可以发现 两个文件数据部分,他们的name值是一样的
数据规则
简单总结下post数据的规则
-
不同字段数据之间以边界字符串分隔;
-
每一行数据用”CR LF”(\r\n)分隔;
-
数据以 边界分割符 后面加上 –结尾,如:
-
每个字段数据的header信息(content-disposition/content-type)和字段数据以一个空行分隔:
--boundary\r\n // 注意,如上面的headers的例子,分割字符串应该是 ------WebKitFormBoundaryuP1WvwP2LyvHpNCi\r\n
数据解析基本思路
-
必须使用buffer来进行post数据的解析
利用文章一开始的方法(data += chunk, data为字符串 ),可以利用字符串的操作,轻易地解析出各自端的信息,但是这样有两个问题: -
文件的写入需要buffer类型的数据
-
二进制buffer转化为string,并做字符串操作后,起索引和字符串是不一致的(若原始数据就是字符串,一致),因此是先将不总的buffer数据的toString()复制给一个字符串,再利用字符串解析出个数据的start,end位置这样的方案也是不可取的。
-
利用边界字符串来分割各字段数据
-
每个字段数据中,使用空行(\r\n\r\n)来分割字段信息和字段数据
-
所有的数据都是以\r\n分割
-
利用上面的方法,我们以某种方式确定了数据在buffer中的start和end,利用buffer.splice( start, end ) 便可以进行文件写入了
-
文件写入
-
比较简单,使用 File System 模块.
var fs = new require( 'fs' ).writeStream, file = new fs( filename ); fs.write( buffer, function(){ fs.end(); });
node-formidable模块源码分析
各文件说明
file.js
file.js主要是封装了文件的写操作
incoming_from.js
模块的主体部分
multipart_parser.js
封装了对于POST数据的分段读取与解析的方法
querystring_parser.js
封装了对于GET数据的解析
总体思路
与我上面提到的思路不一样,node-formidable是边接受数据边进行解析。
上面那种方式是每一次有数据包到达后, 添加到buffer中,等所有数据都到齐后,再对数据进行解析.这种方式,在每次数据包到达的间隙是空闲的.
第二种方式使用边接收边解析的方式,对于大文件来说,能大大提升效率.
模块的核心文件主要是 multipart_parser.js 和 incoming_from.js 两个文件, 宏观上, multipartParser 用于解析数据, 比如给定一个buffer, 将在解析的过程中调用相应的回调函数.比如解析到字段数据的元信息(文件名,文件类型等), 将会使用 this.onHeaderField( buffer, start, end ) 这样的形式来传输信息. 而这些方法的具体实现则是在 incoming_form.js 文件中实现的. 下面着重对这两个文件的源码进行分析
multipart_form.js
这个模块是POST数据接受的核心。起核心思想是对每个接受到的partData进行解析,并触发相应时间,由于每次write方法的调用都将产生内部私有方法,所以partData将会被传送到各个触发事件当中,而触发事件(即对于partData的具体处理)的具体实现则是在incoming_form中实现,从这一点来说,两个模块是高度耦合的。
multipart_form 的源码读起来会比较吃力。必须在对post数据结构比较清楚的情况下,在看源码。
源码主要是四个部分:
-
全局变量(闭包内)
-
构造函数
-
初始化函数(initWithBoundary)
-
解析函数(write)
其中全局变量,构造函数比较简单。
初始化函数用 用传进的 边界字符串 构造boundary的buffer,主要用于在解析函数中做比较。 下面主要介绍下解析函数
几个容易迷惑的私有方法
-
make( name )
将当前索引(对buffer的遍历)复制给 this[ name ]. 这个方法就是做标记,用于记录一个数据段在buffer中的开始位置
-
callback( name , buffer, start, end )
调用this的onName方法,并传入buffer和start以及end三个参数。 比如当文件post数据中的文件部分的数据解析完毕,则通过callback( ‘partData’, buffer, start, end ) 将该数据段的首尾位置和buffer传递给 this.onPartData 方法,做进一步处理。
-
dataCallback( name, clear )
前面的callback,如果不看后面的三个参数,其本质不过是一个调用某个方法的桥接函数。而dataCallback则是对callback的一个封装,他将start和end传递给callback。
从源码中可以看到,start是通过mark(name)的返回值获得,而end则可能是当前遍历到的索引或者是buffer的末尾。
因此dataCallback被调用有二种情况:
-
在解析的数据部分的末尾在当前buffer的内部,这个时候mark记录的开始点和当前遍历到的i这个区段就是需要的数据,因此start = mark(name), end = i, 并且由于解析结束,需要将mark清除掉。
-
在当前buffer内,解析的数据部分尚未解析完毕(剩下的内容在下一个buffer里),因此start = mark(name), end = buffer.length
解析的主要部分
解析的主要部分是对buffer进行遍历,然后对于每一个字符,都根据当前的状态进行switch的各个case进行操作。
switch的每一个case都是一个解析状态。
具体看源码和注释,然后对照post的数据结构就会比较清楚。
其中 在状态:S.PART_DATA 这边,node-formidable做了一些处理,应该是针对 文章一开始介绍post数据格式中提到的 二级边界字符串 类型的数据处理。我没有深究,有兴趣的可以再研究下。