邹斌 ·2016-07-22 11:04

背景

前面两篇(基础篇进阶篇)主要介绍流的基本用法和原理,本篇从应用的角度,介绍如何使用管道进行程序设计,主要内容包括:

  1. 管道的概念
  2. Browserify的管道设计
  3. Gulp的管道设计
  4. 两种管道设计模式比较
  5. 实例

Pipeline

所谓“管道”,指的是通过a.pipe(b)的形式连接起来的多个Stream对象的组合。

假如现在有两个Transformboldred,分别可将文本流中某些关键字加粗和飘红。
可以按下面的方式对文本同时加粗和飘红:

  1. // source: 输入流
  2. // dest: 输出目的地
  3. source.pipe(bold).pipe(red).pipe(dest)

bold.pipe(red)便可以看作一个管道,输入流先后经过boldred的变换再输出。

但如果这种加粗且飘红的功能的应用场景很广,我们期望的使用方式是:

  1. // source: 输入流
  2. // dest: 输出目的地
  3. // pipeline: 加粗且飘红
  4. source.pipe(pipeline).pipe(dest)

此时,pipeline封装了bold.pipe(red),从逻辑上来讲,也称其为管道。
其实现可简化为:

  1. var pipeline = new Duplex()
  2. var streams = pipeline._streams = [bold, red]
  3.  
  4. // 底层写逻辑:将数据写入管道的第一个Stream,即bold
  5. pipeline._write = function (buf, enc, next) {
  6. streams[0].write(buf, enc, next)
  7. }
  8.  
  9. // 底层读逻辑:从管道的最后一个Stream(即red)中读取数据
  10. pipeline._read = function () {
  11. var buf
  12. var reads = 0
  13. var r = streams[streams.length - 1]
  14. // 将缓存读空
  15. while ((buf = r.read()) !== null) {
  16. pipeline.push(buf)
  17. reads++
  18. }
  19. if (reads === 0) {
  20. // 缓存本来为空,则等待新数据的到来
  21. r.once('readable', function () {
  22. pipeline._read()
  23. })
  24. }
  25. }
  26.  
  27. // 将各个Stream组合起来(此处等同于`bold.pipe(red)`)
  28. streams.reduce(function (r, next) {
  29. r.pipe(next)
  30. return next
  31. })

pipeline写数据时,数据直接写入bold,再流向red,最后从pipeline读数据时再从red中读出。

如果需要在中间新加一个underline的Stream,可以:

  1. pipeline._streams.splice(1, 0, underline)
  2. bold.unpipe(red)
  3. bold.pipe(underline).pipe(red)

如果要将red替换成green,可以:

  1. // 删除red
  2. pipeline._streams.pop()
  3. bold.unpipe(red)
  4.  
  5. // 添加green
  6. pipeline._streams.push(green)
  7. bold.pipe(green)

可见,这种管道的各个环节是可以修改的。

stream-splicer对上述逻辑进行了进一步封装,提供splicepushpop等方法,使得pipeline可以像数组那样被修改:

  1. var splicer = require('stream-splicer')
  2. var pipeline = splicer([bold, red])
  3. // 在中间添加underline
  4. pipeline.splice(1, 0, underline)
  5.  
  6. // 删除red
  7. pipeline.pop()
  8.  
  9. // 添加green
  10. pipeline.push(green)

labeled-stream-splicer在此基础上又添加了使用名字替代下标进行操作的功能:

  1. var splicer = require('labeled-stream-splicer')
  2. var pipeline = splicer([
  3. 'bold', bold,
  4. 'red', red,
  5. ])
  6.  
  7. // 在`red`前添加underline
  8. pipeline.splice('red', 0, underline)
  9.  
  10. // 删除`bold`
  11. pipeline.splice('bold', 1)

由于pipeline本身与其各个环节一样,也是一个Stream对象,因此可以嵌套:

  1. var splicer = require('labeled-stream-splicer')
  2. var pipeline = splicer([
  3. 'style', [ bold, red ],
  4. 'insert', [ comma ],
  5. ])
  6.  
  7. pipeline.get('style') // 取得管道:[bold, red]
  8. .splice(1, 0, underline) // 添加underline

Browserify

