n
n
nodebook
搜索文档…
9 Node.js 最佳实践

9.1 配置文件

一般代码的运行的环境起码应该包括本地开发环境和线上运行环境,那么问题来了,你开发环境用的配置信息可是跟线上环境不一样的。那么已经存储这个配置信息呢?在代码中写死肯定是最low的方式。更通用的方式是使用配置文件,可是你一旦将这个配置文件就面临一个问题,你这个配置文件一旦提交到了 git 之后,你的同事 pull 代码之后,就有可能就他本地配置文件覆盖掉,而这个配置文件中又包含了本地文件路径有关的配置,但是你和你的同事用的还不是一个操作系统(一个 windows,一个 mac),想想这个场景就恶心。那如果我们不将这个配置文件提交 git 呢?每次新增配置文件选项,都需要口头通知你的同事们,又比如有新同事加入到这个项目来了,你只能把你电脑上存储的配置文件发他一份,让他做相应的修改再使用,想想这个场景就恶心。 其实解决这个问题的办法也很简单,就是在 git 上放置一个配置文件的示例文件,我们就假设它为 config.example.json ,里面写入所有的示例配置项。然后在 .gitignore 中将 config.json 添加进去,最后你在代码中加载 config.json 这个配置文件,这样子的话大家都可以使用自己的配置文件,不会相互干扰,同时在 git 还存留了一份示例文件,配置项的更改都可以呈现到这个示例文件上。貌似是个完美的解决方案,其实这种解决方案不仅适用用 node ,任何语言都适用。 但,这并不是问题的终点,我们是给出了一个配置示例文件 config.example.json,但是如果配置项有修改,你的同事不修改 config.example.json 怎么办?那就把错误扼杀在摇篮中吧,在我们的项目中会引入一个 setting.js 的文件来负责做配置项校验,在应用加载时,如果检测到某个参数不存在或者非法,就直接退出当前进程,让你启动不起来:
1
var log4js = require('log4js');
2
var mongoskin = require('mongoskin');
3
var redis = require('redis');
4
var slogger = require('node-slogger');
5
6
var configObj = require('../config.json');
7
var settings = require('./lib/settings').init(configObj);
8
exports.port = settings.loadNecessaryInt('port');
9
10
//保证配置文件中的debugfilename属性存在,且其所在目录在当前硬盘中存在
11
var debugFile = settings.loadNecessaryFile('debuglogfilename', true);
12
var traceFile = settings.loadNecessaryFile('tracelogfilename', true);
13
var errorFile = settings.loadNecessaryFile('errorlogfilename', true);
14
15
log4js.configure({
16
appenders: [
17
{type: 'console'},
18
{type: 'dateFile', filename: debugFile, 'pattern': 'dd', backups: 10, category: 'debug'}, //
19
{type: 'dateFile', filename: traceFile, 'pattern': 'dd', category: 'trace'},
20
{type: 'file', filename: errorFile, maxLogSize: 1024000, backups: 10, category: 'error'}
21
],
22
replaceConsole: true
23
});
24
25
var debugLogger = exports.debuglogger = log4js.getLogger('debug');
26
var traceLogger = exports.tracelogger = log4js.getLogger('trace');
27
var errorLogger = exports.errorlogger = log4js.getLogger('error');
28
slogger.init({
29
debugLogger:debugLogger,
30
traceLogger:traceLogger,
31
errorLogger:errorLogger
32
});
33
34
35
var dbConfig = settings.loadNecessaryObject('db');//保证配置文件中的db属性存在
36
if (dbConfig.url instanceof Array) {
37
exports.db = mongoskin.db(dbConfig.url, dbConfig.dbOption, dbConfig.relsetOption);
38
} else {
39
exports.db = mongoskin.db(dbConfig.url, dbConfig.dbOption);
40
}
41
42
var redisConfig = settings.loadNecessaryObject('redis');//保证配置文件中的redis属性存在
43
exports.redis = redis.createClient(redisConfig.port, redisConfig.host);
Copied!
代码 9.1 使用配置文件

