2024修正版本: 代码地址 github
本文内容, 继续承接上文 我们现在已经有了一个“相对完整 相对还算不错的REST Service了”,但是呢这距离我们一个满足生产标准的Nodejs Service 来说,还是有不小的距离的。本文将会继续完善整个项目结构,使他能够达到一个 符合 “生产标准”的工程,如果你有任何建议,欢迎👏在评论区留下你的真知灼见。
关于性能优化
生产最佳实践:性能和可靠性 理论知识
这个是express 官方为我们提供的一些有用的建议, 我们先来看看哈, 这里面的内容主要是分了两个部分,一个编码需要注意的地方,和部署需要注意的地方,expressjs.com/en/advanced…
编码需要的地方
- 使用 gzip 压缩
- 不要使用同步函数
- 正确记录log
- 正确处理异常
// ------------ 使用gzip 进行 传输数据的压缩🗜️ ,但是一般来说 Nginx等会帮我们处理这个事,我们是不需要过多的关心
const compression = require('compression')
const express = require('express')
const app = express()
app.use(compression())
// 只需要向上面一样做就好啦,运用这个 compression 这个中间件就能处理了 传输的压缩工作
// ------------ 不要使用同步方法,你这样操作会导致堵塞
// 这里主要是指,虽然在Nodej中很多的API 提供了Sync同步的方法,但我们不推荐。尽量使用异步 去处理
// ------------ 正确的记录
// 实际上我们需要记录日志,主要是希望看到 服务上线之后,如果有异常我们能发现其发生原因然后准确的修复它,如果没有日志我们会很被动。
// 一般来说 最常用的解决方案是用一成熟的lib 去做log记录,比如 winston
// ------------正确处理异常
// 有时候,我们的程序依然必不可免的出现异常,在 任何程序中,你都应该妥善的处理他们,不管你是client 还是service 你都要处理他们,你可不希望 它影响你最终的结果,在express中我们有这样的处方
// 对于premise 记得添加 catch 对于async 方式的,需要try catch,并且把error 向外传递, 而且你应遵循 Nodejs中的原则:“错误优先” 需要注意⚠️ 你最不应该做的事情是 去监听 uncaughtException 会改变遇到异常的进程的默认行为容易导致:“僵尸进程”, 下面的处理方式我们认为是好的
app.get('/search', (req, res) => {
// Simulating async operation
setImmediate(() => {
const jsonStr = req.query.params
try {
const jsonObj = JSON.parse(jsonStr)
res.send('Success')
} catch (e) {
res.status(400).send('Invalid JSON string')
}
})
})
app.get('/', (req, res, next) => {
// do some sync stuff
queryDb()
.then((data) => makeCsv(data)) // handle data
.then((csv) => { /* handle csv */ })
.catch(next)
})
app.use((err, req, res, next) => {
// handle error
})
const wrap = fn => (...args) => fn(...args).catch(args[2])
app.get('/', wrap(async (req, res, next) => {
const company = await getCompanyById(req.query.id)
const stream = getLogoStreamById(company.id)
stream.on('error', next).pipe(res)
}))
部署需要注意的地方
这里更多的关注 代码之外的事情,比如自动重启等等
- 将 NODE_ENV 设置为“生产”
- 确保您的应用程序自动重启
- 在集群中运行您的应用程序
- 缓存请求结果
- 使用负载均衡器
- 使用反向代理
- 我们先来做第一件事情,设置环境。
经过官方 验证 环境设置非常重要 NODE_ENV = "production"时 是普通 模式 性能下的 三倍,我们可以这样 设置 package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "export NODE_ENV='development' && node app.js",
"build": "export NODE_ENV='production' && node app.js"
}
- 确保您的应用程序自动重启
- 在集群中运行您的应用程序:
这两个操作我们都可以在 nodejs 进程管理工具 pm2 上完成,非常的简单,后文中我们介绍了具体的实现细节,pm2官方文档 pm2.keymetrics.io/docs/usage/…
npm i -g pm2
# int 一下,生成 一个配置
pm2 init simple
构建配置文件, 在项根目录 ecosystem.config.js
// 名称任意,按照个人习惯来
module.exports = {
apps: [
{
name : "app1",
script : "./app.js",
max_restarts: 20, // 设置应用程序异常退出重启的次数,默认15次(从0开始计数)
cwd: "./", // 应用程序所在的目录
exec_mode: "cluster", // 应用程序启动模式,这里设置的是 cluster_mode(集群),默认是 fork
log_date_format:"YYYY-MM-DD HH:mm Z", // 日志时间格式
out_file:"./logOut.log",
instances:2, // 启动两个实例
env_production: {
NODE_ENV: "production"
},
env_development: {
NODE_ENV: "development"
}
}
],
};
pm2 start ecosystem.config.js
对于服务器自动重启后,要自动运行node服务,需要依赖服务的编排等功能,我们也可以人肉的设置一些配置, Systemd 是一个 Linux 系统和服务管理器。大多数主要的 Linux 发行版都采用 systemd 作为其默认的 init 系统。
systemd 服务配置文件称为单元文件,文件名以.service. 这是一个直接管理 Node 应用程序的示例单元文件。替换您的系统和应用程序中包含的值: 具体的参考手册是==> www.freedesktop.org/software/sy…
[Unit]
Description=<Awesome Express App>
[Service]
Type=simple
ExecStart=/usr/local/bin/node </projects/myapp/index.js>
WorkingDirectory=</projects/myapp>
User=nobody
Group=nogroup
# Environment variables:
Environment=NODE_ENV=production
# Allow many incoming connections
LimitNOFILE=infinity
# Allow core dumps for debugging
LimitCORE=infinity
StandardInput=null
StandardOutput=syslog
StandardError=syslog
Restart=always
[Install]
WantedBy=multi-user.target
实际上,上面的内容 Systemd 相关的你了解就好啦,pm2 也提供了快捷的设置方式
# 设置pm2开机自启
#(可选项:ubuntu, centos, redhat, gentoo, systemd, darwin, amazon)
# 然后按照提示需要输入的命令进行输入, 最后保存设置
pm2 startup centos
pm2 save
-
缓存请求结果;
我们依然可以使用Nginx 来处理请求缓存!注意是Http的请求缓存 文档地址在这里 serversforhackers.com/c/nginx-cac…
-
使用负载均衡器;
关于负载均衡,我们可以部署多个node service 实例,然后使用Nginx 去处理。也可以使用 pm2 ,没错pm2 自带有这个功能,不需要特殊设置
-
使用反向代理;
采用微服务 架构下不同的服务之间要频繁的额通信,我们使用反向代理,使得他们的调用在“内网”进行,或者在同一k8s 集群下进行,会大幅度提升 通信效率。最常用工具依然是Nginx,www.digitalocean.com/community/t…
实际操作
需要加上gzip 等压缩
如果你使用nginx,那么这个东西也可以省略不写,nginx 自带就能处理,
而且简单。我们这儿不用nginx,但是在实际项目产线必定80%就是Nginx, 虽然你不需要用,但是你得知道它怎么用
++++
const compression = require('compression');
++++
const app = express();
app.use(compression());
正确的处理日志
我们这里使用 winston,虽然我在nest 文章中使用的是log4j但是这并不影响什么,
如果我们使用pm2 那么这个实际上也是可选的,但是你如果希望自己处理一些业务什么上的log
那么自定义的这种东西还是有意义的. 关于winston 我们可以去看官方文档,
由于我们这个项目 没有复杂的业务,我们只是简单的记录了一些路由日志和错误日志 而已
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
defaultMeta: { service: 'user-service' },
transports: [
new winston.transports.File({ filename: 'log/error.log', level: 'error' }),
new winston.transports.File({ filename: 'log/combined.log' }),
],
});
module.exports = {
logger: logger,
};
//我们先定义,后面在使用
正确的处理 异常
我们结合 上面讲到的logger 就能很完美的处理 程序中的一些异常情况
// 在appjs中 给定一个路由 全局处理 未捕获的异常
// 设置一个路由,如果前面的都有问题,就到这里来处理错误
app.use((err, req, res, next) => {
logger.error({
level: 'error',
message: err.message,
});
res.json(err);
});
// 在utils 下定义一个wrap 文件减少 try 冗余代码
module.exports = {
// 一个包装工具 🔧可以马上把 路由函数的的error 处理到next 去,
// 减少try 的冗余代码
wrap:
(fn) =>
(...args) =>
fn(...args).catch(args[2]),
};
// 在每个路由处理中 都加上 wrap,一旦有err 它会自动next,最后走到我这个err 路由中
const express = require('express');
const generaController = require('../controllers/genraController');
const generaRouter = express.Router();
const { wrap } = require('../utils/');
// CRUD
generaRouter.post('/', wrap(generaController.create));
generaRouter.get('/', wrap(generaController.query));
generaRouter.put('/', wrap(generaController.update));
generaRouter.delete('/', wrap(generaController.deleteGenre));
module.exports = generaRouter;
修改环境
对于修改环境 ,我们可以很简单的在ackage中实现, 但是我们可以使用pm2 ,来更好操作
"start": "export NODE_ENV='development' && node app.js ",
"build": "export NODE_ENV='production' && node app.js "
使用pm2
首先我以我的这个小demo项目,小mac 机器作为演练环境
# 设置pm2 nodejs 本体的进程 ,在系统重启的时候自动重启
npm i -g pm2
# 设置为开启自启动
pm2 startup
pm2 save
# pm2 init 生成config 文件
pm2 init simple
# 编辑这个最简单的文件
module.exports = {
apps: [
{
name: 'app1',
script: './app.js',
max_restarts: 20, // 设置应用程序异常退出重启的次数,默认15次(从0开始计数)
cwd: './', // 应用程序所在的目录
exec_mode: 'cluster', // 应用程序启动模式,这里设置的是 cluster_mode(集群),默认是 fork 我们后面就讲 这是一些多进程的方式
log_date_format: 'YYYY-MM-DD HH:mm Z', // 日志时间格式
out_file: './log/pm2.log',
instances: 2, // 启动两个实例
env_production: {
NODE_ENV: 'production',
},
env_development: {
NODE_ENV: 'development',
},
},
],
};
# 最后直接去run 就好啦,当然你可以把这个东西写在你的 script中
pm2 start ecosystem.config.js
返向代理 http缓存
关于负载均衡这个话题,我们完全可以是哟Nginx去完成,但是使用pm2 也是ok的,怎么说呢,
这个话题比较大且空,所以这里不过过度介绍啦,基本上 负载均衡 这件事 pm2 也是内置且自
动管理的。在上述的一些优化中,我们讨论了 有关部署的话题 基本上一句话都离不开pm2 ,
比如:“修改环境变量pm2 能做”、“负载均衡和缓存pm2 也能处理” 处理异常和自动弄重启pm2
也能处理,....很多事情有关Node 部署和运维的pm2 都可以处理。所以我们有机会 ,
详细的介绍一下,现在你只需要了解这样一件事**你的Node 写好啦丢配置好一些配置,直接丢给
pm2处理** 就可了。
上述的代码在commi-m "feature:优化和pm2部署配置"
压力测试 + 性能优化,PM2的底层实现
本节的内容主是来讨论 Nodejs Service 性能的指标 和优化的建议. 我们将深入了解Nodejs 的压力测试 ,稳定性的实现原理和逻辑,这里我参考的文章是:参考
还原项目结构
我们需要还原上一个分支的内容,比如PM2 配置的内容,因为在这一讲,重点是性能 和测试,所以我们不实用一些pm2 之类的东西
现在我们需要这几件事情需要做
- 删除文件 ecosystem.config.js
- 还原script 配置
- 为了 让我们的项目更加的简洁合理,我们把公用的方法丢到utils中, 把db 的配置做好啦,把log 的方法抽离到啦utils中
整改项目 并且请出 我们的压测 工具
我们这种小规模的API,在性能上不会有什么大的差距,为了体现这种差距,我们新建一个test.js 来测试它的代码如下
我们去做一些基础的代码准备工作
const fs = require('fs')
const express = require('express')
const app = express()
app.get('/', (req, res) => {
res.end('hello world')
})
app.get('/index', (req, res) => {
const file = fs.readFileSync(__dirname + '/index.html', 'utf-8') // 永远遵循Node 是原则 不要去写同步的线程堵塞的代码
/* return buffer */
res.end(file)
/* return stream 这种方式更加好用 和 常用 通常做SSR 和 返回文件的时候都用这个 */
// fs.createReadStream(__dirname + '/index.html').pipe(res)
})
app.listen(3000)
介绍一下 压测工具 ab 文档在这 www.tutorialspoint.com/apache_benc… ,由于为的电脑是MAC 所以自带的有这个工具,下面是常用的命令参数
注意:压测只是一种手段,为了就是看看服务器的抗压能力, 然后我们需要通过各种手段找出 性能未达标的原因,以及想办法优化他
参数 解释
-c concurrency 设定并发数,默认并发数是 1
-n requests 设定压测的请求总数
-t timelimit 设定压测的时长,单位是秒
-p POST-file 设定 POST 文件路径,注意设定匹配的 -T 参数
-T content-type 设定 POST/PUT 的数据格式,默认为 text/plain
-V 查看版本信息
-w 以Html表格形式输出
我们来试一下:" 对上面的index 接口,每秒请求200个,总次数1600次 "
$ ab -c200 -n1600 http://127.0.0.1:3000/index
运行完成后会输出很多参数,我们主要关注的是 下面这几个指标。尤其是最后四个
Complete requests: 1600 # 请求完成成功数 这里判断的依据是返回码为200代表成功
Failed requests: 0 # 请求完成失败数
Total transferred: 8142400 bytes # 本次测试传输的总数据
HTML transferred: 7985600 bytes
Requests per second: 2188.47 [#/sec] (mean) # QPS 每秒能够处理的并发量
Time per request: 91.388 [ms] (mean) # 每次请求花费的平均时常
Time per request: 0.457 [ms] # 多久一个并发可以得到结果
Transfer rate: 10876.09 [Kbytes/sec] received # 吞吐量 每秒服务器可以接受多少数据传输量
指标有了之后,我们就需要针对性的解决问题,首先我们 来看,
硬件层面的:如果这里的吞吐量刚好是我们服务器的网卡带宽一样高,说明瓶颈来自于我们的带宽,而不是来自于其他例如cpu,内存,硬盘等等,那么我们其他的如何查看呢,我们可以借助这两个命令。 接下来我们一点一点的分析。从不同的维度出发,从不同的观点出发 看待优化这件事。
top 监控计算机cpu和内存使用情况
iostat 检测io设备的带宽的
硬件问题
我们就可以在使用ab压测的过程中实时查看服务器的状态,看看瓶颈来自于「cpu」、「内存」、「带宽」等等对症下药。当然存在一种特殊情况,很多场景下「NodeJs」只是作为「BFF」这个时候假如我们的「Node」层能处理600的「qps」但是后端只支持300,那么这个时候的瓶颈来自于后端。
软件问题
主要是指你的代码写的太烂了,拖垮了机器,那么什么样才算好代码?标准是什么?指标是什么?
我们看看使用什么工具来分析Node 的性能,我们都知道Node的基础是V8 ,那么我们可以用 chrome devtools 来采集这些性能数据,如果你觉得不够严谨可以使用node 自带的性能分析工具,但是它很麻烦,而且数据不直观(所以我们不讲哈) 。 他们的操作分别如下
--prof
$ node --prof test.js
# 它会生成一些性能分析文件.log 我们使用另一个命令解析它
$ ab -c50 -t5 http://127.0.0.1:3000/index
$ node --prof-process isolate-0x104a0a000-25750-v8.log > profile.txt
# 这样在这个文件里就会有非常详细的性能记录,js c++ gc 掉用栈信息 都有
使用chrome devtools
先我们使用下面命令挂上devtoos
$ node --inspect-brk test.js
然后我们前往这个地址 chrome://inspect/ ,找到自己的Remote Target 点击 inspect 进去,你就发现这个和你调试前端的时候大同小异,这里你可以记录性能还可以看火焰图,还是 比较直观和简单的.然后你就能找问题的本质,然后一个一个的处理这些 性能问题,如果是原创机器 使用ip连接就好啦,当然你可以试试这个工具 clinic 去调试远程服务器。
通过分析我们发现,这个掉用栈耗时14% 太高啦 ,然后是找到掉用方法,我们把它放到 外面掉用,启动的时候缓存起来,而不是每次都去读取文件
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.end('hello world');
});
// 我们改成启动的时候读取 而不是每次请求读取,约等于做啦一次缓存
// 在Nodejs中 底层是基于C++的,虽然我们后去的utf-8,c++底层识别到的是buffer 而且这个readFileSync,默认的buffer,如果我们去掉utf-8会更快
const file = fs.readFileSync(__dirname + '/index.html', 'utf-8');
app.get('/index', (req, res) => {
/* return buffer */
res.end(file);
/* return stream */
// fs.createReadStream(__dirname + '/index.html').pipe(res)
});
app.listen(3000);
再跑一下ab 现在好多啦。所以我们 得出一条结论 减少不必要的计算,我们可以缓存因为计算都要吃CPU的在并发的时候就会有瓶颈,做缓存就是一种空间换时间的方式
关于内存的优化,最重要的事情就是防止内存溢出 ,v8 GC 分两类 ,在devtools中有内存分析的工具
- 新生代:容量小、垃圾回收更快
- 老生代:容量大,垃圾回收更慢
想要优化,我们就要了解其底层原理,在Nodejs的buffer分配策略是这样的:“分两种情况<8kb 的时候,Node会先创一个8kb空间,然后计算buffer占用大小,然后从创建的8k中切一点出来用,依次新增”, 依据这样类似于池的概念,我们也可以这样去优化
关于多进程
上面我们说了 很多 硬件 + 软件 优化,现在我们来看看更加底层的优化
现在的计算机一般呢都搭载了多核的cpu,我们可以利用「多进程」或者「多线程」来尽量利用这些多核cpu来提高我们的性能。
进程:拥有系统挂载运行程序的单元 拥有一些独立的资源,比如内存空间 线程:进行运算调度的单元 进程内的线程共享进程内的资源 一个进程是可以拥有多个线程的
在Nodejs中 ,主进程在运行V8和JS,它是老板。在通过事件循环,LiibUv 由 四个字进程去工作。虽然JS是单线程的,但是我们可以通过Node提供的API 在其他CPU再跑一个JS环境,来充分利用多线程提升程序的性能。我们来尝试一下
- 我们先删除原来的test.js
- 然后呢 新建master & child 分别写入下面的内容
// master
/* 自带的子进程模块 */
const cp = require('child_process')
/* fork一个地址就是启动了一个子进程 */
const child_process = cp.fork(__dirname + '/child.js')
// 基本上和浏览器的work 一样 每次fork 启动一个字线程,但是我们推荐 启动的字线程个数在 os.cpus().length / 2 比较合适
/* 通过send方法给子进程发送消息 */
child_process.send('主进程发这个消息给子进程')
/* 通过 on message响应接收到子进程的消息 */
child_process.on('message', (str) => {
console.log('主进程:接收到来自自进程的消息', str);
})
// children
/* 通过on message 响应父进程传递的消息 */
process.on('message', (str) => {
console.log('子进程, 收到消息', str)
/* process是全局变量 通过send发送给父进程 */
process.send('子进程发给主进程的消息')
})
上面就上小打小闹的demo. 现在我们来介绍一个更加强大的线程管理 lib cluster,pm2就是基于它实现的线程管理,。具体说一下它的应用场景,我们想一下这样一种服务架构 "如果我们可以在不同的核分别去跑一个「http服务」那么是不是类似于我们后端的集群,部署多套服务呢,当客户端发送一个「Http请求」的时候进入到我们的「master node」,当我们收到请求的时候,我们把其请求发送给子进程,让子进程自己处理完之后返回给我,由主进程将其发送回去", 使用 cluster lib 就能很简单的实现
我们删除前面的两个mater 和 children 文件新建app2.js cluster.js, 分别写入如下的内容
// mater.js 正常的额启动文件
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.end('hello world');
});
// 我们改成启动的时候读取 而不是每次请求读取,约等于做啦一次缓存
const file = fs.readFileSync(__dirname + '/index.html', 'utf-8');
app.get('/index', (req, res) => {
res.end(file);
});
app.listen(3000);
// cluster
const cluster = require('cluster')
const os = require('os')
/* 判断如果是主线程那么就启动三个子线程 */
if(cluster.isMaster){
/* 多少个cpu启动多少个子进程 */
for (let i = 0; i < os.cpus().length; i++) cluster.fork()
} else {
/* 如果是子进程就去加载启动文件 */
require('./mater.js')
}
这个时候我们再去用ab 压测你会发现 性能的提升还是很大的,上面的操作呢,实际上就是用cpu 模拟了一下 负载均衡
优雅重启
还记得上面的优化中我们有提到一个点 :“在程序死掉的时候自动重启,同时不影响其服务”,这就是 优雅的重启,我们看看如果我们不使用pm2 如何实现, 对此我们方案也非常的简单,“心跳检测,杀掉僵尸进程” 主要需要做的工作如下
- 主线程每隔五秒发送一个心跳包ping,同时记录上发送次数+1,时间根据自己而定 这里五秒是测试方便
- 子线程接收到了ping信号回复一个pong
- 主线程接收到了子线程响应让计算数-1
- 如果大于五次都还没响应可能是假死了,那么退出线程并清空定时器,
只需要改造一下 clusterjs就好
const cluster = require('cluster')
const os = require('os')
/* 判断如果是主线程那么就启动三个子线程 */
if(cluster.isMaster){
/* 多少个cpu启动多少个子进程 */
for (let i = 0; i < os.cpus().length; i++) {
let timer = null;
/* 记录每一个woker */
const worker = cluster.fork()
/* 记录心跳次数 */
let missedPing = 0;
/* 每五秒发送一个心跳包 并记录次数加1 */
timer = setInterval(() => {
missedPing++
worker.send('ping')
/* 如果大于5次都没有得到响应说明可能挂掉了就退出 并清楚定时器 */
if(missedPing > 5 ){
process.kill(worker.process.pid)
worker.send('ping')
clearInterval(timer)
}
}, 5000);
/* 如果接收到心跳响应就让记录值-1回去 */
worker.on('message', (msg) => {
msg === 'pong' && missedPing--
})
}
/* 如果有线程退出了,我们重启一个 */
cluster.on('exit', () => {
cluster.fork()
})
} else {
/* 如果是子进程就去加载启动文件 */
require('./index.js')
/* 心跳回应 */
process.on('message', (msg) => {
msg === 'ping' && process.send('pong')
})
process.on('uncaughtException', (err) => {
console.error(err)
/* 进程错误上报 */
/* 如果程序内存大于xxxm了让其退出 */
if(process.memoryUsage().rss > 734003200){
console.log('大于700m了,退出程序吧');
process.exit(1)
}
/* 退出程序 */
process.exit(1)
})
}
总结一下 上面就是如何做优化的内容啦
以上的内容我们全部提交在 git commit -m"POA" 中,需要具体指出的是,一般来说我们都是用pm2去操作多线程,如果你有特殊需求采取自己控制线程.
很冷门 ,使用Jest做单元测试
我相信,国内几乎!没有几个公司几个哥们 认认真真去搞前端的单元测试的,我很少看到这方面有成熟且不错的方案,或者文章分享,希望看到这个文章的大佬,如果你对这个方面有建树,不妨一起来分享一下你们的经验,解析来我就以自己的观点出发,写来下面的内容。望各位高手斧正!,对于Nodejs来说,我们使用JEST 这个测试框架来测试, 我参考了这些文章参考1 对于源码实现感兴趣的同学可以来这里参考源代码。
在本例子中,我们将会实现: 服务启动测试,controller测试,service测试 middleware测试
-
第一步我们需要 整理目前的项目结构,我们把上面的东西都清理一下,只保留我们项目必要的业务文件,其他的demo文件全部删除掉,
-
我们新建一个文件夹__test__然后在里面编写测试,
-
重新编写app.js 把启动的代码丢外面,很重要!
// app.js
+++
app.use('/author', author);
app.use('/book', book);
app.use('/genre', genre);
app.use('/book-instance', bookInstance);
// 设置一个路由,如果前面的都有问题,就到这里来处理错误
app.use((err, req, res, next) => {
logger.error({
level: 'error',
message: err.message,
});
res.status(500).json(err);
});
module.exports = app;
// server.js
const { initConnection } = require('./db');
const app = require('./app');
initConnection().then(() => {
app.listen(3000, () => {
console.log('server start in 3000');
});
});
-
“步子不能迈太大,容易扯着蛋🥚”
现在我们来一点点的介绍,我们要测试什么,如何对它进行测试, 流程上是如何做的。 首先 明确要测试的方面有哪些:
server - 启动是否正常
middleware - 加载正常,请求时正常工作
controllers - 请求特定路由,看响应是否是符合预期
services - 调用特定方法,返回结果符合预期,边界情况
routes、lib - 普通测试
在正式测试之前,我们来看看一个简单的demo, 首先我们在 根目录新建这样的文件
// number-add.js 解析来它就是要被我们测试的文件, 现在的内容是这样的
const debug = require('debug');
module.exports = (a, b) => {
debug('value a: ', a);
debug('value b: ', b);
return a + b;
};
// describe是干什么的?主要是模块模块的每一个 测试用例 封装起来,
// 然后我们的测试文件 长这样,它在__test__文件夹下 number-add.test.js
describe('demo测试模块', () => {
// 在所有单测运行前执行,用于准备当前 describe 模块所需要的环境准备,比如全局的数据库;
beforeAll(() => {});
// 在每个单测运行前执行,用于准备每个用例(it)所需要的操作,比如重置 server app 操作
beforeEach(() => {
jest.mock('debug');
});
// 在每个单测运行后执行,用于清理每个用例(it)的相关变量,比如重置所有模块的缓存
afterEach(() => {
jest.resetModules();
});
// 在所有单测运行后执行,用于清理环境,比如清理一些为了单测而生成的“环境准备”
afterAll(() => {});
it('当 env 为默认的 development 环境时,返回 localhost 地址', () => {
const add = require('../number-add.js');
const total = add(1, 2);
console.log('total', total);
expect(total).toBe(3);
});
});
上面就是一个非常非常普通和简单的测试,我们来看看,我们要做完一个项目上的测试,还需要掌握哪些技能
-
单元测试必须要的 mock
mock是jest 中的模拟数据的工具,它有下面的几种作用:屏蔽外部影响、模拟外部的掉用
屏蔽外部影响
// 还记得我们上面的number-add 嘛,它引入了一个外部模块,所以我们在测试的时候这样模拟
// mock debug 模块,使得每次 require 该模块时,返回自动生成的 mock 实例
jest.mock('debug');
+++
// It 实际上就是test ,名字不一样而已
it('返回 a 和 b 的和', () => {
const add = require('utils/number-add')
const total = add(1, 2)
expect(total).toBe(3)
})
模拟外部调用
如果我的用例中有一个 promise 怎么办?,下面就是解决方案
// 这里我们改造一下,number-add
const debug = require('debug');
const fetch = require('node-fetch');
module.exports = async (apiA, apiB) => {
const stringA = await fetch(apiA);
const stringB = await fetch(apiB);
return stringA + stringB;
};
// 然后看看我们的东西到底如写 测试 number-add.test.js
describe('demo测试模块', () => {
beforeAll(() => {});
beforeEach(() => {});
afterEach(() => {
jest.resetModules();
});
afterAll(() => {});
it('测试外部掉用和异步', async () => {
// 模拟外部实现
jest.mock('node-fetch', () => {
return jest
.fn()
.mockImplementationOnce(async () => 'Hello ') // 首次调用时返回 'Hello '
.mockImplementationOnce(async () => 'world!'); // 第二次调用时返回 ' world!'
});
// 进行测试
const addAsync = require('../number-add');
const stringRes = await addAsync('apiA', 'apiB');
console.log('stringRES', stringRes);
expect(stringRes).toBe('Hello world!');
});
});
上面就是两个简单的例子了,现在我们看看 一个真正的mock 该怎么写
// 实际上我们不经常写 连续, 调用的代码,这样不够直观 所以我们在项目中一般是这样写的, 但是这不代表,你不用了解 第一种连续. 调用的写法 哈!
describe('测试 string-add-async 模块 2', () => {
it('返回接口 a 和 接口 b 所返回的字符串拼接', async () => {
// mock node-fetch 模块,使得每次 require 该模块时,返回 mock 实例
jest.mock('node-fetch')
const fetch = require('node-fetch')
fetch
.mockImplementationOnce(async () => 'Hello ') // 首次调用时返回 'Hello '
.mockImplementationOnce(async () => 'world!') // 第二次调用时返回 ' world!'
const addAsync = require('utils/string-add-async')
const string = await addAsync('apiA', 'apiB')
expect(string).toBe('Hello world!')
})
})
我们再来看看在测试中的mock 实例,它具备下面的特点
当一个模块被 mock 之后,便返回了一个 mock 实例,该实例上有丰富的方法可以用来进一步 mock;且还给出了丰富的属性用以断言
mockImplementation(fn) 其中 fn 就是所 mock 模块的实现 mockImplementationOnce(fn) 与 1 类似,但是仅生效一次,可链式调用,使得每次 mock 的返回都不一样 mockReturnValue(value) 直接定义一个 mock 模块的返回值 mockReturnValueOnce(value) 直接定义一个 mock 模块的返回值(一次性) mock.calls 调用属性,比如一个 mock 函数 fun 被调用两次:fun(arg1, arg2); fun(arg3, arg4);,则 mock.calls 值为 [['arg1', 'arg2'], ['arg3', 'arg4']]
-
现在所有你该了解的知识你应该都已经了解啦,接下来进入真实的项目测试环
别忘记把 冗余文件删除掉,我们来构建 test 更详细的目录, 为了测试 中间件 middleware 现在我们写一个简单的中间件,它的作用就是记录每一个请求日志
// middleware/reqInfo.js
const { logger } = require('../utils/logger');
module.exports = {
reqInfo: (req, res, next) => {
logger.info({
level: 'info',
message: {
reqData: Date.now(),
requestType: req.method,
originURL: req.originalUrl,
},
});
next();
},
};
// app.js 应用
+++
const app = express();
app.use(bodyParser.json());
app.use(compression());
// 引用全局中间件
app.use(reqInfo);
+++
好了,所以准备工作我们都做完啦, 现在开始编写单元测试
第一:测试程序的启动和运行
const supertest = require('supertest');
const { initConnection, disconnect } = require('../../db');
const app = require('../../app');
// 重要介绍,每次app.listen 都是启动的一个服务实例 的操作,所以我们才把应用的启动
// 和app 配置 分开,还要注意,由于实例的问题,如果你在每个 it 中都去listen 会导致有问题
// 所以我们把它 放在 这些钩子 中 ,很重要!!另外我们一定要配合 supertest 来做nodejs 的
// 单元测试,现在我们有来这样的基础的模板 template 后面的代码我们都回你用到它
describe('server 服务', () => {
let server;
beforeAll(async () => {
// 数据库连接, 如果返回的是一个异步的 jest会等待它
return initConnection();
});
beforeEach(() => {
if (server) {
server.close();
}
server = app.listen(3331);
});
afterEach(() => {});
afterAll(() => {
//释放数据库连接
server.close();
return disconnect();
});
it('启动正常', async () => {
expect(() => supertest(server)).not.toThrow();
});
it('服务异常', async () => {
await supertest(server)
.post('/author')
.send({ name: 'john' })
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
console.log('res', res);
})
.catch((err) => {
expect(err.message).toBe(
'expected 200 "OK", got 500 "Internal Server Error"',
);
});
});
});
第二:测试中间件
还记得我们上面写的那个中间件吗?我们现在来测试它,如果你要测试这个中间件,你需要知道logger 是否正常工作,你需要引入logger 然后访问特点的URL之后看看它存的日志和是否和预期一致,接下来你我们来测试它
const supertest = require('supertest');
const { initConnection, disconnect } = require('../../../db');
const app = require('../../../app');
const { reqInfo } = require('../../../middleware/reqInfo');
const { logger } = require('../../../utils/logger');
describe('server 服务', () => {
let server;
beforeAll(async () => {
return initConnection();
});
beforeEach(() => {
if (server) {
server.close();
}
server = app.listen(3333);
jest.resetModules()
});
afterEach(() => {});
afterAll(() => {
server.close();
return disconnect();
});
it('req-info 日志正常', async () => {
await supertest(server)
.get('/author')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
// 这个查询的逻辑需要去看 winston 官方文档
const loggerPromiseQuery = () => {
return new Promise((resolve, reject) => {
logger.query(
{
from: new Date() - 24 * 60 * 60 * 1000,
until: new Date(),
limit: 10,
start: 0,
order: 'desc',
},
(err, res) => {
const messageInfo = res.file.filter(
(it) => it.level === 'info' && typeof it.message === 'object',
)[0];
resolve(messageInfo.message.originURL);
},
);
});
};
const value = await loggerPromiseQuery();
// 如果日志和测试一样 说明 这个日志是对等的 这个case 通过
expect(value).toBe('/author');
});
});
第三:controllers接口测试
基本的结构和上文的保持一致,大概不会差很多, 我们需要用到mock 来保障 屏蔽外部的影响. 举个简单的例子来说 比如 还是 get Author 这个API, 我们需要测试它正常和异常场景, 这次我们聚焦在controller 层的测试,因此我们 需要屏蔽 service 的实现带来的影响,所以我们mock service 的实现
const supertest = require('supertest');
const { initConnection, disconnect } = require('../../../db');
const app = require('../../../app');
const { logger } = require('../../../utils/logger');
describe('author controller', () => {
let server;
beforeAll(async () => {
return initConnection();
});
beforeEach(() => {
if (server) {
server.close();
}
server = app.listen(3333);
});
afterEach(() => {});
afterAll(() => {
server.close();
return disconnect();
});
it('controller get success', (done) => {
supertest(server)
.get('/author')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
// console.log('status-->', res.status); 其实 = 200 也是说明这个测试用例通过来
// console.log('body-->', res.body);
if (err) return done(err);
return done();
});
});
});
第四:service测试
和上文的测试保持一致就好,这里不过多的详细说明
最后我们说一下 评价标准, 另外这里有一篇文章,个人认为写的非常不错,大家可以参考一下单元测试到底如何写?
测试维度: 我们认为 源代码被测试的比例有四个维度去度量
- 行覆盖率(line coverage):是否每一行都执行了?
- 函数覆盖率(function coverage):是否每个函数都调用了?
- 分支覆盖率(branch coverage):是否每个if代码块都执行了?
- 语句覆盖率(statement coverage):是否每个语句都执行了?
总结
至此所有的MongoDB - Nodejs 项目就结束啦,我们覆盖到了 入门-业务开发-部署和性能优化 -单元测试,这个项目的目的旨在 为了,提供一个 graphql-node分支的测试环境接下来我们会到主要的目标构建Graphql-NodeService