当前位置: 首页 > 文档资料 > 深入浅出 Node.js >

第十一章 产品化

优质
小牛编辑
121浏览
2023-12-01

Node 相对于大多数 web 技术还算是年轻的,这意味着没有现成和成熟的框架或应用系统可以直接上手使用,商业化还处于萌芽状态。反过来,这也能让开发者接触到较多的底层细节,如 HTTP 协议、进程模型、服务模型等,这些底层原理与其它现有技术并无实质上的差别。对于 Node 开发者而言,很多其它语言走过的路需要开发者带着 Node 特性重新去实践一遍。这并不是坏事,Node 更接近底层使得开发者对于具体细节的可控都非常高。

目前,在国内大多数人都将 Node 以实验性质的方式来使用,国外已经有知名的项目将 Node 应用在实际的生产环境中,如 eBay 的数据中间层、Linkedin 移动应用的服务器端等等。本章将详细介绍将 Node 产品化过程中需要注重的一些细节,这些细节其实时具备普适性的,并非 Node 所独有的。鉴于部分 Node 开发者可能从前端转来,为了完善 Node 生态的介绍,所以添加此章。尽管因为熟悉 JavaScript,可以较好地上手 Node,但是事实上从演示原型到产品还需要较长地缝隙需要去填补。

在实际地产品中,需要很多非编码相关地工作以保证项目地进展和产品地正常运行等,这些细节包括工程化、架构、容灾备份、部署和运维等。只有这些任务在持续性进行,才表明项目是活着的。

11.1 项目工程化

所谓的工程化,可以理解为项目的组织能力。体现在文件上,就是文件的组织能力。对于不同类型的项目,其组织方式也有所不同。除此之外,还应当有能够将整个项目串联起来的灵魂性文件。

项目的组织就犹如行军作战的阵法和章法,混乱而无目的的军队几乎不可能打胜仗,有其形、有其魂的组织的生命周期才会更长,其形态才更稳固。

在项目工程化过程中,最基本的己不是目录结构、构建工具、编码规范和代码审查等,下面逐一讲解。

11.1.1 目录结构

目前,主要的两类项目为 Web 应用和模块应用。普通的模块应用遵循 CommonJS 的模块和包规范即可,其细节参见第二章。对于 Web 应用,组织方式有各种各样,但是只要遵循单一原则即可。常见的 Web 应用都是以 MVC 为主要框架的,其余部分在这个基础上进行扩展。下面是我的某个 Web 应用项目:

tree -L 2

|----- History.md # 项目改动历史
|----- INSTALL.md # 项目安装说明
|----- Makefile   # Makefile文件
|----- benchmark  # 基准测试
|----- controllers # 控制器
|----- lib         # 没有模块化的文件目录
|----- middlewares # 中间件
|----- package.json # 包描述文件,项目依赖配置项等
|----- proxy        # 数据代理目录,类似于MVC中的M
|----- test         # 测试目录
|----- tools        # 工具目录
|----- views        # 视图目录
|----- routes.js    # 路由注册表
|----- dispatch.js  # 多进程管理
|----- README.md    # 项目说明文件
|----- assets       # 静态文件目录
|----- assets.json  # 静态文件与CDN路径的映射文件
|----- bin          # 可执行脚本
|----- config       # 配置目录
|----- logs         # 日志目录
└----- app.js       # 工作进程

这个项目结构将各种功能的文件分门别类地归纳到目录中,其中包含普通的 MVC 约定 CommonJS 模块约定以及一些自有约定。成熟一点的 Web 应用框架(如 Express)还提供了命令行工具来初始化 Web 应用,为开发者提供了一个较好的起点。

在实际的项目中,还存在 node_modules 这样一个目录,但这个目录通常不用加入到版本控制中。在部署项目时,我们通过npm install命令安装 package.json 文件中配置的依赖文件时,会自动生成这个目录。

11.1.2 构建工具

有了源代码项目,只要完成了第一步。要想真正能用上源代码,还需要一定的操作,这些操作主要有合并静态文件、压缩文件大小、打包应用、编译模块等。如果每次都手工完成这些操作,效率会比较低下。为了节约资源,此类工作交给工具来完成比较何时,而构建工具就是完成此类需求的。将长哟个操作通过构建工具配置起来后,后续只要简单的命令就能完成大部分工作了。

目前,在 Node 的应用中,主流的构建工具还是老牌的 make,但是它的缺点时只在*nix 操作系统中有效。为了实现跨平台,Grunt 应用而生。Grunt 通过 Node 协程,借助 Node 跨平台能力,实现了很好的平台兼容性。

1. Makefile

Makefile 文件是*nix 系统下经典的构建工具。除了 Windows 系统外,其它系统几乎都能使用它。受 Makefile 影响的还有 Ruby 的 Rakefile 和 Gemfile 等。Makefile 文件通常用爱管理一些编译相关的工作。以下为经典的 3 行构建代码:

./configure
make
make install

在这 3 行代码中,有两行命令跟 Makefile 有关。在 Web 应用中,通常也会在 Makefile 文件中编写一些构建任务来帮助项目提升效率,比如静态文件的合并编译、应用打包、运行测试、清理目录、扫描代码等。下面为我的某个 Web 项目的 Makefile 文件:

