Node.js硬实战 - 编写模块

杨旭 bio photo By 杨旭

构建模块并回馈给社区


第十三章 掌握Node的所有


  • 计划开发一个模块
  • 设置package.json
  • 依赖处理与与异化版本号
  • 添加可执行脚本
  • 模块测试
  • 发布模块

头脑风暴:明确需要做什么

使用递归来实现斐波那契数列计算

function fibonacci(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

因为JavaScript引擎的单线程机制,该算法在V8引擎中运行是很慢的;而且因为没有尾递归优化机制,运算过程中可能导致堆栈移除。

编写一个模块来实现一个更快的斐波那契数列计算

规划

  • 调查一下现有模块,并且确保自己的模块只专注于一件事
$ npm search fibonacci

在列表中可以看到搜索结果以及历史版本,可以观察模块的发布日期以及更新频率等信息

创建一个自己的模块

  • 使用一句话描述自己的模块:简单和明确
Calculate fibonacci as quickly as possible with JavaScript

创建目录并初始化package.json

$ mkdir fastfib && cd fastfib
$ yarn init

使用TDD来验证我们的想法

想法是否可行?目的是否明确?功能是否可用?

// test/fastfib.spec.js
const assert = require('assert')
const fastfib = require('../index')

assert.equal(fastfib(0), 0)
assert.equal(fastfib(1), 1)
assert.equal(fastfib(2), 1)
assert.equal(fastfib(3), 2)
assert.equal(fastfib(4), 3)
assert.equal(fastfib(5), 5)
assert.equal(fastfib(6), 8)
assert.equal(fastfib(7), 13)
assert.equal(fastfib(8), 21)
assert.equal(fastfib(9), 34)
assert.equal(fastfib(10), 55)
assert.equal(fastfib(11), 89)
assert.equal(fastfib(12), 144)

console.log('Test passed')

创建lib目录以及实现代码

// lib/recurse.js
function recurse(n) {
    if (n === 0) return 0
    if (n === 1) return 1
    return recurse(n - 1) + recurse(n - 2)
}

module.exports = recurse

在根目录创建入口文件index.js

const recurse = require('./lib/recurse')

module.exports = recurse

运行测试并校验结果

基准测试

我们的目的是实现一个更快的算法,那么现在的性能怎么样?

  • 使用Benchmark.js来进行基准测试
$ yarn add benchmark --dev
  • 创建一个benchmark目录并添加index.js文件来执行基准测试 ```js const assert = require(‘assert’) const recurse = require(‘../lib/recurse’) const suite = new (require(‘benchmark’)).Suite // 创建测试套件

suite .add(‘recurse’, () => { // 添加一个测试,计算20个数字 recurse(20) }) .on(‘complete’, function() { console.log(‘result: ‘) this.forEach((result) => { // 输出测试结果 console.log(result.name, result.count, result.times.elapsed) })

    assert.equal( // 断言递归的方式是正确的
        this.filter('fastest').map('name'),
        'recurse',
        'expect recurse to be the fastest'
    )
})
.run() ```

执行结果如下,在5.424秒内执行了507次

result: 
recurse 507 5.424

当前实现没有实现尾递归优化,lib目录下新增一个tail.js


function tail (n) {
    return fib(n, 0, 1)
}

function fib(n, current, next) {
    if (n === 0) return current
    return fib(n - 1, next, current + next)
}

module.exports = tail

尾递归优化

函数调用会在内存形成一个”调用记录”,又称”调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个”调用栈”(call stack)

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

“尾调用优化”(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。


  • 添加一个对比测试
      .add('recurse', () => { // 添加一个测试,计算20个数字
          recurse(20)
      })
      .add('tail', () => { // 添加一个测试,计算20个数字
          tail(20)
      })
    

测试结果中,原有的断言失败,因为尾递归拥有更高的效率

在入口文件index.js中,将尾递归作为默认实现

module.exports = require('./lib/tail')

执行功能测试,保证功能正确

尝试更快的算法:使用迭代

function iter(n) {
    let current = 0
    let next = 1
    let swap = 0

    for (let i = 0; i < n; i++) {
        swap = current
        current = next
        next = swap + next
    }

    return current
}

module.exports = iter

添加对比测试并验证结果

suite
    .add('recurse', () => { // 添加一个测试,计算20个数字
        recurse(20)
    })
    .add('tail', () => {
        tail(20)
    })
    .add('iter', () => {
        iter(20)
    })

使用iter作为最终实现,最后的目录结构如下:

编辑package.json

{
  "name": "fastfib-demo",
  "version": "0.1.0",
  "description": "Calculate a Fibonacci number as fast as possible with JavaScript",
  "main": "index.js",
  "repository": "git@github.com:stoneyangxu/fastfib-demo.git",
  "author": "stoneyangxu <stoneyangxu@icloud.com>",
  "license": "MIT",
  "private": false,
  "scripts": {
    "test": "node test/fastfib.spec.js && node benchmark/index.js"
  },
  "keywords": [
    "fibonacci",
    "fast"
  ],
  "bugs": "https://github.com/stoneyangxu/fastfib-demo/issues",
  "homepage": "https://github.com/stoneyangxu/fastfib-demo",
  "devDependencies": {
    "benchmark": "^2.1.4"
  }
}


npm的依赖

模块的package.json中会定义依赖的其他模块,以便使用者维持模块的完整性。

  • dependencies 模块正常运行时需要的依赖
  • devDependencies 开发时需要的依赖,例如测试、基准测试、服务器加载工具等
  • optionalDependencies 非必要的依赖,可以增强模块功能,安装依赖时如果失败,可以跳过并降级使用其他模块
  • peerDependencies 运行时需要的依赖,但是已经被安装了,例如grunt等,保证只有在安装了指定依赖(版本)时,才能够安装

语义化版本号

版本号规则

主版本号.次版本号.修订号

  • 主版本号 做了不兼容的API修改
  • 次版本号 做了向下兼容的功能增强
  • 修订号 做了向下兼容的问题修正

版本控制

低于1.0.0的版本来表示当前API还未完全实现,会频繁变更。

变更日志

格式:

Version 0.5.0--2017-09-23
---
added: feature x
removed: feature y [breaking change!]
updated: feature z
fixed: bug xx

用户体验

在发布模块之前确定模块正常工作。

添加可执行脚本

  • 添加脚本
// bin/index.js

#! /usr/bin/env node

const fastfib = require('../')
const seqNo = Number(process.argv[2])

if (isNaN(seqNo)) {
    return console.error('\nInvalid sequence number provided, try: \n fastfib 30\n')
}

console.log(fastfib(seqNo))
  • package.json中添加可执行脚本
  "bin": {
    "fastfib": "./bin/index.js"
  },

  • 使用npm link命令来测试脚本
$ npm link
$ fastfib 20

fastlib被模拟为全局安装,可以当作脚本来执行

  • 在其他模块(或应用)中连接模块
$ cd application
$ npm link ../fastlib

发布

  • 注册,注册信息会保存在.npmrc中
$ npm adduser

  • 发布
    $ npm publish
    

  • 更新版本
    $ npm version patch
    $ npm publish
    

  • 删除模块
    $ npm unpublich --force
    

使用私有模块

当我们只希望在内部共享模块时,将其设置为私有,在package.json中添加:

"private": true

执行npm public时会被阻止

在内部共享时,有几种方式:

使用GIT来共享

  • 在安装时指定git路径,会自动从master拉取模块
$ npm install git+ssh://git@github.com:mycompany/fastfib.git --save
  • 在package.json文件中指定仓库地址
"dependencies": {
    "a": "git+ssh://git@github.com:mycompany/a.git#0.1.0",
    "b": "git+ssh://git@github.com:mycompany/b.git#develop",
    "b": "git+ssh://git@github.com:mycompany/c.git#dacc525c"
}

a模块指定tag b模块指定分支 c模块指定提交的SHA-1

使用URL来共享

  • 将模块打包
    $ tar -czf fastfib.tar.gz fastfib
    
  • 将文件上传到web服务器上
  • 安装时指定url
$ npm install http://some-server/fastfib.tar.gz --save

使用私有仓库

自定搭建npm私有源,将模块发布到私有源上