写这篇文章的起因是我在尝试使用 Node 的原生模块去接收表单上传的数据并分割过滤表单文件与数据时,由于一开始是对可写流拼接 buffer 转的字符串进行轮询,导致只能接收文件格式为 txt 的文本文件,在接收图片或其他文件格式时却发生了写入后无法打开的情况,而 txt 文件的中文也会乱码,这是因为 buffer 在转字符串时默认为 utf8 格式,进行写入后,无法识别中文,而其他文件格式在无法判别 encoding 的情况下,在编码的转换截取中极其容易被截漏或截错。

由于在网上根本没有一篇真正告诉你怎么用 Node 原生代码去处理表单的过程,即使有的也都是错的,我是都测试过的,因为它们的原理大都跟我一样都是对字符串进行轮询,这是错误的处理方式。

所以经过对各大中间件进行追本溯源的过程后,我找到了专门用来处理表单(MIME 为 multipart/form-data)的中间件 multiparty,遂决定看懂后一定要自己过滤一遍并写出文章给其他跟我有类似问题的童鞋参考。

文章已同步至我的 github https://github.com/YOLO0927/multiparty-source-code-analysis

下面放上解析

本中间件的构造函数 Form 与直接暴漏的过滤函数 Form.prototype.parse 都十分简单,我不在此赘述,因为这个 2 个环节非常简单,稍微有点基础的同学都应该能看懂,重要的是我下面分析的 Form.prototype._write 方法,这是一个对可写流内部传入底层写入方法的一个重写,在包内你不会找到任何一个地方有调用这个方法,所以大家一定要看 Node 文档 Stream 模块中对 writable._write 方法的描述

大家一定要注意我标记的三行解释,它充分的告诉了我们,这个方法是内部方法,只要我们利用继承,将 From 继承 Stream.Writable 即可实现,下面放上我对 Form.prototype._write 的整段分析,而整个中间件的核心也就是这个方法了,其他多为此方法的辅助函数

首先童鞋们一定要注意源码 line:50 位置的util.inherits(Form, stream.Writable)
大家看到这个方法可能会找不到此包在哪里调用,其实这关键的操作在于包一旦引入便进行了 Form 继承 写入流 stream.Writable 的操作,原本可写流的内部方法 writable._write 被 Form 原型中的 _write 先传入底层,再次实例化 Form 调用 write 时将会使用的底层函数会是我们写的 Form.prototype._write由此我们不但使 Form 的实例化对象具有写入流,并且还重写了写入的逻辑
达到每次 transform 流都会调用我们在这里重写的 _write 方法,在 parse 方法的最后调用 req.pipe(self) 时由管道流将 req 写入 Form 实例时触发调用