TESTS = $(shell ls -S `find test -type f -name "*.js" -print`)
TESTTIMEOUT = 5000
MOCHA_OPTS =
REPORTER = spec

install:
  @$PYTHON=`which python2.6` NODE_ENV=test npm install

test:
  @NODE_ENV=test ./node_modules/mocha/bin/mocha \
    --reporter $(REPORTER) \
    --timeout $(TESTTIMEOUT) \
    $(MOCHA_OPTS) \
    $(TESTS)

test-cov:
  @$(MAKE) test REPORTER=dot
  @$(MAKE) test MOCHA_OPTS='--require blanket' REPORTER=html-cov > coverage.html
  @$(MAKE) test MOCHA_OPTS='--require blanket' REPORTER=travis-cov

reinstall: clean
  @$(MAKE) install

clean:
  @rm -rf ./node_modules

build:
  @./bin/combo views .

.PHONY: test test-cov clean install reinstall

这个 Makefile 文件将测试、测试覆盖率、项目清理、依赖安装等整合进 make 命令。将 Makefile 于持续集成工具或发布工具整合起来将会让开发者省心省力。

2. Grunt

Makefile 唯一的缺陷也许就是跨平台问题了,为此才有 ant、rake 等工具的出现。在 Node 生态系统中,也有一款构建工具解决了 Makefile 无法跨平台的问题——Grunt。

Grunt 用 Node 协程,能够同时在 Windows 和*nix 平台下运行。Grunt 结合 NPM 的包依赖管理,完全可以媲美 Java 世界的 Maven 工具,同时它又如 Makefile 一样,能够用来构建完善的自动化任务工具。它的设计理念于 Makefile 并不相同:Makefile 依托强大的 bash 编程,Grunt 则依托它丰富的插件,它自身提供通用接口用于插件的接入,具体的任务则由插件完成。

Grunt 的核心插件以grunt-contrib-开头,在 NPM 包管理平台上可以找到和查看。Grunt 提供了 3 个模块分别用于运行时、初始化和命令工行:grunt、grunt-init、grunt-cli。后面两个模块都可以作为命令行工具使用,安装时带-g即可。

如同 make 命令一样,Grunt 也会在项目目录中提供一个 Gruntfile.js 文件。类似于 Makefile 文件的任务配置,在目录下执行 grunt 命令回去读取该文件,然后解析、执行任务。下面是某个模块项目的 Gruntfile.js 文件:

module.exports = function (grunt) {
  grunt.loadNpmTasks('grunt-contrib-clean')
  grunt.loadNpmTasks('grunt-contrib-concat')
  grunt.loadNpmTasks('grunt-contrib-jshint')
  grunt.loadNpmTasks('grunt-contrib-uglify')
  grunt.loadNpmTasks('grunt-replace')

  // Project configuiration
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    jshint: {
      all: {
        src: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
        options: {
          jshintrc: 'Jshint.json',
        },
      },
    },
    clean: ['lib'],
    concat: {
      htmlhint: {
        src: ['src/core.js', 'src/reporter.js', 'src/htmlparser.js', 'src/rules/*.js'],
        dest: 'lib/htmlhint.js',
      },
    },
    uglify: {
      htmlhint: {
        options: {
          banner:
            '/*!\r\n * HTMLHint v<%= pkg.version %>\r\n * https://github.com/yaniswang/HTMLHint\r\n *\r\n * (c) 2013 Yanis Wang<yanis.wang@gmail.com>.\r\n * MIT Licensed\r\n*/\n',
          beautify: {
            ascii_only: true,
          },
        },
        files: {
          'lib/<%= pkg.name %>.js': ['<%= concat.htmlhint.dest %>'],
        },
      },
    },
    replace: {
      htmlhint: {
        files: { 'lib/htmlhint.js': 'lib/htmlhint.js' },
        options: {
          prefix: '@',
          variables: {
            VERSION: '<%= pkg.version %>',
          },
        },
      },
    },
  })

  grunt.registerTask('dev', ['jshint', 'concat'])
  grunt.registerTask('default', ['jshint', 'clean', 'concat', 'uglify', 'replace'])
}

make 工具和 Grunt 各有所长,但是对于不熟悉 bash 编程的开发者,Grunt 则宛如救星。

11.1.3 编码规范

构建了良好的项目结构后,工程化算是有了一个不错的开头。也许很少有人遇见一个团队由很多人通过 JavaScript 开发应用的情景,但是在 JavaScript 应用场景越来越多的情况下,整个谈对一起维护一份代码将会很常见。多人维护相同的代码,将会面临团队成员水平不一等问题。而代码是否具备良好的可维护性是最能体现团队素质的地方。为团队统一良好的编码风格,有助于帮助提升代码的可读性,进而提升可维护性。项目中代码的可维护性是影响项目后期成本的重要因素,一旦早期不注重可维护性,后期项目的迭代和 bug 修复将会耗费巨大的成本。建议在项目一开始就制定基本的编码,让团队形成统一的风格。

编码贵伐的统一一般有几种实现方式,一种是文档式的约定,一种是代码提交时的强制检查。前者靠自觉,后者靠工具。

在 JSLint 和 JSHint 工具的帮助下,现在已经能够很好地配置规则了。一旦团队约定了编码规范的详细规则,则可以生成一份规则文件。一些扫描工具或者编辑器能够通过该规则文件对源码进行扫描,直接提示开发者问题所在。