9.2 自动重启

作为一个健壮的线上环境,肯定不希望自己的应用程序垮掉。然而,现实开发中在代码中总是会时不时出现未捕获的异常导致程序崩溃,真实编程实践中,我们肯定会对代码慎之又慎,但是想要代码100%无bug是不可能的,想想那个整天升级打补丁的微软。 我们用下面代码监听未捕获异常:
1
process.on('uncaughtException', function(err) {
2
try {
3
errorlogger.error('出现重大异常,重启当前进程',err);
4
} catch(e) {
5
console.log('请检查日志文件是否存在',e);
6
}
7
8
console.log('kill current process:'+process.pid);
9
process.exit();
10
});
Copied!
代码 9.2.1 监听未捕获异常代码 9.2.1中最后一行将当前进程强制退出,这是由于如果不这么做的话,很有可能会触发内存泄漏。我们肯定希望进程在意外退出的时候,能够重新再启动。这种需求其实可以使用 Node 的 cluster 来实现,这里我们不讲如何通过代码来达到如上需求,我们介绍一个功能十分之完备的工具——pm2。 首先我们运行 cnpm install pm2 -g 对其进行全局安装。为了做对比,我们首先来观察不用pm2的效果。本章用的源码是第6章的基础上完成的,由于在第6章中我们使用了登陆拦截器,为了不破坏这个结构,我们新生成一个路由器,放置在 routes/test.js,然后在 app.js 中引入这个拦截器:
1
app.use('/test',testRotes);
2
app.use(authFilter);
3
app.use('/', routes);
Copied!
代码 9.2.2 添加测试路由器 然后在 routes/test.js 中添加让程序崩溃的代码:
1
router.get('/user', function(req, res) {
2
setTimeout(function() {
3
console.log(noneExistVar.pp);
4
res.send('respond with a resource');
5
},0);
6
});
Copied!
代码 9.2.3 导致进程崩溃 可能你要问,这个地方为啥要加个 setTimeout ,因为如果你不把这个错误放到异步代码中,就会像代码 5.2.6那样被express本身捕获到,就不会触发未捕获异常了。 最后启动应用,访问 /test/user 路径,不出意外,程序崩溃了。 然后我们用 pm2 来启动:
pm2 start src/bin/www 运行成功后会有如下输出:
1
[PM2] Spawning PM2 daemon
2
[PM2] PM2 Successfully daemonized
3
[PM2] Starting src/bin/www in fork_mode (1 instance)
4
[PM2] Done.
5
┌──────────┬────┬──────┬──────┬────────┬─────────┬────────┬─────────────┬──────────┐
6
7
│ App name │ id │ mode │ pid │ status │ restart │ uptime │ memory │ watching │
8
├──────────┼────┼──────┼──────┼────────┼─────────┼────────┼─────────────┼──────────┤
9
10
│ www │ 0 │ fork │ 5804 │ online │ 0 │ 10s │ 29.328 MB │ disabled │
11
└──────────┴────┴──────┴──────┴────────┴─────────┴────────┴─────────────┴──────────┘
12
13
Use `pm2 show <id|name>` to get more details about an app
Copied!
输出 9.2.1 pm2 命令还有好多命令行参数,如果单纯手敲的话就太麻烦了,幸好它还提供了通过配置文件的形式来指定各个参数值,它支持使用 json 或者 yaml 格式来书写配置文件,下面给出一个 json 格式的配置文件:
1
{
2
apps : [{
3
name : "chapter7",
4
script : "./src/bin/www",
5
instances : 2,
6
watch : true,
7
error_file : "/temp/log/pm2/chapter7/error.log",
8
out_file : "/temp/log/pm2/chapter7/out.log",
9
env: {
10
"NODE_ENV": "development",
11
},
12
env_production : {
13
"NODE_ENV": "production"
14
}
15
}]
16
}
Copied!
配置文件 9.2.1 process.json
我为啥要在日志文件的路径配置项上写linux路径呢,因为在 windows 下使用 pm2 ,一旦出现未捕获异常,进程重启的时候,都会弹出命令行窗口来抢占当前的桌面。所以我只能在 linux 下进行测试。并且经过测试,如果使用node 0.10.x版本的话,遇到未捕获异常时,进程无法重启,会僵死,所以推荐使用 4.x+版本。
接着运行如下命令来启动项目:
1
pm2 start process.json
Copied!
命令 9.2.1 如果你想重启当前项目,运行:
1
pm2 restart process.json
Copied!
命令 9.2.2
如果想关闭当前进程,运行:
1
pm2 stop process.json
Copied!
命令 9.2.3
你还可以使用命令 pm2 logs chapter7 来查看当前项目的日志。最后我们来测试一下,访问我们故意为之的错误页面http://localhost:8100/test/user,会看到控制台中会打印重启日志:
1
chapter7-0 ReferenceError: noneExistVar is not defined
2
chapter7-0 at null._onTimeout (/home/gaoyang/code/expressdemo/chapter7/src/routes/test.js:7:17)
3
chapter7-0 at Timer.listOnTimeout (timers.js:92:15)
4
chapter7-0 [2016-09-16 23:28:14.016] [ERROR] error - 出现重大异常,重启当前进程 [ReferenceError: noneExistVar is not defined]
5
chapter7-0 ReferenceError: noneExistVar is not defined
6
chapter7-0 at null._onTimeout (/home/gaoyang/code/expressdemo/chapter7/src/routes/test.js:7:17)
7
chapter7-0 at Timer.listOnTimeout (timers.js:92:15)
8
chapter7-0 [2016-09-16 23:28:14.025] [INFO] console - kill current proccess:6053
9
chapter7-0 load var [port],value: 8100
10
chapter7-0 load var [debuglogfilename],value: /tmp/debug.log
11
chapter7-0 load var [tracelogfilename],value: /tmp/trace.log
12
chapter7-0 load var [errorlogfilename],value: /tmp/error.log
13
chapter7-0 [2016-09-16 23:28:14.908] [INFO] console - load var [db],value: { url: 'mongodb://localhost:27017/live',
14
chapter7-0 dbOption: { safe: true } }
15
chapter7-0 [2016-09-16 23:28:14.934] [INFO] console - load var [redis],value: { port: 6379, host: '127.0.0.1' }
16
chapter7-0 [2016-09-16 23:28:15.003] [INFO] console - Listening on port 8100
Copied!
输出 9.2.1 我们看到进程自己重启了,最终实现了我们的目的。