Browserify的功能介绍可见substack/browserify-handbook,其核心逻辑的实现在于管道的设计:

  1. var splicer = require('labeled-stream-splicer')
  2. var pipeline = splicer.obj([
  3. // 记录输入管道的数据,重建管道时直接将记录的数据写入。
  4. // 用于像watch时需要多次打包的情况
  5. 'record', [ this._recorder() ],
  6. // 依赖解析,预处理
  7. 'deps', [ this._mdeps ],
  8. // 处理JSON文件
  9. 'json', [ this._json() ],
  10. // 删除文件前面的BOM
  11. 'unbom', [ this._unbom() ],
  12. // 删除文件前面的`#!`行
  13. 'unshebang', [ this._unshebang() ],
  14. // 语法检查
  15. 'syntax', [ this._syntax() ],
  16. // 排序,以确保打包结果的稳定性
  17. 'sort', [ depsSort(dopts) ],
  18. // 对拥有同样内容的模块去重
  19. 'dedupe', [ this._dedupe() ],
  20. // 将id从文件路径转换成数字,避免暴露系统路径信息
  21. 'label', [ this._label(opts) ],
  22. // 为每个模块触发一次dep事件
  23. 'emit-deps', [ this._emitDeps() ],
  24. 'debug', [ this._debug(opts) ],
  25. // 将模块打包
  26. 'pack', [ this._bpack ],
  27. // 更多自定义的处理
  28. 'wrap', [],
  29. ])

每个模块用row表示,定义如下:

  1. {
  2. // 模块的唯一标识
  3. id: id,
  4. // 模块对应的文件路径
  5. file: '/path/to/file',
  6. // 模块内容
  7. source: '',
  8. // 模块的依赖
  9. deps: {
  10. // `require(expr)`
  11. expr: id,
  12. }
  13. }

wrap阶段前,所有的阶段都处理这样的对象流,且除pack外,都输出这样的流。
有的补充row中的一些信息,有的则对这些信息做一些变换,有的只是读取和输出。
一般row中的sourcedeps内容都是在deps阶段解析出来的。

下面提供一个修改Browserify管道的函数。

  1. var Transform = require('stream').Transform
  2. // 创建Transform对象
  3. function through(write, end) {
  4. return Transform({
  5. transform: write,
  6. flush: end,
  7. })
  8. }
  9.  
  10. // `b`为Browserify实例
  11. // 该插件可打印出打包时间
  12. function log(b) {
  13. // watch时需要重新打包,整个pipeline会被重建,所以也要重新修改
  14. b.on('reset', reset)
  15. // 修改当前pipeline
  16. reset()
  17.  
  18. function reset () {
  19. var time = null
  20. var bytes = 0
  21. b.pipeline.get('record').on('end', function () {
  22. // 以record阶段结束为起始时刻
  23. time = Date.now()
  24. })
  25.  
  26. // `wrap`是最后一个阶段,在其后添加记录结束时刻的Transform
  27. b.pipeline.get('wrap').push(through(write, end))
  28. function write (buf, enc, next) {
  29. // 累计大小
  30. bytes += buf.length
  31. this.push(buf)
  32. next()
  33. }
  34. function end () {
  35. // 打包时间
  36. var delta = Date.now() - time
  37. b.emit('time', delta)
  38. b.emit('bytes', bytes)
  39. b.emit('log', bytes + ' bytes written ('
  40. + (delta / 1000).toFixed(2) + ' seconds)'
  41. )
  42. this.push(null)
  43. }
  44. }
  45. }
  46.  
  47. var fs = require('fs')
  48. var browserify = require('browserify')
  49. var b = browserify(opts)
  50. // 应用插件
  51. b.plugin(log)
  52. b.bundle().pipe(fs.createWriteStream('bundle.js'))

事实上,这里的b.plugin(log)就是直接执行了log(b)

在插件中,可以修改b.pipeline中的任何一个环节。
因此,Browserify本身只保留了必要的功能,其它都由插件去实现,如watchifyfactor-bundle等。

除了了上述的插件机制外,Browserify还有一套Transform机制,即通过b.transform(transform)可以新增一些文件内容预处理的Transform。
预处理是发生在deps阶段的,当模块文件内容被读出来时,会经过这些Transform处理,然后才做依赖解析,如babelifyenvify

Gulp

Gulp的核心逻辑分成两块:任务调度与文件处理。
任务调度是基于orchestrator,而文件处理则是基于vinyl-fs

类似于Browserify提供的模块定义(用row表示),vinyl-fs也提供了文件定义(vinyl对象)。