关于编码规范可以参见附录 C,在其中有详细的描述。

JavaScript 是一门太过于灵活的语言,每个团队应当有自己的约束规范,使得编码能够保持灵活又严谨,这对于工程化是一个很好的增进。

11.1.4 代码审查

代码审查简历在具体的代码提交过程中。目前,开源社区大多通过 GitHub 实现代码托管。对于一些企业,也通过 gitlab 等开源工具搭建了内部的代码托管平台。这类托管平台除了实现代码托管外,还增强了 bug 追踪的系统,并且利用 git 的分支特点,可以很好地实现代码审查。git 地分支开发模式非常灵活,非常利用分布式开发。开发者可以很容易地从主干签出代码,然后进行功能地开发,待开发完毕后,提交回主干,发起和并请求即可。

代码审查主要在请求合并地过程中完成,需要审查的点有功能是否恒却完成、编码风格是否符合规范、单元测试是否有同步添加等。如果不符合规范,就需要重新更改代码,然后再提交审查,只有通过审查之后,代码才应该合并进主干。

代码审查需要耗费一定的精力,一些可以自动化完成的工作可以交给工具来自动完成,比如编码规范的检查。但检查后的结果,还需要人工完成确认。尽管实行代码审查回花费一定的精力,但是代码质量的稳固提升所带来的好处还是会逐渐汇报给产品的。

再代码合并的过程中,一般还会集成单元测试的执行等环境,待一切都没有问题之后才会上线部署。

11.2 部署流程

代码在完成开发、审查、合并之后,才会进入部署流程。尽管经过一系列严谨的人工审查和单元测试的质量保证,但也并不能直接上线到生产环境中直接运行,还需要在测试环境中测试之后才允许进入生产环境进行线上测试。

11.2.1 部署环境

在实际的项目需求中,有两个点需要验证,一是功能的正确性,一是于数据相关的检查。第一个需求是普适的检查,通常会准备测试环境来供开发或者测试人员验证代码的改动是否正确。之所以要准备专有的测试环境,是为了排除掉无关因素的影响。但是对于一些功能而言,它的行为是与具体数据相关的,测试环境中的数据几种在种类或者大小上不能够满足测试需求,进而需要在一个预发布环境中测试。预发布环境与普通的测试环境差别在于它的数据较为接近线上真实的数据。

我们将普通测试环境称为 stage 环境,预发布环境称为 pre-release 环境,实际的生产环境称为 product 环境。

11.2.2 部署操作

就普通的示例代码而言,我们通常直接在命令中执行node file.js以启动应用。这对于开发中的应用而言,时常地终端进程和频繁重启并无问题。但是对长时间执行地服务进程而言,这里存在两个问题:首先会占用一个命令行窗口,其次随着窗口地退出会导致打开地进程以并退出。为了能让进程持续执行,我们可能会用到 nohup 和&以不挂断进程地方式执行:

nohup node app.js &

启动进程很容易,但是还有两个需求需要考虑——停止进程和重启进程。手工管理地方式会显得繁琐,为此,我们需要一个脚本来实现应用地启动、停止和重启等操作。要完成这样地操作,bash 脚本是最精巧又擅长此类需求的。bash 脚本的内容通过与 Web 应用以约定的方式来实现。这里所说的约定,其实就是要解决进程 ID 不容易查找的问题。如果没有约定,我们需要找到应用对应的进程,然后调用 kill 命令杀死进程。这通常要调用 ps 来查找:

ps aux | grep node

jacksontian 3618 0.0 0.0 2432768 592 s002 R+ 3:00PM 0:00.00 grep node
jacksontian 3614 0.0 0.4 3054400 32612 s000 S+ 2:59PM 0:00.69 /usr/local/bin/node /Users/jacksontian/git/h5/app.js

这里将对应的 Node 进程杀掉:kill 3614即可。

这里所谓的约定是,主进程在启动时将进程 ID 写入到一个 pid 文件中,这个文件可以存放在一个约定的路径下,如应用的run/app.pid。下面是将 pid 写入到文件中的示例:

var fs = require('fs')
var path = require('path')

var pidfile = path.join(__dirname, 'run/app.pid')
fs.writeFileSync(pidfile, process.pid)

脚本在停止或重启应用时通过 kill 给进程发送 SIGTERM 信号,而进程收到该信号时删除 app.pid 文件,同时退出进程,相关代码如下:

process.on('SIGTERM', function () {
  if (fs.existsSync(pidfile)) {
    fs.unlinkSync(pidfile)
  }
  process.exit(0)
})

下面是一个完整的 bash 脚本,用于控制应用的启动、停止和重启等操作:

#!/bin/sh
DIR=`pwd`
NODE=`which node`
# get action
ACTION=$1

# help
usage() {
  echo "Usage: ./appctl.sh {start|stop|restart}"
  exit 1;
}

get_pid() {
  if [ -f ./run/app.pid ]; then
    echo `cat ./run/app.pid`
  fi
}

# start app
start() {
  pid = `get_pid`

  if [! -z $pid ]; then
    echo 'server is already running'
  else
    $NODE $DIR/app.js 2>&1 &
    echo 'server is running'
  fi
}

# stop app
stop() {
  pid=`get_pid`
  if [ -z $pid ]; then
    echo 'server not running'
  else
    echo 'server is stopping...'
    kell -15 $pid
    echo 'server stopped !'
  fi
}