Form.prototype._write = function(buffer, encoding, cb) {if (this.error) return;var self = this;var i = 0;var len = buffer.length;  // 轮询表单数据 buffer 的长度var prevIndex = self.index;var index = self.index;var state = self.state; // 轮询表单数据 buffer 时的状态var lookbehind = self.lookbehind;var boundary = self.boundary; // 通过 parse 方法里拿到的分隔符 boundaryvar boundaryChars = self.boundaryChars;var boundaryLength = self.boundary.length;var boundaryEnd = boundaryLength - 1;var bufferLength = buffer.length;var c;var cl;// boundary = \r\n------XXXXXXXXXXXXXX// 全过程对 buffer 进行循环,所以不存在任何文件的格式问题,如果是对 buffer.toString 后的字符串进行循环,是无法判断收集到的文件是何种格式的,这点是核心关键// 所以我们只能对 buffer 进行循环,在循环中根据 buffer 字节码判断 : - LF RF 等符号来判断到底循环到哪个步骤,这是过滤难点for (i = 0; i < len; i++) {c = buffer[i];switch (state) {case START:// 记住 index 永远最大只是 boundary 的长度index = 0;state = START_BOUNDARY;/* falls through */case START_BOUNDARY:console.log(`${i}, ${c} === ${boundary[index+2]}, ${index}, ${boundary[index]}`)if (index === boundaryLength - 2 && c === HYPHEN) {index = 1;state = CLOSE_BOUNDARY;break;} else if (index === boundaryLength - 2) {// 如果此时 c 对应 boundary 倒数第二位是换行符 CR CR => 13(buffer 中回车符 \r 是13)if (c !== CR) return self.handleError(createError(400, 'Expected CR Received ' + c));index++;break;} else if (index === boundaryLength - 1) {// 如果 c 对应 boundary 最后一位不是换行符 LF LF => 10(buffer 中换行符 \n 是10)if (c !== LF) return self.handleError(createError(400, 'Expected LF Received ' + c));index = 0;self.onParsePartBegin();state = HEADER_FIELD_START;break;}if (c !== boundary[index+2]) index = -2;if (c === boundary[index+2]) index++;break;case HEADER_FIELD_START:// 此时这里已经是 Conteng--Disposition 的开头了state = HEADER_FIELD;// 记录此时 boundary 换行后的第一个位置下标self.headerFieldMark = i;// 将 index 重置,在记录 data 值的时候会用到index = 0;/* falls through */case HEADER_FIELD:// 此时如果直接遇到开头即为回车符的情况,那么肯定是表单上传的第一个表单信息的头部以获取完毕,在下一行一定会是此表单第一个 data 的值if (c === CR) {self.headerFieldMark = null;state = HEADERS_ALMOST_DONE;break;}index++;if (c === HYPHEN) break;// 如果是当前 buffer 循环到 58 => 冒号时if (c === COLON) {if (index === 1) {// empty header fieldself.handleError(createError(400, 'Empty header field'));return;}// 截取了头部的 key,第一个是 Content-Dispositionself.onParseHeaderField(buffer.slice(self.headerFieldMark, i));self.headerFieldMark = null;state = HEADER_VALUE_START;break;}cl = lower(c);if (cl < A || cl > Z) {self.handleError(createError(400, 'Expected alphabetic character, received ' + c));return;}break;case HEADER_VALUE_START:if (c === SPACE) break;// 记录冒号空格后的位置,即 value 值的第一个位置self.headerValueMark = i;state = HEADER_VALUE;/* falls through */case HEADER_VALUE:// 当到达回车符时,开始对本行的头部 key 对应的 value 进行过滤操作if (c === CR) {// 截取 value 并赋值给 form.headerValueself.onParseHeaderValue(buffer.slice(self.headerValueMark, i));self.headerValueMark = null;// 在 form.partHeaders 对象中记录对应 key value,并在其中过滤 Content-Disposition 中的 name 与 filename 字段出来为独立的 key value 等// 最后重置 headerField 与 headerValue 等待接受下一次循环过滤self.onParseHeaderEnd();state = HEADER_VALUE_ALMOST_DONE;}break;case HEADER_VALUE_ALMOST_DONE:// 不出意外,此时一定是会到换行符的,每行结尾不论是 windows、Unix、macOs 任何操作系统都会是换行符 LF,此时重置 buffer 的循环状态,获取下一行的头部 keyif (c !== LF) return self.handleError(createError(400, 'Expected LF Received ' + c));state = HEADER_FIELD_START;break;case HEADERS_ALMOST_DONE:// 回车符后紧跟换行符if (c !== LF) return self.handleError(createError(400, 'Expected LF Received ' + c));var err = self.onParseHeadersEnd(i + 1);if (err) return self.handleError(err);state = PART_DATA_START;break;case PART_DATA_START:state = PART_DATA;// 记录表单 data 值开头的当前位置self.partDataMark = i;/* falls through */case PART_DATA:// 开始过滤每个表单的 data 值prevIndex = index;// 在 HEADER_FIELD_START 阶段,index 已重置为 0,所以表单每个头部信息过滤完毕后开始过滤 data 值时一定会先时 0if (index === 0) {// 使用 boyer-moore 字符串算法安全的跳过没有 boundadry 的数据行// boyer-moore derrived algorithm to safely skip non-boundary datai += boundaryEnd;while (i < bufferLength && !(buffer[i] in boundaryChars)) {i += boundaryLength;}i -= boundaryEnd;c = buffer[i];}if (index < boundaryLength) {// 当完全跳过没有 boundary 的数据后,此时 buffer 已经轮循到下一个 boundary 时,即下一个表单头,此时已经计算出 data 需要截取的终点// 由于表单上传的最后也是以分隔符 boundary 结束的,所以我们再次用 index 记录何时才到最后整个表单数据的结尾if (boundary[index] === c) {if (index === 0) {// 开始截取 buffer 并流式写入 dataself.onParsePartData(buffer.slice(self.partDataMark, i));self.partDataMark = null;}index++;} else {index = 0;}} else if (index === boundaryLength) {index++;// 如果一段完毕后是回车府而不是 HYPERN => '-', 则代表表单数据还没到最后一个结尾分隔符,此时我们依照步骤继续下一个头的过滤// 如果到达了最后一行的分隔符位置,则开始进行收尾状态 CLOSE_BOUNDARY// 此为最后一行的一个例子 -----WebKitFormBoundarynvsHLTGB18H2f0ib--,没到最后一行,最后是不会有 '--' 这 2 个 HYPHEN 的if (c === CR) {// CR = part boundaryself.partBoundaryFlag = true;} else if (c === HYPHEN) {index = 1;state = CLOSE_BOUNDARY;break;} else {index = 0;}} else if (index - 1 === boundaryLength)  {if (self.partBoundaryFlag) {index = 0;if (c === LF) {self.partBoundaryFlag = false;self.onParsePartEnd();self.onParsePartBegin();state = HEADER_FIELD_START;break;}} else {index = 0;}}if (index > 0) {// when matching a possible boundary, keep a lookbehind reference// in case it turns out to be a false leadlookbehind[index-1] = c;} else if (prevIndex > 0) {// if our boundary turned out to be rubbish, the captured lookbehind// belongs to partDataself.onParsePartData(lookbehind.slice(0, prevIndex));prevIndex = 0;self.partDataMark = i;// reconsider the current character even so it interrupted the sequence// it could be the beginning of a new sequencei--;}break;case CLOSE_BOUNDARY:if (c !== HYPHEN) return self.handleError(createError(400, 'Expected HYPHEN Received ' + c));if (index === 1) {self.onParsePartEnd();state = END;} else if (index > 1) {return self.handleError(new Error("Parser has invalid state."));}index++;break;case END:break;default:self.handleError(new Error("Parser has invalid state."));return;}}if (self.headerFieldMark != null) {self.onParseHeaderField(buffer.slice(self.headerFieldMark));self.headerFieldMark = 0;}if (self.headerValueMark != null) {self.onParseHeaderValue(buffer.slice(self.headerValueMark));self.headerValueMark = 0;}if (self.partDataMark != null) {self.onParsePartData(buffer.slice(self.partDataMark));self.partDataMark = 0;}self.index = index;self.state = state;self.bytesReceived += buffer.length;self.emit('progress', self.bytesReceived, self.bytesExpected);if (self.backpressure) {self.writeCbs.push(cb);} else {cb();}
};

