Express、Koa2使用和中间件原理简单实现

492 阅读9分钟

一、使用 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-sessionconnect-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 请求,根据 pathmethod 判断触发哪些
  • 实现 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.sliceclass 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 洋葱模型

Koa洋葱模型.png

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 为何使用多进程

为何使用多进程.png

  • 回顾之前讲 session 时说,操作系统限制以恶进程的内存
  • 内存:无法重复利用机器的全部内存
  • CPU:无法利用多核 CPU 的优势

5.2.2 多进程和 redis

多进程和redis.png

  • 多进程之间,内存无法共享

  • 多进程访问一个 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 自带负载均衡,会根据进程空闲度,尽量让本次请求派发到空闲一点的进程