restart() {
  stop
  sleep 0.5
  echo ====
  start
}

case "$ACTION" in
  start)
    start
  ;;
  stop)
    stop
  ;;
  restart)
    restart
  ;;
  *)
    usage
  ;;
esac

在部署的过程中,只要执行这个 bash 脚本即可,无需手工管理进程:

./appctl.sh start
./appctl.sh stop
./appctl.sh restart

这个脚本的核心就是围绕run/app.pid来进行操作的。要获取进程 ID,只需要读取该文件即可。

11.3 性能

Node 产品的性能与许多因素相关,这里我们将范畴缩减到 Web 应用中来,只评估一些常见的提升性能的方法。对于 Web 应用而言,最直接有效的莫过于动静分离、多进程架构、分布式,其中涉及的几个拆分原则如下:

  • 做专一的事。
  • 让擅长的工具做擅长的事情。
  • 将模型简化。
  • 将风险分离。

除此之外,缓存也能带来很大的性能提升。

11.3.1 动静分离

在普通的 Web 应用中,Node 尽管也能通过中间件实现静态文件服务,但是 Node 处理静态文件的能力并不算突出。将图片、脚本、样式表和多媒体等静态文件都引导到专业的静态文件服务器上,让 Node 只处理动态请求即可。这个过程中可以用 Nginx 或者专业的 CDN 来处理。

将动态请求和静态请求分离后,服务器可以专注在动态服务方面,专业的 CDN 会将静态文件与用户尽可能靠近,同时能够有更精确和高效的缓存机制。静态文件请求分离后,对静态请求使用不同域名或多个域名还能消除掉不必要的 Cookie 传输和浏览器对下载线程数的限制。

静态文件和动态请求分离只是最简单的分离,也较容易实现。事实上还有更复杂的情况,比如一个网页中同时存在动态数据和静态内容,在 Node 中将内容发送至客户端时需要进行字符串到 Buffer 的转换,但是对于静态内容而言就无需进行字符串层级的替换,只要保留成 Buffer 即可。直接进行 Buffer 传输可以很大程度上提升性能,但这种程度上的控制也许没有普适性,需要较多细节处理。

11.3.2 启用缓存

提升性能其实差不多只有两个途径,一是提升服务的速度,而是避免不必要的计算。前者提升的性能在海量流量面前终有瓶颈,但后者却能够在访问量越大时收益越多。避免不必要的计算,应用场景最多就是缓存。

尽管同步 I/O 在 CPU 等待时浪费的时间较为严重,但是在缓存的帮助下,却能够消除同步 I/O 带来的时间浪费。但不管是同步 I/O 还是异步 I/O,避免不必要的计算这条原则如果遵循得较好,性能提升是显著得。

如今,Redis 或 Memcached 几乎是 Web 应用得标准配置。如果你的产品需要应对巨大的流量,启用缓存并应用好它,是系统性能瓶颈的关键。

11.3.3 多进程架构

在第九章中,我们已经详细介绍了多进程架构。通过多进程架构,不仅可以充分利用多核 CPU,更是可以建立机制让 Node 进程更加健壮,以保障 Web 应用持续服务。由于 Node 是通过自有模块构建 HTTP 服务器的,不想大多数服务器端技术那样有专有的 Web 容器,所以需要开发者自己处理多进程的管理。不过好在官方已经有 cluster 模块,在社区也有 pm、forever、pm2 这样的模块用于进程管理,这里不再展开具体细节。

11.3.4 读写分离

除了动静分离外,另一个较为重要的分离是读写分离,这主要针对数据库而言。就任意数据库而言,读取的速度远远高于写入的速度。而某些数据库在写入时为了保证数据一致性,会进行锁表操作,这同时会影响到读取的速度。某些系统为了提升性能,通常会进行数据库的读写分离,将数据库进行主从涉及,这样读数据操作不再收到写入的影响,降低了性能的影响。

此外,还有其它许多方案用以提升系统性能,以应对海量的请求,这里不再一一展开。

11.4 日志

在真实的项目中,开发只是整个投入的一小部分。应用或系统真正上线运转起来时,问题有可能会接踵而来。所谓智者千虑,必有一疏。无论多么周密的代码编写,一些位置问题总是可能在某个不确定的时候出现。这种情况下,与其遇见 bug 修复它,不如建立鉴权的排查和跟踪机制,而日志就是实现这种机制的关键。在健全的系统中,完善的日志记录最可能还原问题现场。通过记录日志来定位问题时一种成本较小的方式。这种非结构化、轻量的记录方式容易实现,也容易扩展。

11.4.1 访问日志

访问日志一般用来记录每个客户端对应用的访问。在 Web 应用中,主要记录 HTTP 请求中的关键数据。一般的 Web 服务器都实现了记录访问日志的功能,只需要简单的配置即可启用。在用 Nginx 或 Apache 进行反向代理时,可以利用这些已有的设施完成访问日志的记录。在 Node 开发的 Web 应用中,也可以自行实现访问日志的记录。

中间件框架 Connect 在其众多中间件中提供了一个日志中间件,通过它可以将关键数据按一定格式输出到日志文件中。下面是 Connect 的一段示例代码:

var app = connect()