针对不知道为什么 pipe 会触发 writable._write 的同学,我在这里清楚的解释下
由于 pipe 的原理实际就是监测 Readable 的 data 事件,然后在其中不停调用 Writable.write(chunk),如下简单实现 Readable.prototype.pipe(writable, options)

  readable.on('data', (chunk) => {// 当发生背压时(即当前可用内存已写满此类情况)if (writable.write(chunk) === false) {readable.pause()}})// drain 事件在写入流再次可继续写入时触发,此时将可读流恢复读取即可再次触发 data 事件去 writewritable.on('drain', () => {readable.resume()})

由上述简单实现我们已经看到了在 pipe 管道流的过程中,我们是不断的在调用 write 的,而 write 的底层实现就是 _write,所以我们改写的 _write 就会被调用啦。

最后当 pipe 读写完毕后即 _write 全部调用完后 writable 会源码会进行以下顺序触发调用

  1. 触发 finish 事件从而使 setUpParser 函数中监听 finish 的函数发生调用
  2. 紧接着调用 endFlush => maybeClose => holdEmitQueue闭包调用
  3. 下一事件循环触发 close 事件 => 由此触发 Form.prototype.parse 由回调时监听的 close 事件,而将表单的 field 与 file 都注入使用者的回调内

下面是我打印的调用顺序,由于 pipe 是一个基于事件循环(event loop)的异步过程,所以第一次 pipe 完一定会是会在下一个事件循环队列,所以我们必须跳过第一次的主进程循环队列的 macro task 所以,下面是我的 log 过程及结果

插入标记


输出结果

这就是最好的证明了~

大家最好是在 IDE 中对着源码看会比较容易看点,如果大家像我一样一遍看不懂就看两遍,不行就三遍,因为我就是这么做的= =。。。

如文中有错误的知识描述,大家告诉我后,我会立刻更改。

multiparty 中间件源码解析相关推荐

  1. Redux 异步数据流-- thunk中间件源码解析

    Thunk 引入背景 这是一个关于Redux异步数据流的故事.引入thunk中间件的完整故事在Redux官方中文文档异步数据流.一句话总结就是:原生Redux只支持同步数据流,所以需要引入中间件(mi ...

  2. Gin源码解析和例子——中间件(middleware)

    在<Gin源码解析和例子--路由>一文中,我们已经初识中间件.本文将继续探讨这个技术.(转载请指明出于breaksoftware的csdn博客) Gin的中间件,本质是一个匿名回调函数.这 ...

  3. .Net Core 中间件之主机地址过滤(HostFiltering)源码解析

    一.介绍 主机地址过滤中间件相当于一个白名单,标记哪些主机地址能访问接口. 二.使用 新建WebAPI项目,修改Startup中的代码段如下所示.下面表示允许主机名为"localhost&q ...

  4. 解析多层list_基于laravel5.2进行中间件源码的解析

    在laravel5.2中,Http的主要作用就是过滤Http请求(php aritsan是没有中间件机制的),同时也让系统的层次(Http过滤层)更明确,使用起来也很优雅.但实现中间件的代码却很复杂, ...

  5. python flask源码解析_用尽洪荒之力学习Flask源码

    [TOC] 一直想做源码阅读这件事,总感觉难度太高时间太少,可望不可见.最近正好时间充裕,决定试试做一下,并记录一下学习心得. 首先说明一下,本文研究的Flask版本是0.12. 首先做个小示例,在p ...

  6. Go http源码解析(一)

    Go web之旅 此篇开始将开启Go web之旅,我将这趟旅途分为三个子旅程: 源码解析 框架解读 中间件使用 所以在这趟旅途中我们将领略源码之雄伟,框架之奇艳,中间件之灵秀.在接下来的时间里我会按照 ...

  7. mysql 网络io_分布式 | DBLE 网络模块源码解析(一):网络 IO 基础知识

    作者:路路 热爱技术.乐于分享的技术人,目前主要从事数据库相关技术的研究. 本文来源:原创投稿 *爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源. 前言 对于计算机学科来说 ...

  8. 路由框架ARouter最全源码解析

    ARouter是2017年阿里巴巴开源的一款Android路由框架,官方定义: ARouter是Android平台中对页面,服务提供路由功能的中间件,提倡简单且够用 有下面几个优势: 1.直接解析UR ...

  9. Laravel源码解析之从入口开始

    前言 提升能力的方法并非使用更多工具,而是解刨自己所使用的工具.今天我们从Laravel启动的第一步开始讲起. 入口文件 laravel是单入口框架,所有请求必将经过index.php define( ...

最新文章

  1. travis-ci自动部署_如何使用Travis CI部署(几乎)零恐惧的Cloud Foundry应用
  2. C#中使用Directory实现对文件夹的常用操作
  3. docker 和挂载文件一起打包成新镜像_Docker文件系统和数据卷
  4. p2000专业软件测试对比,对比说说丽台p2000和p2200对比哪个好些?有何区别呢?良心点评实际情况...
  5. 微信挑战者飞聊被下架后 再度火速上线 尚能一战否?
  6. C# WinForm 文件上传下载
  7. Selenium 调用IEDriverServer打开IE浏览器
  8. php for 每次增加2,php – 为什么foreach会将refcount增加2而不是1?
  9. 【数学建模】day05-微分方程建模
  10. python将word文档转换为txt
  11. 智慧园区中心服务平台建议方案
  12. next项目部署到服务器pm2进程守护
  13. 日紫白飞星算法_年月日时紫白飞星算法
  14. 微信电脑版多用户登录
  15. osgEarth的Rex引擎原理分析(一一四)rex与mp引擎的关系
  16. Android耗电统计算法
  17. Cesium模型制作服务
  18. 实验五 网络编程与安全 20162316 刘诚昊
  19. 智能家居设备可能被利用变成家庭虐待的工具
  20. 用自己的数据集进行遥感图像分类---------u-net改进版dlinknet

热门文章

  1. 个人淘客推广App — 支持淘宝、京东、唯品会、拼多多、美团推广
  2. 写个脚本,一键打开软件文档(Windows)
  3. 正态分布,二维正态分布,卡方分布,学生t分布——概率分布学习 python
  4. 在ImportNew上翻译的文章列表
  5. 美国股市表现强劲,近三分之一的美国购房者全款买房
  6. canvas绘制圆制作吃豆豆,结果在计时器中加入clearRect也毫无作用,无法清除上一次绘制的动画
  7. 公认最搞笑的15则冷笑话
  8. HBase集成Hive详解
  9. 研究人员发布用于自动驾驶的开源逼真模拟器-译-
  10. leaflet之轨迹回放(一 Leaflet.MovingMarker)