9.3 开机自启动

虽然我们在服务上线的时候,可以请高僧来给服务器开光,其实只要不是傻子就看得出来那只不过博眼球的无耻炒作而已。机器不是你想不宕就不宕,所以说给你的服务加一个开机自启动,是绝对有必要的,庆幸的是 pm2 也提供了这种功能。
以下演示命令是在 Ubuntu 16.04 做的,其他服务器差别不大,首先运行 pm2 startup,正常情况会有如下输出:
1
[PM2] Writing init configuration in /etc/init.d/pm2-root
2
[PM2] Making script booting at startup...
3
>>> Executing chmod +x /etc/init.d/pm2-root
4
[DONE]
5
>>> Executing mkdir -p /var/lock/subsys
6
[DONE]
7
>>> Executing touch /var/lock/subsys/pm2-root
8
[DONE]
9
>>> Executing chkconfig --add pm2-root
10
[DONE]
11
>>> Executing chkconfig pm2-root on
12
[DONE]
13
>>> Executing initctl list
14
rc stop/waiting
15
tty (/dev/tty3) start/running, process 2312
16
tty (/dev/tty2) start/running, process 2310
17
tty (/dev/tty1) start/running, process 2308
18
tty (/dev/tty6) start/running, process 2318
19
tty (/dev/tty5) start/running, process 2316
20
tty (/dev/tty4) start/running, process 2314
21
plymouth-shutdown stop/waiting
22
control-alt-delete stop/waiting
23
rcS-emergency stop/waiting
24
kexec-disable stop/waiting
25
quit-plymouth stop/waiting
26
rcS stop/waiting
27
prefdm stop/waiting
28
init-system-dbus stop/waiting
29
splash-manager stop/waiting
30
start-ttys stop/waiting
31
rcS-sulogin stop/waiting
32
serial stop/waiting
33
[DONE]
34
+---------------------------------------+
35
[PM2] Freeze a process list on reboot via:
36
$ pm2 save
37
38
[PM2] Remove init script via:
39
$ pm2 unstartup systemv
Copied!
按照上面的提示,用 pm2 save 产生当前所有已经启动的 pm2 应用列表,这样下次服务器在重启的时候就会加载这个列表,把应用再重新启动起来。
最后,如果不想再使用开机启动功能,运行 pm2 unstartup systemv 即可取消。

