nodejs-post文件上传原理详解

jkiu 9年前

                其中请求报文中的开始行和首部行包含了常见的各种信息,比如http协议版本,方法(GET/POST),accept-language,cookie等等。 而’实体主体’一般在post中使用,比如我们用表单上传文件,文件数据就是在这个’实体主体’当中.

                 总体上来说,对于post文件上传这样的过程,主要有以下几个部分:

  1. 获取http请求报文肿的头部信息,我们可以从中获得是否为POST方法,实体主体的总大小,边界字符串等,这些对于实体主体数据的解析都是非常重要的

  2. 获取POST数据(实体主体)

  3. 对POST数据进行解析

  4. 将数据写入文件

             获取http请求报文头部信息

               利用nodejs中的 http.ServerRequest中获取1):

      • request.method

               用来标识请求类型

  •              request.headers

nodejs-post文件上传原理详解

               其中我们关心两个字段:

                content-type      包含了表单类型和边界字符串(下面会介绍)信息。

                content-length      post数据的长度

         关于content-type

  • get请求的headers中没有content-type这个字段

  • post 的 content-type 有两种

    1. application/x-www-form-urlencoded
      这种就是一般的文本表单用post传地数据,只要将得到的data用querystring解析下就可以了

    2. 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数据的规则

  1. 不同字段数据之间以边界字符串分隔;

  2. 每一行数据用”CR LF”(\r\n)分隔;

  3. 数据以 边界分割符 后面加上 –结尾,如:

  4. 每个字段数据的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是边接受数据边进行解析。

nodejs-post文件上传原理详解

上面那种方式是每一次有数据包到达后, 添加到buffer中,等所有数据都到齐后,再对数据进行解析.这种方式,在每次数据包到达的间隙是空闲的.

第二种方式使用边接收边解析的方式,对于大文件来说,能大大提升效率.

模块的核心文件主要是 multipart_parser.js 和 incoming_from.js 两个文件, 宏观上, multipartParser 用于解析数据, 比如给定一个buffer, 将在解析的过程中调用相应的回调函数.比如解析到字段数据的元信息(文件名,文件类型等), 将会使用 this.onHeaderField( buffer, start, end ) 这样的形式来传输信息. 而这些方法的具体实现则是在 incoming_form.js 文件中实现的. 下面着重对这两个文件的源码进行分析 nodejs-post文件上传原理详解

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被调用有二种情况:

  1. 在解析的数据部分的末尾在当前buffer的内部,这个时候mark记录的开始点和当前遍历到的i这个区段就是需要的数据,因此start = mark(name), end = i, 并且由于解析结束,需要将mark清除掉。

  2. 在当前buffer内,解析的数据部分尚未解析完毕(剩下的内容在下一个buffer里),因此start = mark(name), end = buffer.length

解析的主要部分

解析的主要部分是对buffer进行遍历,然后对于每一个字符,都根据当前的状态进行switch的各个case进行操作。

switch的每一个case都是一个解析状态。

具体看源码和注释,然后对照post的数据结构就会比较清楚。

其中 在状态:S.PART_DATA 这边,node-formidable做了一些处理,应该是针对 文章一开始介绍post数据格式中提到的 二级边界字符串 类型的数据处理。我没有深究,有兴趣的可以再研究下。