Browserify的管道处理的是row流,Gulp管道处理vinyl流:

  1. gulp.task('scripts', ['clean'], function() {
  2. // Minify and copy all JavaScript (except vendor scripts)
  3. // with sourcemaps all the way down
  4. return gulp.src(paths.scripts)
  5. .pipe(sourcemaps.init())
  6. .pipe(coffee())
  7. .pipe(uglify())
  8. .pipe(concat('all.min.js'))
  9. .pipe(sourcemaps.write())
  10. .pipe(gulp.dest('build/js'));
  11. });

任务中创建的管道起始于gulp.src,终止于gulp.dest,中间有若干其它的Transform(插件)。

如果与Browserify的管道对比,可以发现Browserify是确定了一条具有完整功能的管道,而Gulp本身只提供了创建vinyl流和将vinyl流写入磁盘的工具,管道中间经历什么全由用户决定。
这是因为任务中做什么,是没有任何限制的,文件处理也只是常见的情况,并非一定要用gulp.srcgulp.dest

两种模式比较

BrowserifyGulp都借助管道的概念来实现插件机制。

Browserify定义了模块的数据结构,提供了默认的管道以处理这样的数据流,而插件可用来修改管道结构,以定制处理行为。

Gulp虽也定义了文件的数据结构,但只提供产生、消耗这种数据流的接口,完全由用户通过插件去构造处理管道。

当明确具体的处理需求时,可以像Browserify那样,构造一个基本的处理管道,以提供插件机制。
如果需要的是实现任意功能的管道,可以如Gulp那样,只提供数据流的抽象。

实例

本节中实现一个针对Git仓库自动生成changelog的工具,完整代码见ezchangelog

ezchangelog的输入为git log生成的文本流,输出默认为markdown格式的文本流,但可以修改为任意的自定义格式。

输入示意:

  1. commit 9c5829ce45567bedccda9beb7f5de17574ea9437
  2. Author: zoubin <zoubin04@gmail.com>
  3. Date: Sat Nov 7 18:42:35 2015 +0800
  4.  
  5. CHANGELOG
  6.  
  7. commit 3bf9055b732cc23a9c14f295ff91f48aed5ef31a
  8. Author: zoubin <zoubin04@gmail.com>
  9. Date: Sat Nov 7 18:41:37 2015 +0800
  10.  
  11. 4.0.3
  12.  
  13. commit 87abe8e12374079f73fc85c432604642059806ae
  14. Author: zoubin <zoubin04@gmail.com>
  15. Date: Sat Nov 7 18:41:32 2015 +0800
  16.  
  17. fix readme
  18. add more tests