9.4 使用docker

随着智能设备的蓬勃发展,整个互联网的网民总数出现了井喷,对于软件开发者来说,面对的用户群体越来庞大,需求变化原来越快,导致软件开发的规模越来越大,复杂度越来越高。为了应对这些趋势,最近几年一些新的技术渐渐被大家接受,比如说 devops,比如说我们接下来要讲的 docker 容器。
有了docker,大家就可以本地开发代码,然后开发完成之后直接打一个包扔到服务器上运行,这个包就是我们所说的容器,它跟宿主机无关,不管运行在何种宿主机上,它的内部环境都是一致。所以说有了docker,我们再也不用担心在本地跑的好好的,结果一到服务器就出错的问题了。
当然如果你们服务器使用了Docker 技术的话,9.3小节的内容就没有必要使用了。因为在 Docker 上是没法设置开机服务的。
pm2 提供了生成 Dockerfile 的功能,不过生成的文件实用性不是很强,我需要稍加改造了一下。另外为了方便的演示docker使用,专门在 oschina 新建一个代码仓库用于第8章代码。下面演示一下dockerfile的编写,具体流程是在docker构建的时候,使用 git clone 从仓库中拿去代码,然后安装所需的依赖。构建完成之后,每次启动这个docker容器的使用使用 pm2 命令启动当前应用。dockerfile的示例代码如下:
1
FROM mhart/alpine-node:latest
2
3
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
4
RUN apk update && apk add git && apk add openssh-client && rm -rf /var/cache/apk/*
5
6
#创建应用目录
7
RUN mkdir -p /var/app
8
RUN mkdir -p /var/log/app
9
#将git clone用的sshkey的私钥拷贝到.ssh目录下
10
COPY deploy_key /root/.ssh/id_rsa
11
RUN chmod 600 ~/.ssh/id_rsa
12
#将当前git服务器域名添加到可信列表
13
RUN ssh-keyscan -p 22 -t rsa git.oschina.net >> /root/.ssh/known_hosts
14
15
WORKDIR /var/app
16
17
#clone代码
18
RUN git clone [email protected]:nodebook/chapter9.git .
19
#拷贝配置文件
20
COPY config.production.json config.json
21
COPY process.production.json process.json
22
23
#安装cnpm
24
RUN npm install -g cnpm --registry=https://registry.npmmirror.com
25
#安装pm2
26
RUN cnpm install pm2 -g
27
RUN cnpm install
28
29
#向外暴露当前应用的端口
30
EXPOSE 8100:8100
31
32
## 设置环境变量
33
ENV NODE_ENV=production
34
# 启动命令
35
CMD ["pm2-docker", "process.json"]
Copied!
代码 9.4.1 Dockerfile示例
其中 From 代表使用的基础镜像,alpine 是一个非常轻量级的 linux 发行版本,所以基于其制作的 docker 镜像非常小,特别利于安装。这里的 alpine-node 在 alpine 操作系统上集成了 node ,单纯 pull 安装的话也非常小。然后 RUN 和 COPY 两个命令是在构建的时候执行命令和拷贝文件,注意 COPY 命令仅仅只能拷贝当前执行docker 命令的目录下的文件,也就是说拷贝的时候不能使用相对路径,比如说你要执行 COPY xxx/yyy /tmp/yyy 或者 COPY ../zzz /tmp/zzz 都是不允许的。为了正确的 clone git 服务器上的代码,我们还需要配置一下 部署密钥。 谈到部署密钥的概念,这里还要多说几句。我们一般从git服务器上clone下来代码后,会对代码进行编写,然后 push 你编写后的新代码。但是服务器上显然是不适合在其上面进行直接改动代码的场所,所以就有了部署密钥的概念,使用部署密钥你可以做 clone 和 pull 操作,但是你不能做 push 操作。
1
$ ssh-keygen -f deploy_key -C "[email protected]"
2
Generating public/private rsa key pair.
3
Enter passphrase (empty for no passphrase):
4
Enter same passphrase again:
5
Your identification has been saved in deploy_key.
6
Your public key has been saved in deploy_key.pub.
7
The key fingerprint is:
8
SHA256:S3JbyWc68K43kifBwYcJJxlIFlDlXz9MJDGI6gEhFKw [email protected]
9
The key's randomart image is:
10
+---[RSA 2048]----+
11
|+o+==+o+ .+.. |
12
| o....= o + |
13
|. . ..= o. . |
14
|E o .=o.= |
15
| . ...So+ * |
16
| . +o* + . |
17
| oo+ |
18
| +.+. |
19
| .*.. |
20
+----[SHA256]-----+
Copied!
命令9.4.1 生成密钥对
我们在第8章项目代码根目录下新建一个 deploy 文件夹,进入这个文件夹然后运行 命令 9.4.1,一路回车即可。然后我们就得到了 代码 9.4.1 中的 deploy_key了。生成完了之后去 git.oschina.com 上配置一下公钥(也就是我们生成的 deploy_key.pub 文件),在项目页(在这里是 http://git.oschina.net/nodebook/chapter8 )上点击 管理 导航链接(),在打开的页面中点击 部署公钥管理,然后选择 添加公钥,用记事本打开刚才生成的 deploy_key.pub 文件,全选复制,然后贴到输入框中:
图 9.4.1 添加部署公钥
最后要注意一下 EXPOSE 命令,他代表 docker 及向宿主机暴露的端口号,如果不暴露端口的话,在宿主机上没法访问我们应用监听的端口。 我们运行 docker build -t someone/chapter8 . 其中 -t 参数指定当前镜像的 tag 名称, someone 是指你在 docker hub 网站上注册的用户,build 成功后你可以通过 docker push someone/chapter8 将构建后的结构 push 到 docker hub 网站上去,然后在服务器上运行 docker pull someone/chapter8 来拿取你当初 push 的仓库。当然你可以直接将 Dockerfile 拿到你的服务器上执行 build 命令,这时候 -t 参数可以随便指定,甚至不写。
鉴于国内的网络环境问题,在做 build 的时候,pull 基础镜像很有可能会失败,这时候你就只能求助于国内的 docker 镜像站了,比如说 daocloud
build 命令运行完成之后,运行 docker images 会输出:
1
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
2
someone/chapter8 latest 2a1a00cc1b41 4 minutes ago 147.7 MB
Copied!
最后我们通过 docker run -d --name chapter8 someone/chapter8 即可生成一个 docker 容器。其中 -d 参数代表在后台运行, --name 指定当前 docker 容器的名称, someone/chapter8 说明我们使用刚才 build 的镜像来生成容器。 通过 docker ps 命令的输出,我们可以查看生成的 docker 容器:
1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2
fb0d726a86dc someone/chapter8 "pm2-docker process. 4 seconds ago Up 4 seconds 8100/tcp chapter8
Copied!

9.5 代码

本章代码9.1、9.2小节代码和第8章存储在相同位置:https://github.com/yunnysunny/nodebook-sample/tree/master/chapter8 , 9.4章节代码为演示方便专门做了一个仓库,位于:http://git.oschina.net/nodebook/chapter8 。