// 记录访问日志
connect.logger.format(
  'home',
  ':remote-addr :response-time - [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :res[content-length]'
)
app.use(
  connect.logger({
    format: 'home',
    stream: fs.createWriteStream(__dirname, '/logs/access.log'),
  })
)

这里记录的数据有remote-addrresponse-time等,这些数据已经足够用来帮助分析 Web 应用的用户分布情况、服务器端的响应时间、响应状态和客户端类型等。这些数据属于运营数据,能反过来帮助改进和提升网站。

从上面的示例代码中可以看出,数据是以:token的形式进行格式化的。Connect 提供了token()方法来对应实际数据,下面是:status的最终取值:

exports.token('status', function (req, res) {
  return res.statusCode
})

Connect 在最终响应前会将实际数据替换掉 token(),然后写入到日志文件中。在实际的应用场景中,可以置入一些用户信息,用以跟踪一些数据,比如某个登录用户太过密集地访问某个页面等,他可能是一个机器人,在爬取网页中地数据。根据日志分析,得出其 IP,可以实现定点拒绝服务。

11.4.2 异常日志

异常日志通常用来记录哪些意外产生地异常错误。通过日志地记录,开发者可以根据异常信息去定位 bug 出现地具体位置,以快速修复问题。

异常日志通常由完善地分级,Node 中提供了 console 对象就简单地实现了这几种划分,具体如下:

  • console.log:普通日志。
  • console.info:普通信息。
  • console.warn:警告信息。
  • console.error:错误信息。

console 模块在具体实现实,log 与 info 方法都将信息输出给标准输出 process.stdout,warn 与 error 方法则将信息输出到标准错误 process.stderr,而 info 和 error 分别是 log 和 warn 的别名。下面是它们的实现代码:

Console.prototype.log = function () {
  this._stdout.write(util.format.apply(this, arguments) + '\n')
}

Console.prototype.info = Console.prototype.log

Console.prototype.warn = function () {
  this._stderr.write(util.format.apply(this, arguments) + '\n')
}
Console.prototype.error = Console.prototype.warn

console 对象上具有一个 Console 属性,它是 console 对象的构造函数。借助这个构造函数,我们可以实现自己的日志对象,相关代码:

var info = fs.createWriteStream(logdir + '/info.log', { flags: 'a', mode: '0666' })
var error = fs.createWriteStream(logdir + '/error.log', { flags: 'a', mode: '0666' })

var logger = new console.Console(info, error)

分别调用它的 API,日志内容就能各自写入到对应的文件中,相关代码:

logger.log('Hello world!')
logger.error('segment fault')

有了记录信息的日志 API 后,开发者需要关心的是小心捕获每一个异常。在第四章中,我们提到异步调用中回调函数里的异常无法被外部捕获的问题,也提到了异步 API 编写的规范,每个开发者应当将 API 内部发生的异常作为第一个实参传递给回调函数。对于回调函数中产生的异常,则可以不用过问,交给全局的uncaughtException时间去捕获即可。

在逐层次的异步 API 调用中,异常时传递给调用方还是该立即通过日志记录,这是一个需要注意的问题。就异常的 API 编写而言,尽量不要隐藏错误,不要通过try/catch块将异常捕获,然后隐藏起来不想外部效用这暴露。这对于底层 API 的涉及而言,尤为重要。事实上,日志通常时服务于业务的。我们的建议时一场尽量由最上层的调用者捕获记录,底层调用者或中间层调用者出现的异常只要正常传递给上层的调用方即可。

底层或中间件层调用通常这样写:

exports.find = function (id, callback) {
  // 准备SQL
  db.query(sql, function (err, rows) {
    if (err) {
      return callback(err)
    }

    // 处理结果
    var data = rows.sort()
    callback(null, data)
  })
}

如果上层 API 对下层 API 返回的结果不需要做任何处理,直接简写即可:

exports.find = function (id, callback) {
  // 准备SQL
  db.query(sql, callback)
}

但是对于最上层的业务,不能无视下层传递过来的任何异常,需要记录异常,以便将来排查错误,同时应该对用户给出友好的提示:

exports.index = function (req, res) {
  proxy.find(id, function (err, rows) {
    if (err) {
      logger.error(err)
      res.writeHead(500)
      res.end('Error')
      return
    }

    res.writeHead(200)
    res.end(rows)
  })
}

如果日志只是通过以上方式简单记录,那么它对排查错误的帮助并不太大,因为由特殊的异常需要更详细的数据来还原现场,所以最好在记录异常时有良好的格式和更详细的数据。为此可以准备一个 format 方法来封装和格式化异常信息,该方法代码如下:

var format = function (msg) {
  var ret = ''
  if (!msg) {
    return ret
  }

  var date = moment()
  var time = date.format('YYYY-MM-DD HH:mm:ss.SSS')
  if (msg instanceof Error) {
    var err = {
      name: msg.name,
      data: msg.data,
    }

    err.stack = msg.stack

    ret = util.format(
      '%s %s: %s\nHost: %s\nData: %j\n%s\n\n',
      time,
      err.name,
      err.stack,
      os.hostname(),
      err.data,
      time
    )

    console.log(ret)
  } else {
    ret = time + ' ' + util.format.apply(util, arguments) + '\n'
  }

  return ret
}

为此,我们在异常出现时可以将调用时的数据传递给格式化方法,然后记录下日志,示例:

