前言
尽早接触node有很多好处,首先,由于node相对于很多web技术还比较年轻,这可以让开发者接触到较多的底层细节,例如http协议、进程模型、服务模型等,这些底层原理与其他现有技术并无实质性的差别。由于,node的生态尚不成熟,因此,在开发实际的产品中,还是需要很多非编码相关的工作以保证项目的进展和产品的正常运行等,这些工作包括工程化、架构、容灾备份、部署、运维等。
项目工程化
所谓项目工程化,就是项目的组织能力,具体包括目录结构、构建工具、编码规范和代码审查等。
目录结构
node下主要存在两类项目,web应用和模块应用,我们来看下边的一个例子:
构建工具
项目写完代码,并测试完毕后还需要进行合并静态文件、压缩文件大小、打包应用、编译模块等工作。如果每次都手工完成这些重复的操作,那样的效率就比较低下了,为了节约资源,此类工作交给工具来完成比较合适,这样的工具就是构建工具。
Makefile
$ ./configure
$ make
$ make install
以静态文件合并编译、应用打包、运行测试、清理目录、扫描代码的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 $(TIMEOUT) \
$(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命令
Grunt
Grunt的核心是基于插件式的项目构建管理,核心插件是以grunt-contrib-开头的,我们可以通过安装,grunt、 grunt-init、 grunt-cli,来使用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 configuration
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
.\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']);
};
Gulp
原书中并没有Gulp的介绍,我在此处进行补充,首先也是需要用npm安装gulp,然后,写gulpfile.js文件,文件样子如下:
var gulp = require('gulp'),
compass = require('gulp-compass'),
mincss=require('gulp-minify-css');
gulp.task('css', function() {
// 编译css
console.log("hello gulp-css");
//压缩css
gulp.src('./sass/*.scss')
.pipe(compass({
config_file: './config.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2'));
});
gulp.task('watch',function(){
gulp.watch('./sass/*.scss',['css'])
});
gulp.task('default', ['watch'],function() {
// 将你的默认的任务代码放在这
console.log("hello gulp");
gulp.src('./sass/*.scss')
.pipe(compass({
config_file: './config.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2'));
gulp.src('./sass/billmanager/*.scss')
.pipe(compass({
config_file: './config_billmanager.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2/billmanager'));
gulp.src('./sass/common/*.scss')
.pipe(compass({
config_file: './config_common.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2/common'));
gulp.src('./sass/trading/*.scss')
.pipe(compass({
config_file: './config_trading.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2/trading'));
gulp.src('./sass/wx/*.scss')
.pipe(compass({
config_file: './config_wx.rb',
css: './public/css2',
sass: 'sass'
}))
//css压缩
.pipe(mincss())
.pipe(gulp.dest('./public/css2/wx'));
});
//gulp.src(); 该方法用于返回我们匹配的文件
通过构建一个个的task实现css的压缩、图片压缩、静态文件合并等工作。
编码规范
一般情况下,编码规范既需要文档式的约定,也需要在提交代码时通过代码强制检查工具进行检测。这个工具可以是JSLint或者JSHint。团队可以约定编码规范的详细规则,生成一份规范文件,并写入.jshintrc文件帮助检测编码规范。
代码审查
可以使用gitlab在企业内部搭建代码托管平台,这类平台可以实现代码托管、bug跟踪、代码审查等功能。(类似于github)
那么代码审查需要审查的点有:功能是否正确完成、编码风格是否符合规范、单元测试是否有同步添加等。流程如下:
部署流程
代码完成开发、审查、合并之后,才会进入部署流程。
部署环境
一个项目的开发到正式发布会存在几种环境,首先是开发环境,然后是测试环境,也叫stage环境。接着是预发布环境,也称为pre-release环境,最后是生产环境,也叫product环境。部署流程如下:
部署操作
部署,其实就是要启动一个长时间执行的服务进程,因此,需要使用nohup和&命令,以不挂断进程的方式执行:nohup node app.js &。同时还要考虑项目停止和项目重启。因此需要写一个bash脚本来简化操作。bash脚本的内容通过与web应用约定好的方式来实现,这里所说的约定,其实就是要解决进程ID不容易查找的问题。如果没有约定,我们只能手工的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
然后,还是手工的kill掉进程。
这里的约定就是主进程启动时将进程ID写入一个pid文件中,这个文件可以存放在一个约定的路径下,可以在node的进程文件,如app.js中添加个小功能来写pid。
var fs = require('fs');
var path = require('path');
var pidfile = path.join(__dirname, 'run/app.pid');
fs.writeFileSync(pidfile, process.pid);
脚本停止或者重启时,可以kill掉进程,然后发送SIGTERM信号给node,那么进程在收到信号后,将会删除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 ..."
kill - 15 $pid
echo "server stopped !"
fi
}
restart() {
stop
sleep 0.5
echo =====
start
}
case "$ACTION" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
*)
usage
;;
esac
然后执行这些命令即可:
./appctl.sh start
./appctl.sh stop
./appctl.sh restart
另外,我还提供了一种比较轻量的机制,为那些没有在node中实现pid写入的程序使用,这样就可以在不修改代码的基础上,完成启动、停止、重启服务的bash脚本了。
结构如下:
bash代码如下:
//env.sh
export NODE_HOME=/home/userp/luluda
export NODE_PATH=$NODE_HOME/node_modules
export NODE_PID=$NODE_HOME/bin/node.pid
export MAIN_JS="./bin/www"
//start
#!/bin/sh
cd /home/userp/luluda/bin
. ./env.sh
#run
cd $NODE_HOME/bin
echo $NODE_PID
if [ -e $NODE_PID ]
then
echo "服务已启动,线程,请先执行 shutdown_node.sh 结束服务后,再启动!"
exit -1
fi
#run
cd $NODE_HOME
echo "Start Node.js ... ...."
nohup node $MAIN_JS \
1>>$NODE_HOME/log/node.log 2>&1 &
if [ ! -z "$NODE_PID" ]
then
echo $! > $NODE_PID
fi
echo "Node PID: "`cat $NODE_PID`
//stop
#!/bin/sh
cd /home/userp/luluda/bin
. ./env.sh
if [ -e $NODE_PID ]
then
echo "Stop Node ... ..."
echo "Killing: `cat $NODE_PID`"
kill -9 `cat $NODE_PID`
rm -r $NODE_PID
else
echo "服务尚未启动!"
fi
//restart
#!/bin/sh
#根据实际路径进行修改
/home/userp/meet_server/bin/shutdown_node.sh
sleep 10
#根据实际路径进行修改
/home/userp/meet_server/bin/start_node.sh
性能
提升web应用性能的方法有好多,例如动静分离、多进程架构、分布式,但是这些都是需要进行拆分的,因此,先说一下拆分原则:
1.做专一的事
2.让擅长的工具做擅长的事情
3.将模型简化
4.将风险分离
动静分离
node可以通过中间件的方式实现动静分离,但是,还是那个原则,让擅长的工具做擅长的事情。因此,将图片、脚本、样式表和多媒体等静态文件都引导到专业的静态文件服务器上,让node只处理动态请求即可。这个过程可以使用nginx或者利用CDN来处理。
CDN会让静态文件与用户尽可能靠近,同时CDN自己也有更加精确和高效的缓存机制。静态文件请求分离后,对静态请求使用不同的域名或多个域名还能消除掉不必要的cookie传输和浏览器对下载线程数的限制。另外,有些网页中,动态内容内还存在静态内容,因此,将此部分的静态内容再次分离还可以提高效率。(这里就要使用buffer了,因此,静态内容无需进行字符串转化,直接使用buffer二进制就可以显示,也就是说,直接保留buffer原始内容即可。因此,效率又可以进一步提升。)
启用缓存
提升性能差不多只有两个途径,一是提升服务的速度,二是避免不必要的计算。避免不必要的计算使用最多的场景就是缓存的使用。现在的通常做法是使用redis作为缓存。将从数据库中查询出来的静态内容或者不变的内容,通过redis进行存储,等到下一次同样的请求到来时,就会优先检查缓存是否存在数据,如果存在就命中缓存中的数据,如果没有就去db中再次请求,然后返回并同步缓存。
接下来的内容是朴灵在书中,没有写的,我在这里简单的补充一下:
redis+node的缓存小代码
例如,我们请求一个api
const express = require('express')
const superagent = require('superagent')
const PORT = 3000
const app = express()
function getNumberOfRepos(req, res, next) {
const org = req.query.org
superagent.get(`https://api.github.com/orgs/${org}/repos`, (err, response) => {
if (err) throw err
var num = response.body.length
res.send(`Organization "${org}" has ${num} public repositories.`)
})
}
app.get('/repos', getNumberOfRepos)
app.listen(PORT, () => console.log(`app listen on port ${PORT}`))
因为要到外网访问,因此,速度没有保障,同时这个api获取的是一个静态的结果,因此,可以使用缓存。
另外,值得注意的是,redis的流行,也是因为redis的简单,使用redis就像使用一般编程语言中的hash map一样简单。
添加数据只需要:
client.set(‘some key’, ‘some value’);
然后再设置个过期时间,防止内存过满,另外设置lru也是非常必要的。
client.setex(‘some key’, 3600, ‘some value’);
const express = require('express')
const superagent = require('superagent')
const redis_client = require('redis').createClient(6379)
const PORT = 3000
const app = express()
function getNumberOfRepos(req, res) {
const org = req.query.org
superagent.get(`https://api.github.com/orgs/${org}/repos`, (err, response) => {
if (err) throw err
var num = response.body.length
redis_client.setex(org, 10, num)
res.send(`Organization "${org}" has ${num} public repositories.`)
})
}
function cache(req, res, next) {
const org = req.query.org
redis_client.get(org, (err, data) => {
if (err) throw err
if (data != null) {
res.send(`Organization "${org}" has ${data} public repositories.`)
} else {
next()
}
})
}
app.get('/repos', cache, getNumberOfRepos)
app.listen(PORT, () => console.log(`app listen on port ${PORT}`))
redis的lru置换策略
置换缓存时,记得设置置换策略。
noeviction: 不进行置换,表示即使内存达到上限也不进行置换,所有能引起内存增加的命令都会返回error
allkeys-lru: 优先删除掉最近最不经常使用的key,用以保存新数据
volatile-lru: 只从设置失效(expire set)的key中选择最近最不经常使用的key进行删除,用以保存新数据
allkeys-random: 随机从all-keys中选择一些key进行删除,用以保存新数据
volatile-random: 只从设置失效(expire set)的key中,选择一些key进行删除,用以保存新数据
volatile-ttl: 只从设置失效(expire set)的key中,选出存活时间(TTL)最短的key进行删除,用以保存新数据
redis的数据类型
此处只说Redis的五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。
我们做的行情tick就是使用了list这个结构。
多进程架构
首先,第九章中就已经明白了,使用多进程架构可以充分利用cpu,同时,因为node不需要额外的容器就可以使用http服务(基于http模块),因此,需要开发者自己处理多进程的管理,另外,也可以使用官方提供的cluster模块,或者pm、forever、pm2这样的模块来进行进程的管理。详细内容见第九章。
读写分离
读写分离主要是对于数据库的操作时读写分离的,读的速度要远远快于写的速度。(因为写需要锁表,来保护数据一致性),读写分离需要将数据库进行主从设计,但是,因为我公司没有专门的运维人员,因此,我们当时使用的阿里的rds进行读写分离的实现的。
日志
为了建立健全的排查和跟踪机制,需要为系统增加日志,完善的日志最能还原问题现场,好似侦探断案的第一手线索。
一般情况下,如果使用nginx作为反向代理,可以通过nginx来启用日志。
我的项目是使用node来自己记录日志的。
另外,对于用户的来访,还可以记录remote-addr和response-time,来定位用户分布情况、服务器响应时间、响应状态和客户端类型。
日志的等级
console.log:输出给process.stdout
console.info:输出给process.stdout
console.warn:输出给process.stderr
console.error:输出给process.stderr
捕获抓不到的异常
有些异常很是诡异,抓不到,因此,给全局设置一个uncaughtException,来抓取全局异常。
日志内容格式化
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;
}
上边的代码展示了如何格式化日志,以此精确定位错误的发生。
日志与数据库
日志在线写,日志分析通过一些文件同步到数据库中。
分割日志
可以按照日期分割,也可以按照日志类型分割(_stdout和_stderr)。
监控报警
对于新上线的应用,需要两个方面的监控,业务逻辑的监控和硬件型的监控。我们来看看具体怎么做。
监控
1.日志监控
例如查看具体的业务实现,通过日志时间分析,来反映某项业务的qps,同时,在日志上也可以查询到pv(每日ip访问或者刷新次数)和uv(每日某个客户端访问的次数,不重复计算),可以通过pv和uv很好地知道使用者的习惯、预知访问高峰等。
2.响应时间
健康的系统响应时间波动较小,持续均衡。
3.进程监控
检查操作系统中运行的应用(工作)进程数,如果低于某个预估值,就应当发出报警。
4.磁盘监控
监控磁盘用量,防止因为磁盘空间不足造成的系统问题,一旦磁盘用量超过警戒值,服务器的管理者就应该清理日志或者清理磁盘了。
5.内存监控
检查是否有内存泄漏的情况。如果内存只升不降,那么铁定就是内存泄漏了。健康的内存应该是有升有降的。
如果进程中存在内存泄漏,又一时没有排查解决,有一种方案可以解决这种情况,这种方案应用于多进程架构的服务集群,让每个工作进程指定服务多少次请求,达到请求数之后进程就不再服务新的链家,主进程启动新的工作进程来服务客户,旧的进程等所有连接断开后就退出。
6.cpu占用监控
cpu使用分为用户态、内核态、IOWait等,如果用户态cpu使用率较高,说明服务器上的应用需要大量的cpu开销,如果内核态cpu使用率较高,说明服务器花费大量时间进行进程调度或者系统调用,IOWait使用率则反应的是cpu等待磁盘IO操作。
用户态小于70%、内核态小于35%且整体小于70%,cpu处于健康状态。
7.cpu load监控
cpu load又称为cpu平均负载,描述操作系统当前的繁忙程度,可以简单的理解为cpu在单位时间内正在使用和等待使用cpu的平均任务数。它有三个指标,即1分钟的平均负载、5分钟的平均负载、15分钟的平均负载。cpu load 高说明进程数量过多,这在node中可能体现在用子进程模块反复启动新的进程。
8.IO负载
IO负载,主要讲的是磁盘IO,对于node来说,此类IO压力多半来源于数据库IO。
9.网络监控
主要监控网络流量,这个值可以查看公司的相关宣传是否有效,广告是否有效,是否增加了访问流量。(监控流入流量和流出流量)
10.应用状态监控
这个监控可以通过增加时间戳来实现:
app.use('/status', function (req, res) {
res.writeHead(200);
res.end(new Date());
})
同时,对于业务相关的内容也需要尽可能的打印出来。
11.DNS监控
可以基于第三方的软件进行检测,如DNSPod等,我们用的阿里云的DNS。
报警的实现
有了监控,那么就一定应该提供报警系统。一般情况下,报警系统有:邮件报警、IM报警、短信报警、电话报警
邮件报警
基于node编写邮件报警系统,可以通过nodemailer模块来实现,看个例子:
var nodemailer = require("nodemailer");
// 建建立一个SMTP传输连接
var smtpTransport = nodemailer.createTransport("SMTP", {
service: "Gmail",
auth: {
user: "[email protected]",
pass: "userpass"
}
});
// 邮件选项
var mailOptions = {
from: "Fred Foo ✔ ", // 发件人地址
to: "[email protected], [email protected]", // 收件人地址
subject: "Hello ✔ ", // 标题
text: "Hello world ✔ ", // 纯文本内容
html: "Hello world ✔ " // HTML内容
}
// 发送邮件
smtpTransport.sendMail(mailOptions, function (err, response) {
if (err) {
console.log(err);
} else {
console.log("Message sent: " + response.message);
}
})
短信或电话报警
对接短信或者电话平台即可。
监控系统的稳定性
对,需要确保监控系统的稳定性,否则,监控系统频繁出错,反而得不偿失了。我们公司使用了阿里云提供的检测系统,还不错,基本满足需求。
node服务稳定性
这本书,经常在阐述应用的稳定性问题,其中第四章从单进程角度描述了应用的稳定性,第九章从多进程角度阐述了应用的稳定性。单独一台服务器满足不了业务无限增长的需求,这就需要将node按多进程的方式部署到多台机器中,这样如果某台机器出现问题,其余机器为用户继续提供服务。另外,大企业也会进行异地机房灾备和搭建就近的服务器。这就抵消了一部分因为地理位置带来的网络延迟的问题。为了更好的稳定性,典型的水平扩展方式就是多进程、多机器、多机房,这样的分布式设计在现在的互联网公司并不少见。
多机器
看到上边的示意图,可以发现,在多机器的情况下,需要考虑负载均衡、状态共享和数据一致性等问题。
多机房
这个作者在书中秋风扫落叶的一笔带过。我也没有机会玩这样的部署,因此,不懂。
容灾备份
异构共存
node虽然神奇,但是,任何神奇的node功能,都是由操作系统的底层功能进行支持的。因此,node的异构共存,也是很简单和普遍的一件事。
如果不是标准协议,而是一个RESTful服务接口的话,也完全不存在问题,这样做其实就是现在比较流行的微服务架构的一个基础设计了。
总之,使用node不存在推翻已有设计的情况,node可以通过标准协议(tcp之类)和各种系统、语言和平相处。