一、使用 express
- express 是 nodejs 最常用的 web server 框架
- 安装(使用脚手架 express-generator)
1.1 安装 express
npm install express-generator -g
express express-test
npm install & npm start
1.2 登录
- 使用
express-session
和connect-redis
,简单方便 req.session
保存登录信息,登录校验做成 express 中间件
1.2.1 express-session
-
安装
npm i express-session --save
-
使用
const session = require('express-session') // ... app.use(session({ secret: 'WJiol#23123_', // 密钥 cookie: { // path: '/', // 默认配置 // httpOnly: true, // 默认配置 maxAge: 24 * 60 * 60 * 1000 } }))
1.2.2 connect-redis
-
安装redis和connect-redis:
npm i redis connect-redis --save
-
配置redisClient文件
// 该文件生成 redis 客户端 const redis = require('redis') const { REDIS_CONF } = require('../conf/db') // 创建redis的客户端 const redisClient = redis.createClient(REDIS_CONF.port, REDIS_CONF.host) // 监听是否发生错误 redisClient.on('error', err => { console.error(err); }) module.exports = redisClient
-
使用
const session = require('express-session') const RedisStore = require('connect-redis')(session) const redisClient = require('./db/redis') const sessionStore = new RedisStore({ client: redisClient }) app.use(session({ secret: 'WJiol#23123_', // 密钥 cookie: { // path: '/', // 默认配置 // httpOnly: true, // 默认配置 maxAge: 24 * 60 * 60 * 1000 }, store: sessionStore }))
1.3 日志
- access log 记录,直接使用脚手架推荐的 morgan
- 自定义日志使用 console.log 和 console.error 即可
- 日志文件拆分、日志内容分析
app.use(loger())
使用morgan时第一个参数指定每一行日志输出格式,第二个参数是配置对象,stream用于指定输出流,默认是process.stdout
标准输出流,打印到控制台;也可以根据文件路径生成一个writeStream
写入流,写入到文件中
var logger = require('morgan'); // 记录日志
const ENV = process.env.NODE_ENV
// 如果不是线上环境,就是用dev格式记录日志,并以标准输出流打印到控制台上(默认第二个参数是{stream: process.stdout})
if (ENV != 'production') {
app.use(logger('dev'));
} else {
// 如果是线上环境,就把日志写入文件
// 拿到文件路径,生成写入流对象
const logFileName = path.join(__dirname, 'logs', 'access.log')
const writeStream = fs.createWriteStream(logFileName, {
flags: 'a' // 追加写入
})
// 指定stream为上面生成的写入流
app.use(logger('combined', {
stream: writeStream
}));
}
二、express 中间件原理
2.1 分析
app.use
用来注册中间件,先收集起来- 遇到
http
请求,根据path
和method
判断触发哪些 - 实现
next
机制,即上一个通过 next 触发下一个 app.use(func)
不传入路径,相当于app.use('/', func)
2.2 实现
- 把通过use、get、post注册的中间件保存在实例上
- register函数用于统一注册,根据是否传入path,生成info信息,info保存path和当前注册的中间件数组,因为一次use可能传入多个中间件
- match用于根据当前url以及method,去匹配数组中存放的中间件信息,统一提取到一个数组中
- handle方法里面定义了next,next每次从匹配的中间件数组中取第一个执行。handle中主动调用了第一次next方法。
- callback是生成server实例的回调,里面调用了handle方法,保证第一个next中间件执行。
const http = require('http')
const slice = Array.prototype.slice
class LikeExpress {
constructor() {
// 存放中间件的列表
this.routes = {
all: [], // 存放app.use注册的
get: [], // 存放app.get注册的
post: [] // 存放app.post注册的
}
}
register(path) {
const info = {}
if (typeof path === 'string') {
info.path = path
// 把传入参数除了第一个path之外(即之后的中间件函数),存入stack数组,
info.stack = slice.call(arguments, 1)
} else {
// 如果没有传入路径,就默认指定为'/'根路径
info.path = '/'
info.stack = slice.call(arguments, 0)
}
// info中存储path,当前路由,stack所有中间件
return info
}
use() {
// 这里不知道为啥要用apply改变this执行
const info = this.register.apply(this, arguments)
this.routes.all.push(info)
}
get() {
const info = this.register.apply(this, arguments)
this.routes.get.push(info)
}
post() {
const info = this.register.apply(this, arguments)
this.routes.post.push(info)
}
match(method, url) {
let stack = []
if (url === '/favicon.ico') {
return stack
}
// 获取 routes
let curRoutes = []
curRoutes = curRoutes.concat(this.routes.all) // use注册的,无论get、post都执行
curRoutes = curRoutes.concat(this.routes[method])
// 根据每一个路由的path和当前url,过滤出匹配的中间件信息
curRoutes.forEach(routeInfo => {
if (url.indexOf(routeInfo.path) === 0) {
stack = stack.concat(routeInfo.stack)
}
})
return stack
}
// 核心的 next 机制
handle(req, res, stack) {
const next = () => {
// 每次执行next都把第一个中间件从数组弹出,拿到第一个匹配的中间件
const middleware = stack.shift()
if (middleware) {
// 执行中间件函数
middleware(req, res, next)
}
}
next()
}
callback() {
return (req, res) => {
res.json = (data) => {
res.setHeader('Content-type', 'application/json')
res.end(JSON.stringify(data))
}
const url = req.url
const method = req.method.toLowerCase()
// match用于根据当前url和中间件的path匹配,拿到所有中间件中需要访问的
const resultList = this.match(method, url)
// handle里面定义了next方法,每次弹出数组第一个中间件执行
this.handle(req, res, resultList)
}
}
listen(...args) {
const server = http.createServer(this.callback())
server.listen(...args)
}
}
// 导出一个工厂函数
module.exports = () => {
return new LikeExpress()
}
2.3 测试
// 测试文件
const express = require('./like-express')
// 本次 http 请求的实例
const app = express()
app.use((req, res, next) => {
console.log('请求开始...', req.method, req.url)
next()
})
app.use((req, res, next) => {
// 假设在处理 cookie
console.log('处理 cookie ...')
req.cookie = {
userId: 'abc123'
}
next()
})
app.use('/api', (req, res, next) => {
console.log('处理 /api 路由')
next()
})
app.get('/api', (req, res, next) => {
console.log('get /api 路由')
next()
})
// 模拟登录验证
function loginCheck(req, res, next) {
setTimeout(() => {
console.log('模拟登陆成功')
next()
})
}
app.get('/api/get-cookie', loginCheck, (req, res, next) => {
console.log('get /api/get-cookie')
res.json({
errno: 0,
data: req.cookie
})
})
app.listen(8000, () => {
console.log('server is running on port 8000')
})
三、使用 koa2
- express 中间件是异步回调,koa2 原生支持 async / await(所以node.js版本需要大于8)
- 新开发框架和系统,都开始基于 koa2,例如 egg.js
- express 虽然未过时,但是 koa2 肯定是未来趋势
- 中间件都要用async修饰,如
async (ctx, next) => {}
格式,koa2返回数据使用ctx.body=...返回
3.1 安装 koa2
npm install koa-generator -g
- 执行
koa2 koa2-test
,初始化项目目录文件 - 下载依赖和启动
npm install && npm run dev
3.2 处理 get 和 post请求
-
get请求
// blog.js const router = require('koa-router')() router.prefix('/api/blog') router.get('/list', async function (ctx, next) { const query = ctx.query ctx.body = { errno: 0, query, data: ['获取博客列表'] } }) module.exports = router
-
post请求
// user.js const router = require('koa-router')() router.prefix('/api/user') router.post('/login', async function (ctx, next) { const { username, password } = ctx.request.body ctx.body = { errno: 0, username, password } }) module.exports = router
-
在app.js中注册中间件
const Koa = require('koa') const app = new Koa() const blog = require('./routes/blog') const user = require('./routes/user') app.use(blog.routes(), blog.allowedMethods()) app.use(user.routes(), user.allowedMethods())
3.3 实现 登录
- 和 express 类似
- 基于 koa-generic-session 和 koa-redis
-
首先安装 koa-genneric-session、koa-redis、redis :
npm i koa-generic-session koa-redis redis --save
-
引入并注册
const session = require('koa-generic-session') const redisStore = require('koa-redis') // session 配置 app.keys = ['WJiol#23123_'] // 加密串 app.use(session({ // 配置 cookie cookie: { path: '/', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }, // 配置 redis store: redisStore({ all: '127.0.0.1:6379', // 先写死本地的 redis }) }))
3.4 日志
- access log 记录,使用 morgan
- 自定义日志使用 console.log 和 console.error
- 因为 morgan 仅支持express框架,需要安装 koa-morgan 插件使 morgan 兼容支持koa环境
npm i koa-morgan --save
const path = require('path')
const fs = require('fs')
const morgan = require('koa-morgan')
const ENV = process.env.NODE_ENV
// 如果不是线上环境,就是用dev格式记录日志,并以标准输出流打印到控制台上(默认第二个参数是{stream: process.stdout})
if (ENV != 'production') {
app.use(morgan('dev'));
} else {
// 如果是线上环境,就把日志写入文件
// 拿到文件路径,生成写入流对象
const logFileName = path.join(__dirname, 'logs', 'access.log')
const writeStream = fs.createWriteStream(logFileName, {
flags: 'a' // 追加写入
})
// 指定stream为上面生成的写入流
app.use(morgan('combined', {
stream: writeStream
}));
}
四、koa2 中间件原理
4.1 洋葱模型
const Koa = require('koa');
const app = new Koa();
// x-response-time
app.use(async (ctx, next) => {
console.log('第一层洋葱 - 开始', ctx.response);
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
console.log('第一层洋葱 - 结束', ctx.response);
});
// logger
app.use(async (ctx, next) => {
console.log('第二层洋葱 - 开始', ctx.response);
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
console.log('第二层洋葱 - 结束', ctx.response);
});
// response
app.use(async ctx => {
console.log('第三层洋葱 - 开始', ctx.response);
ctx.body = 'Hello World';
console.log('第三层洋葱 - 结束', ctx.response);
});
app.listen(8000);
访问打印结果如下:
第一层洋葱 - 开始 {
status: 404,
message: 'Not Found',
header: [Object: null prototype] {},
body: undefined
}
第二层洋葱 - 开始 {
status: 404,
message: 'Not Found',
header: [Object: null prototype] {},
body: undefined
}
第三层洋葱 - 开始 {
status: 404,
message: 'Not Found',
header: [Object: null prototype] {},
body: undefined
}
第三层洋葱 - 结束 {
status: 200,
message: 'OK',
header: [Object: null prototype] {
'content-type': 'text/plain; charset=utf-8',
'content-length': '11'
},
body: 'Hello World'
}
GET / - 6
第二层洋葱 - 结束 {
status: 200,
message: 'OK',
header: [Object: null prototype] {
'content-type': 'text/plain; charset=utf-8',
'content-length': '11'
},
body: 'Hello World'
}
第一层洋葱 - 结束 {
status: 200,
message: 'OK',
header: [Object: null prototype] {
'content-type': 'text/plain; charset=utf-8',
'content-length': '11',
'x-response-time': '9ms'
},
body: 'Hello World'
}
4.2 分析
- app.use 用来注册中间件,先收集起来
- 实现 next 机制,即上一个通过 next 触发
- (koa 不涉及 method 和 path 的判断)
4.3 实现
const http = require('http')
// 组合中间件 // compose用于组合所有中间件函数的调用顺序
function compose(middlewareList) {
return function (ctx) {
// 中间件调用的逻辑
function dispatch(i) {
const fn = middlewareList[i]
try {
// 之所以用Promise.resolve包裹,是为了防止有些中间件前面没有用async修饰,导致返回值不是Promise
// 因此经过Promise.resolve包裹之后,所有中间件都返回promise
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1))) // 执行中间件,并拿到下一个中间件函数作为第二个参数next传入
} catch(err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
class LikeKoa2 {
constructor() {
this.middlewareList = [] // 中间件存储的数组
}
use(fn) {
this.middlewareList.push(fn)
return this // app.use()支持链式调用,app.use(fn1).use(fn2),不过我们这里没涉及
}
// 用于把 req, res 组合成 ctx 对象
createContext(req, res) {
const ctx = {
req,
res
}
ctx.query = req.query // ... 等其他准备工作,就不一一写了
return ctx
}
// 执行中间件的启动函数
handleRequest(ctx, fn) {
return fn(ctx)
}
callback() {
// compose用于组合所有中间件函数的调用顺序,这里拿到第一个中间件函数
const fn = compose(this.middlewareList)
return (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn) // 执行第一个中间件函数
}
}
listen(...args) {
const server = http.createServer(this.callback())
server.listen(...args)
}
}
module.exports = LikeKoa2
4.4 测试
const Koa = require('./like-koa2');
const app = new Koa();
// logger
app.use(async (ctx, next) => {
await next();
const rt = ctx['X-Response-Time'];
console.log(`${ctx.req.method} ${ctx.req.url} - ${rt}`);
});
// x-response-time
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx['X-Response-Time'] = `${ms}ms`;
});
// response
app.use(async ctx => {
ctx.res.end('This is like koa2');
});
app.listen(8000);
五、线上环境
- 服务器稳定性
- 充分利用服务器硬件资源,以便提高性能
- 线上日志记录
- 线上环境使用PM2启动
5.1 PM2
5.1.1 PM2的核心价值
- 进程守护,系统崩溃自动重启
- 启动多进程,充分利用 CPU 和内存
- 自带日志记录功能
5.1.2 PM2下载安装
npm install pm2 -g
安装pm2 --version
查看版本
5.1.3 使用
package.json
中设置生产环境 pm2 启动"prd": "cross-env NODE_ENV=production pm2 start app.js"
npm run prd
启动之后,控制台打印了一张表格,并把控制台使用权交还给我们- 之后可以通过运行
pm2 list
查看列表
5.1.4 常用命令
pm2 start ...
启动 ,pm2 list
控制台查看pm2进程列表pm2 restart <AppName>/<id>
重启pm2 stop <AppName>/<id>
停止,pm2 delete <AppName>/<id>
删除pm2 info <AppName>/<id>
查看基本信息pm2 log <AppName>/<id>
查看当前日志打印pm2 monit <AppName>/<id>
检测当前进程 CPU 和 内存 的信息
5.1.5 进程守护
- node app.js 和 nodemon app.js ,进程崩溃则不能访问
- pm2 遇到进程崩溃,会自动重启
5.1.6 配置
- 新建 PM2 配置文件(包括进程数量,日志文件目录等)
- 修改 PM2 启动命令,重启
"prd": "cross-env NODE_ENV=production pm2 start pm2.conf.json"
- 访问 server,检查日志文件的内容(日志记录是否生效)
{
"apps": {
"name": "pm2-test-server",
"script": "app.js",
"watch": true,
"ignore_watch": [
"node_modules",
"logs"
],
"error_file": "logs/err.log",
"out_file": "logs/out.log",
"log_date_format": "YYYY-MM-DD HH:mm:ss"
}
}
- name指定名称
- script指定启动文件
- watch设置是否观察,如果代码更改就重启
- ignore_watch为忽略观察的文件夹
- error_file 错误日志输出文件路径
- out_file 输出日志的文件路径
- log_data_format 每一行日志都加一个格式化时间
5.2 多进程
5.2.1 为何使用多进程
- 回顾之前讲 session 时说,操作系统限制以恶进程的内存
- 内存:无法重复利用机器的全部内存
- CPU:无法利用多核 CPU 的优势
5.2.2 多进程和 redis
-
多进程之间,内存无法共享
-
多进程访问一个 redis ,实现数据共享
-
在之前 pm2 配置文件中,配置instance 属性,指定开启多少进程,如下开启四个进程:
{ "apps": { "name": "pm2-test-server", "script": "app.js", "watch": true, "ignore_watch": [ "node_modules", "logs" ], "instances": 4, "error_file": "logs/err.log", "out_file": "logs/out.log", "log_date_format": "YYYY-MM-DD HH:mm:ss" } }
-
重启之后,就显示四个进程,并且有4个日志文件
-
pm2 自带负载均衡,会根据进程空闲度,尽量让本次请求派发到空闲一点的进程