var input = '{error: format}'
try {
  JSON.parse(input)
} catch (ex) {
  ex.data = input
  logger.error(format(ex))
}

这样在日志文件中就可以详细地捕捉到异常发生时地输入数据,然后定位 bug 和解决问题就是水到渠成的事了。

对于未捕获的异常,Node 提供了机制以免进程直接退出,但是发生未捕获异常的进程也不能在线上进行服务了,因为可能有内存泄漏的风险产生。如何优雅地退出和重启进程在第九章中已详细描述过,那一张中地示例多是用 console.log()来记录问题地,但在实际地产品中,需要严格地日志记录。记录过程同上,不再详述。

11.4.3 日志与数据库

有的开发者对日志可能不太了解,会选择将一些日志写入到数据库中。数据库比日志文件好的地方在于它是结构化数据,可以直接编写 SQL 语句进行分析,日志文件则需要再加工之后才能分析。

但是日志文件与数据库写入在性能上处于两个级别,数据库在写入过程中要经历一系列处理,比如缩表、日志等操作。写日志文件则是直接将数据写到磁盘上。为此,如果有大量的访问,可能会存在写入操作大量排队的状况,数据库的消费速度严重低于生产速度,进而导致内存泄漏等。相比之下,写日志是轻量的方法,将日志分析和日志记录这两个步骤分离开来是较好的选择。日志记录可以在线写,日志分析则可以借助一些工具同步到数据库中,通过离线分析的方式反馈出来。

11.4.4 分割日志

线上业务可能访问量巨大,产生的日志也可能是大量的,上述实例中只是简单地将普通日志和异常日志分开放在两个文件中,日志过多时也不便直接查看。为此,将产生的日志按照日期分割是一个不错的主意。日志的写入一般都是依托在可写流上的。对于 Console 对象,它的内部属性_stdout_stderr就是指向我们传入的两个输入流对象的。在设计的过程中,我们可以按照日期传递对应的日志文件可写流对象,为此可以设计一个定时器用于当日起发生改变时,更改日志对象的两个输入流对象即可。这里将不展开描述具体实现。

11.4.5 小结

捕获日志相对而言是较为繁琐的事情,但是一旦构建好这个基础过程,有问题产生时则可以快速解决。很多开发者在开发过程中完全不(或没来得及)开率日志,到线上产生问题时则会手忙脚乱。良好的日志可以为系统的长期运行保驾护航,出现任何问题时,我们都能做到心中有数。

11.5 监控报警

部署好流程,记录好日志之后,应用就似乎可以自行运转了。实际上,这时候的应用如同初生的婴儿,刚刚学会了走路,如果放任不管,就如同将它放在大街上的人流中。就像为长大的孩子需要有一个人照看一般,应用也应当有一个监控系统。对于走在大街上的孩子,如果摔倒,需要及时将其扶起来。如果应用出现了差错,也需要通过监控及时发现,然后恢复它正常运行。

应用的监控主要有两类,一种是业务逻辑型的监控,一种是硬件型的监控。监控主要通过定时采样来进行记录。除此之外,还要对监控的信息设置上限,一旦出现大的波动,就需要发出警报提醒开发者。为了较好地供开发者使用,监控到的信息一般还要通过数据可视化的方式反映出来,以便更直观地查看。

11.5.1 监控

监控地主要目的时为了将一些重要指标采样记录下来,一旦这些指标发生较大的变化,可以配合报警系统将问题反馈到负责人那。监控的点可以很细致,也可以只选主要的指标。

1. 日志监控

业务逻辑型的监控主要体现在日志上,做足了日志记录的功夫之后,如何将日志应用起来是个问题。通过监控异常日志文件的变动,将新增的异常按异常类型和数量反映出来。某些异常与某个具体子系统有关,监控出现的某个异常多半能反映出子系统的状态。

除了异常日志的监控外,对于访问日志的监控也能体现出实际的业务 QPS 值。观察 QPS 的表现能够检查业务在时间上的分布。

此外,从访问日志宏也能实现 PV 和 UV 的监控。同 QPS 值一样,通过对 PV/UV 的监控,可以很好地知道应用的使用者们的习惯、预知访问高峰等等。

2. 响应时间

响应时间也是一个需要监控的点。一旦系统的某个子系统出现异常或者性能瓶颈,将会导致系统的响应时间边长。响应时间可以在 Nginx 一类的反向代理上监控,也可以通过应用自行产生的访问日志来监控。健康的系统响应时间应该时波动较小的、持续均衡的。

3. 进程监控

监控日志和响应时间读能够较好地监控到系统地状态,但是它们的前提是系统是运行状态的,所以监控进程是比前两者更为紧要的任务。监控进程一般是检查操作系统中运行的应用进程数,比如对于采用多进程架构的 Web 应用,就需要检查工作进程的数量,如果低于预估值,就应当发出报警声。

4. 磁盘监控

磁盘监控主要是监控磁盘的用量。由于日志频繁写的缘故,磁盘空间渐渐被用光。一旦磁盘不够用,将会引发系统的各种问题。给磁盘的使用量设置一个上限,一旦磁盘用量超过警戒值,服务器的管理者就应该整理日志或清理磁盘了。

5. 内存监控