输出示意:

  1. * [[`9c5829c`](https://github.com/zoubin/ezchangelog/commit/9c5829c)] CHANGELOG
  2.  
  3. ## [v4.0.3](https://github.com/zoubin/ezchangelog/commit/3bf9055) (2015-11-07)
  4.  
  5. * [[`87abe8e`](https://github.com/zoubin/ezchangelog/commit/87abe8e)] fix readme
  6.  
  7. add more tests

其实需要的是这样一个pipeline

  1. source.pipe(pipeline).pipe(dest)

可以分为两个阶段:

  • parse:从输入文本流中解析出commit信息
  • format: 将commit流变换为文本流

默认的情况下,要想得到示例中的markdown,需要解析出每个commit的sha1、日期、消息、是否为tag。
定义commit的格式如下:

  1. {
  2. commit: {
  3. // commit sha1
  4. long: '3bf9055b732cc23a9c14f295ff91f48aed5ef31a',
  5. short: '3bf9055',
  6. },
  7. committer: {
  8. // commit date
  9. date: new Date('Sat Nov 7 18:41:37 2015 +0800'),
  10. },
  11. // raw message lines
  12. messages: ['', ' 4.0.3', ''],
  13. // raw headers before the messages
  14. headers: [
  15. ['Author', 'zoubin <zoubin04@gmail.com>'],
  16. ['Date', 'Sat Nov 7 18:41:37 2015 +0800'],
  17. ],
  18. // the first non-empty message line
  19. subject: '4.0.3',
  20. // other message lines
  21. body: '',
  22. // git tag
  23. tag: 'v4.0.3',
  24. // link to the commit. opts.baseUrl should be specified.
  25. url: 'https://github.com/zoubin/ezchangelog/commit/3bf9055',
  26. }

于是有:

  1. var splicer = require('labeled-stream-splicer')
  2. pipeline = splicer.obj([
  3. 'parse', [
  4. // 按行分隔
  5. 'split', split(),
  6. // 生成commit对象,解析出sha1和日期
  7. 'commit', commit(),
  8. // 解析出tag
  9. 'tag', tag(),
  10. // 解析出url
  11. 'url', url({ baseUrl: opts.baseUrl }),
  12. ],
  13. 'format', [
  14. // 将commit组合成markdown文本
  15. 'markdownify', markdownify(),
  16. ],
  17. ])

至此,基本功能已经实现。
现在将其封装并提供插件机制。

  1. function Changelog(opts) {
  2. opts = opts || {}
  3. this._options = opts
  4. // 创建pipeline
  5. this.pipeline = splicer.obj([
  6. 'parse', [
  7. 'split', split(),
  8. 'commit', commit(),
  9. 'tag', tag(),
  10. 'url', url({ baseUrl: opts.baseUrl }),
  11. ],
  12. 'format', [
  13. 'markdownify', markdownify(),
  14. ],
  15. ])
  16.  
  17. // 应用插件
  18. ;[].concat(opts.plugin).filter(Boolean).forEach(function (p) {
  19. this.plugin(p)
  20. }, this)
  21. }
  22.  
  23. Changelog.prototype.plugin = function (p, opts) {
  24. if (Array.isArray(p)) {
  25. opts = p[1]
  26. p = p[0]
  27. }
  28. // 执行插件函数,修改pipeline
  29. p(this, opts)
  30. return this
  31. }

上面的实现提供了两种方式来应用插件。
一种是通过配置传入,另一种是创建实例后再调用plugin方法,本质一样。

为了使用方便,还可以简单封装一下。

  1. function changelog(opts) {
  2. return new Changelog(opts).pipeline
  3. }

这样,就可以如下方式使用:

  1. source.pipe(changelog()).pipe(dest)

这个已经非常接近我们的预期了。

现在来开发一个插件,修改默认的渲染方式。

  1. var through = require('through2')
  2.  
  3. function customFormatter(c) {
  4. // c是`Changelog`实例
  5.  
  6. // 添加解析author的transform
  7. c.pipeline.get('parse').push(through.obj(function (ci, enc, next) {
  8. // parse the author name from: 'zoubin <zoubin04@gmail.com>'
  9. ci.committer.author = ci.headers[0][1].split(/\s+/)[0]
  10. next(null, ci)
  11. }))
  12.  
  13. // 替换原有的渲染
  14. c.pipeline.get('format').splice('markdownify', 1, through.obj(function (ci, enc, next) {
  15. var sha1 = ci.commit.short
  16. sha1 = '[`' + sha1 + '`](' + c._options.baseUrl + sha1 + ')'
  17. var date = ci.committer.date.toISOString().slice(0, 10)
  18. next(null, '* ' + sha1 + ' ' + date + ' @' + ci.committer.author + '\n')
  19. }))
  20. }
  21.  
  22. source
  23. .pipe(changelog({
  24. baseUrl: 'https://github.com/zoubin/ezchangelog/commit/',
  25. plugin: [customFormatter],
  26. }))
  27. .pipe(dest)

同样的输入,输出将会是:

  1. * [`9c5829c`](https://github.com/zoubin/ezchangelog/commit/9c5829c) 2015-11-07 @zoubin
  2. * [`3bf9055`](https://github.com/zoubin/ezchangelog/commit/3bf9055) 2015-11-07 @zoubin
  3. * [`87abe8e`](https://github.com/zoubin/ezchangelog/commit/87abe8e) 2015-11-07 @zoubin

可以看出,通过创建可修改的管道,ezchangelog保持了本身逻辑的单一性,同时又提供了强大的自定义空间。

参考文献

Node.js Stream - 实战篇的更多相关文章

  1. Node.js Stream-基础篇

    Node.js Stream - 基础篇 邹斌 ·2016-07-08 11:51 背景 在构建较复杂的系统时,通常将其拆解为功能独立的若干部分.这些部分的接口遵循一定的规范,通过某种方式相连,以共同 ...

  2. 《Node.js开发实战详解》学习笔记

    <Node.js开发实战详解>学习笔记 ——持续更新中 一.NodeJS设计模式 1 . 单例模式 顾名思义,单例就是保证一个类只有一个实例,实现的方法是,先判断实例是否存在,如果存在则直 ...

  3. Koa与Node.js开发实战(3)——Nunjucks模板在Koa中的应用(视频演示)

    技术架构: ​ 在Koa中应用Nunjucks,需要先把Nunjucks集成为符合Koa规格的中间件(Middleware),从本质上来讲,集成后的中间件的作用是给上下文对象绑定一个render(vi ...

  4. Koa与Node.js开发实战(2)——使用Koa中间件获取响应时间(视频演示)

    学习架构: 在实战项目中,经常需要记录下服务器的响应时间,也就是从服务器接收到HTTP请求,到最终返回给客户端之间所耗时长.在Koa应用中,利用中间件机制可以很方便的实现这一功能.代码如下所示: 01 ...

  5. Koa与Node.js开发实战(1)——Koa安装搭建(视频演示)

    学习架构: 由于Koa2已经支持ES6及更高版本,包括支持async方法,所以请读者保证Node.js版本在7.6.0以上.如果需要在低于7.6的版本中应用Koa的async方法,建议使用Babel ...

  6. 9、Node.js Stream(流)

    #########################################################################介绍Node.js Stream(流)Stream 是 ...

  7. 腾讯高级工程师带你完整体验Node.js开发实战

    Node.js拥有广大的 JavaScript程序员基础并且完全开源,它被广泛地用在 Web服务.开发工作流.客户端应用等诸多领域.在 Web 服务开发这个领域,业界对 Node.js 的接受程度最高 ...

  8. Node.js Stream-进阶篇

    作者:美团点评技术团队链接:https://zhuanlan.zhihu.com/p/21681115来源:知乎著作权归作者所有.商业转载请联系作者获得授权,非商业转载请注明出处. 上篇(基础篇)主要 ...

  9. iKcamp新书上市《Koa与Node.js开发实战》

    内容摘要 Node.js 10已经进入LTS时代!其应用场景已经从脚手架.辅助前端开发(如SSR.PWA等)扩展到API中间层.代理层及专业的后端开发.Node.js在企业Web开发领域也日渐成熟,无 ...

随机推荐

  1. Shell编程和Vim操作

    其实一直不懂什么是shell,安卓adb调试时会使用一些简单的shell命令,总结一下 1.adb调试命令 全称:Android Debug Bridge 设置: export PATH=${PATH ...

  2. 令人崩溃的@requestBody乱码一例

    这个问题真是让我心力憔悴了...在客户现场对接就是乱码,StringHttpConverter怎么配置都不行... 场景其实很简单:客户那头post一个http请求,包体是json字符串,我这头spr ...

  3. Java连接程序数据源

    在实际应用中,可能需要根据表名动态地改变数据源,比如在程序数据集中,通过传进的表名参数,到数据库取出对应的表作为数据源.例如,FineReport是通过AbstractTableData抽象类来读取数 ...

  4. 树莓派搭建ActiveMQ

    树莓派上安装ActiveMQ和在其它Linux发行版基本相同,只是在开防火墙端口时有区别.   硬件信息: 树莓派3B型,Raspbian系统   安装 //下载ActiveMQ安装包 http:// ...

  5. UVA11324 The Largest Clique[强连通分量 缩点 DP]

    UVA - 11324 The Largest Clique 题意:求一个节点数最大的节点集,使任意两个节点至少从一个可以到另一个 同一个SCC要选一定全选 求SCC 缩点建一个新图得到一个DAG,直 ...

  6. iOS状态栏---学习笔记六

    一.设置状态栏的颜色. //1.需要在自定义导航的时候,设置顶部视图 - (UIViewController *)childViewControllerForStatusBarStyle{ retur ...

  7. hibernate单表junit测试

    首先,创建java project ,导入需要的jar包 添加hibernate.cfg.xml <?xml version='1.0' encoding='UTF-8'?> <!D ...

  8. C语言基础(一)

    7744问题(输出所有形如aabb的4位完全平方数) 方法1: #include<stdio.h> #include<math.h> int main (){ ;a<=; ...

  9. [CareerCup] 17.2 Tic Tac Toe 井字棋游戏

    17.2 Design an algorithm to figure out if someone has won a game oftic-tac-toe. 这道题让我们判断玩家是否能赢井字棋游戏, ...

  10. Servlet和JSP

    Servlet 一.Servlet 的生命周期. servlet 有良好的生存期的定义,包括加载和实例化.初始化.处理请求以及服务结束.这个生存期由javax.servlet.Servlet 接口 的 ...