对于 Node 而言,一旦出现内存泄漏,不是那么容易排查。监控服务器的内存使用状况,可以检查应用中是否存在内存泄漏的状况。如果内存只升不降,那么铁定存在内存泄漏问题。健康的内存使用应当是有升有降,在访问量大的时候上升,在访问量回落的时候,占用量也随之回落。

如果进程中存在内存泄漏,又一时没有排查解决,有一种方案可以解决这种状况。这种方案应用于多进程架构的服务器集群,让每个工作进程指定服务多少次请求,达到请求数之后进程就不再服务新的连接,主进程启动新的工作进程来服务客户,旧的进程等所有连接断开后就退出。这样即使存在内存泄漏的风险,也能有效的规避内存泄漏带来的影响。但这属于规避问题,只解决了问题的表响,不推荐使用。

总而言之,监控内存并长时间观察是防止系统出现异常的好方法。如果突然出现内存异常,也能够追踪到是近期的哪些代码改动导致的问题。

6. CPU 占用监控

服务器的 CPU 占用检监控也是必不可少的项,CPU 的使用分为用户态、内核态、IOWait 等。如果用户态 CPU 使用率较高,说明服务器上的应用需要大量的 CPU 开销;如果内核态 CPU 使用率较高,说明服务器花费大量时间进行进程调度或者系统调用;IOWait 使用率则反应的是 CPU 等待磁盘 I/O 操作。

CPU 的使用率,用户态小于 70%、内核态小于 35%且整体小于 70%时,处于健康状态。监控 CPU 占用情况,可以帮助分析应用程序在实际业务中的状况。合理设置监控阈值能够很好地预警。

7. CPU load 监控

CPU load 又称为 CPU 平均负载,它用来描述操作系统当前的繁忙程度,可以简单地理解为 CPU 在单位时间内正在使用和等待 CPU 的平均任务数。他有 3 个指标,即 1 分钟的平均负载、5 分钟的平均负载、15 分钟的平均负载。CPU load 过高说明进程数量过多,这在 Node 中可能体现在用子进程模块反复启动新的进程。监控该值可以防止意外产生。

8. I/O 负载

I/O 负载值得主要是磁盘 I/O。反应的是磁盘上的读写情况,对于 Node 编写的应用,主要是面向网络服务,是故不太可能出现 I/O 负载过高的情况,大多数的 I/O 压力来自于数据库。不管 Node 进程是否与数据库或其它 I/O 密集的应用共处相同的服务器,我们都应该监控该值以防万一。

9. 网络监控

虽然网络流量监控的优先级没有上述项目那么高,但还是需要对流量进行监控并设置上限值。即便应用突然收到用户青睐,流量暴涨时也能同故宫数之感知到网站的宣传效果是否有效。一旦流量值超过警戒值,开发者就应当找出流量增长的原因。对于正常增长,应当评估是否该增加硬件设备来为更多用户提供服务。

网络流量监控的两个主要指标是流入流量和流出流量。

10. 应用状态监控

除了这些硬件需要监控的指标外,应用还应当提供一种机制来反馈其自身的状态信息,外部监控将会持续性地调用应用的反馈接口来检查它的健康状态。

最简单的状态反馈就是给监控响应一个时间戳,监控方检查时间戳是否正常即可:

app.use('/status', function (req, res) {
  res.writeHead(200)
  res.end(new Date())
})

健壮一些的状态响应则是将应用的依赖项的状态打印出来,如数据库连接是否正常、缓存是否正常等。

11. DNS 监控

DNS 是网络应用的基础,在实际的对外服务产品中,多数都对域名有依赖。DNS 故障导致产品出现大面积影响的事件并不少见。由于 DNS 服务通常是稳定的,容易让人忽略,但一旦出现故障,就可能是史无前例的故障。对于产品的稳定性,域名 DNS 状态也需要加入监控。目前国内有一些免费的 DNS 监控服务,如 DNSPod 等,可以通过这些监控服务,监控自己的在线应用。

11.5.2 报警的实现

搭配监控的则是报警系统,空有监控而没有通知功能,故障也是无法及时反馈给开发者的。如今的报警已经能够多样化,最普通的是邮件报警、IM 报警适合在线工作状态,短信或电话报警适合非在线状态。

  • 邮件报警。如果报警系统由 Node 编写,可以调用 nodemailer 模块来实现邮件的发送。下面为一个邮件发送示例:

    var nodemailer = require('nodemailer')
    
    // 建立一个SMTP传输连接
    var smtpTransport = nodemailer.createTransport('SMTP', {
      service: 'Gmail',
      auth: {
        user: 'gmail.user@gmail.com',
        pass: 'userpass',
      },
    })
    
    // 邮件选项
    var mailOptions = {
      form: 'Fred Foo <foo@bar.com>', // 发件人邮件地址
      to: 'bar@bar.com, baz@bar.com', // 收件人邮件地址列表
      subject: 'Hello', // 标题
      text: 'Hello world', // 纯文本内容
      html: '<b>Hello World</b>', // HTML内容
    }
    
    // 发送邮件
    smtpTransport.sendMail(mailOptions, function (err, response) {
      if (err) {
        console.log(err)
      } else {
        console.log('Message sent: ' + response.message)
      }
    })
    
  • 短信或电话报警。一些短信服务平台提供短信接入服务,可以在监控系统中接入此类服务,一旦线上出现达到阈值的异常时,就将信息发送给应用相关的负责人。

11.5.3 监控系统的稳定性

我们发信啊为了保证应用的稳定性,其实不知不觉又引入了一个庞大的监控系统。监控系统自身的稳定性对于应用非常重要,这如同照看孩子的保姆,如果保姆不能尽心尽力,玩忽职守,其结果是有监控系统不如没有。

如何保证监控系统自己的稳定性是另一个话题,本章不再继续展开。

11.6 稳定性

关于应用的稳定性,其实在部分章节中都有阐述,尤其在第四章和第九章中有重点描述,这两章从单进程和多进程的角度提及了稳定性。单独一台服务器满足不了业务无限增长的(如果有的话)需求,这就需要将 Node 按多进程的方式部署到多台机器中。这样如果某台机器出现故障,也能有其余机器为用户提供服务。除此之外,为了能够较好的服务各地用户,绝大多数企业都会选择在各地构建机房以抵消因为地理位置带来的网络延迟等问题。为了更好的稳定性,典型的水平扩展方式就是多进程、多机器、多机房,这样的分布式设计在现在的互联网公司并不少见。

  • 多机器:多机器部署应用带来的好处四能利用更多的硬件资源,为了更多的请求服务。同时能够在有故障时,继续服务用户请求,保证整体系统的高可用性。但是一旦出现分布式,就需要考虑负载均衡、状态共享和数据一致性等问题。

    如同在弹击中将请求分配到多个进程上一样,部署多台机器也需要考虑如何将请求均匀地分配给各个机器,则需要在机房的级别上架设负载均衡,可能是硬件设备来实现,也可能是软件来实现,如反向代理。

    对于状态共享和数据一致性,它们与多进程的问题是一致的,具体可参见第九章,此处不再多述。

  • 多机房:多机房部署是比多机器部署更高层次的部署,目的是为了解决地理位置给用户访问带来的延迟等问题。在容灾方面,机房与机房之间可以互为备份。由于机房与机房之间的网络复杂度再度提升,负载均衡方面需要进一步统筹规划,此处不再展开。

  • 容灾备份:在多机房和多机器的部署结构下,十分容易通过备份的方式进行容灾,任何一台机器或者一个机房停止了服务,都能有其余的服务器来接替新的任务。在这个机制下,我们至少需要 4 台服务器来构建这个稳定的服务集群。

需要注意的是,如今虚拟化技术已经成熟,在多服务器部署中,要尽量避免多个服务器在相同实体机上。因为一旦实体机出现故障,导致多台服务器一起停止服务。

应用自身的部署问题得到解决后,还要考虑的是应用依赖的服务的容灾和备份,如依赖的数据库、缓存等服务。

11.7 异构共存

站在技术的产品化角度来看,选择将一门新技术应用在生产环境中就得考虑与已有的系统或者服务能否异构共存。如果为了应用一种新技术而将已有的所有技术推翻,那并不是一个企业愿意去承担的风险。每一门新的语言或者新的技术在推广和应用的过程中都要面临这样的问题。对于 Node 而言,我在本书中介绍了它诸多原理。可以看出,它并非一个格格不入的新事物,它构建与 C/C++之上,以 JavaScript 为调用语言,以良好的事件驱动架构形成面向网络的平台,任何神奇的地方都能同操作系统底层找到它的起源。

在应用 Node 的过程中,一部分实在全新的项目中应用,一部分是改造已有系统通过 Node 来提升性能。几乎没有将已有系统推翻用 Node 来进行重建的。

关于在全新项目中应用 Node,此处毋庸再提。对于改造已有系统,Node 借助 C/C++底层或网络协议,已经能与这个世界上大多数的系统进行交互。其原理在于能够服务化的产品,都是具有标准协议的。协议几乎是解决异构系统最完美的方案。只要有标准的交互协议,各种语言就能通过网络进行交互。如 MySQL 等数据库,由于有标准的网络协议,所以可以通过各种各样的编程语言进行调用。当然,通过 Node 编写对应的客户端驱动也并不是难事。

对于一般系统,可能并非 TCP 层面的 网络协议,而是 RESTful 的服务接口。两者的不同在于一个是 HTTP 协议,处于应用层;一个是 TCP 协议,处于传输层。协议层次不同,性能方面会体现出差异来。TCP 协议会建立持久的长连接,甚至连接池,而 HTTP 协议则可能频繁地进行连接,在性能上存在损耗。TCP 协议需要依赖客户端驱动,HTTP 协议则基本上有现成的客户端。

总之,在应用 Node 的过程中,不存在为了用它而推翻已有设计的情况。Node 能够通过协议与已有的系统很好地异构共存。将 Node 用于系统改良的开发者需要考虑的是已有的系统是否具备良好的服务化,是否支持多终端,是否自持多语言调用。

11.8 总结

一般而言,决定一项技术进行产品开发时,只有最早期是与这门技术完全相关的。随着时间的迁移,要解决的已经不是原来的问题了,一门技术只能在一定的层面上发挥出它的优势来。用 Node 也是一样,随着开发的金蟾、涉及层面的增多,我们看到在产品的角度要解决的问题依然是大部分技术都要解决的问题。我们希望读者能够将 Node 纳入到新的层面上进行考虑,使它更适应产品,在产品中发挥更大的优势。