KOA2 中间件机制实现、路由处理、错误处理、模版引擎、源码

Posted by CodingWithAlice on February 24, 2025

KOA2 中间件机制实现、路由处理、错误处理、模版引擎、源码

总结:基于 NodeJS 的 HTTP 中间件框架

  • 常见错误

    • 中间件-洋葱模型 中传入空数组,要返回兑现的promise

        if(!fn) { return Promise.resolve() } // 不要忘记处理这个边界情况
      
    • 中间件-洋葱模型 中执行中间件要包裹promise

      ```js rerurn Promise.resolve( fn(ctx, nextFunc) ) // 返回的值使用 Promise.resolve 包裹

    • 路由处理时 router 要调用 methods

        router.get('/cat', async (ctx) => {}) // 记得要调用 methods 来声明
      
    • 全局错误中间件,KOA2 使用 ctx 包装了req和res,错误的抛出是在 ctx.status 和 ctx.body 上

        app.use(async (ctx, next) => {
            try{ await next() } catch(e) {
                ctx.status = 500;
                ctx.body = { error: '错误' }
                // 经常错写成为 ctx.res.send() - 写法没错,但是不推荐
            }
        })
      
    • 源码实现中 callback需要返回一个函数

        // 1、callback 需要返回一个函数:先获取 ctx 上下文,触发执行中间件 
        return (req, res) => {
            const ctx = this.createContext(req, res);
            // 2、中间件的执行需要传入 ctx
        	return fnMiddleware(ctx)
            	// 3、respond需要传递的数据在 ctx 上,不需要明文传递 
                .then(() => respond(ctx))
        	    // 4、要记得 catch 错误,错误在 ctx 上处理
                .catch((err) => ctx.onerror(err)); 
            }
        // 5、createContext(req, res) 挂载的是 ctx ctx.request 上
        ctx.req = ctx.request.req = req; 
        ctx.res = ctx.response.res = res;
        // 6、respond(ctx) 响应数据在 ctx.body 上 
        ctx.res.statusCode = ctx.status; 
        // ctx.res.statusCode 是Node原生的http.ServerResponse对象,直接操作原生响应对象的状态码属性
        // ctx.status 是 KOA 封装的上下文,便捷属性,会映射到 ctx.res.statusCode
        ctx.res.end(ctx.body);
        // ctx.res.end 是 Node.js 原生的 http.ServerResponse 对象,用于结束响应并发送数据
        // ctx.res.send 是错误的写法,send() 方法通常是 Express 框架中响应对象的方法
        // ctx.body 是 KOA 用于设置响应体的方法
      
  • 核心概念

    • 中间件机制:洋葱模型,请求进入应用后,依次进入每个中间件的入栈处理,到达最内层中间件后,再依次经过出栈处理 - 组件组合 compose,借用 async/await 避免回调地狱

      ```js async function fn1(ctx, next) { console.log(‘1’);await next(); console.log(‘5’)} async function fn2(ctx, next) { console.log(‘2’);await next(); console.log(‘4’)} async function fn3(ctx, next) { console.log(‘3’) } function compose(middlewares) { return (ctx, next) => { let index = -1; return dispatch(0);

            function dispatch(i) {
                if (i <= index) { 
                    return Promise.reject(new Error('next multiple times')) 
                }
                index = i;
                let fn = middlewares[i];
                if (i === middlewares.length) {
                    fn = next
                }
                if (!fn) { return Promise.resolve() }
                try {
                    // 定义 next 函数
                    const nextFunc = () => dispatch(i + 1);
                    // 执行中间件函数 nextFunc 还可以写成 dispatch.bind(null, i+1)
                    return Promise.resolve(fn(ctx, nextFunc))
                } catch (e) { 
                    return Promise.reject(e) 
                }
            }
        };   }   const ctx = {};   compose([fn1, fn2, fn3])(ctx, () => Promise.resolve())
        .then(() => console.log(11111))
      
    • 中间件编写:async (ctx, next) =>{ await next() };

    • context 上下文对象:封装 req 请求对象、res 响应对象 ctx.request/ctx.response/ctx.body

  • 路由处理

    • KOA2 本身没有内置路由功能,使用第三方路由模块 koa-router

      ```js router.get(‘/cat’, async (ctx) => { }) // 路由定义

    • 路由参数处理:路由参数 ctx.params,链接参数 ctx.query

  • 错误处理

    • 全局错误处理中间件

        app.use(async (ctx, next) => {
            try {
                await next();
            } catch (err) {
                ctx.status = err.statusCode || 500;
                ctx.body = { error: err.message };
            }
        });
      
  • 模版引擎 - 在服务端生成动态 HTML

    • ejs模版引擎 <% %>:适合复杂模版,性能弱 - 需要动态解析、执行 js
    • art-template模版引擎:``,性能好 - 预编译技术,直接执行 js
  • 其他一些配置

    • koa-bodyparser:获取 post 提交的数据 app.use( bodyParser() )
    • koa-static:静态资源托管 app.use( static(__dirname +'public') )
    • ctx.cookies:设置 cookie ctx.cookies.set(key, value, options)/ctx.cookies.get(key)
    • koa-session:在服务端上存储客户端状态,app.use(session(CONFIG, app)) + ctx.session.key
  • 源码实现两件事:① 封装 http 包,通过 get set 简化 API 方法 ② 引入中间件机制/洋葱模型

      const http = require('http');
      class KOA2 {
          constructor() { this.middlewares = [] } // 初始化中间件数组
          listen(...args) { // 创建一个 http 服务器
              const server = http.createServer(this.callback()); // 传入回调函数
              return server.listen(...args); // 启动服务
          }
          use(fn) { this.middlewares.push(fn); return this; } // 用于注册中间件
          callback() { // 返回一个处理请求的回调函数
              const fn = compose(this.middlewares); // 组合中间件 - 组合成一个函数
              const handleRequest = (req, res) => {
                  const ctx = this.createContext(req, res); // 创建上下文对象
                  return this.handleRequest(ctx, fn); // 处理请求
              }
              return handleRequest
          }
          createContext(req, res) { // 创建上下文对象的方法, this 上的值是导入其他三个文件所得
              const ctx = Object.create(this.context);
              ctx.request = Object.create(this.request);
              ctx.response = Object.create(this.response);
              ctx.req = ctx.request.req = req; // 将 req res 挂载到 ctx 上
              ctx.res = ctx.response.res = res;
              return ctx;
          }
          handleRequest(ctx, fnMiddleware) { // 调用组合后的中间件函数
              return fnMiddleware(ctx)
                  .then(() => respond(ctx)) // 中间件处理完后,调用 respond 响应
                  .catch((err) => ctx.onerror(err));
          }
      }
      function respond(ctx) {
          // 发送响应 -  根据ctx.body的类型和ctx.status的值自动进行一些响应头的设置和响应体的处理,从而完成响应的发送 - 通过 Node 的http模块将响应发送给客户端
          ctx.res.statusCode = ctx.status;
          ctx.res.end(ctx.body);
      }
        
      // 将中间件函数组合成一个函数,实现中间件的洋葱模型
      function compose(middlewares) {}
      module.exports = KOA2;
    

为什么要看KOA2

// 项目文件中简单声明即可定义页面标题
const subscribeout = async function (ctx, next) {
    await ctx.render('xretail/subscribe_out', {
        title: '订阅',
        path: 'xretail/subscribe_out'
    })
    return next();
}

为什么要使用KOA2?

原生 http 的不足

  • ①令人困惑的 request 和response,例如 res.end、res.writehead等
  • ②对复杂业务逻辑:流程描述、切面描述AOP(语言级、框架级)

KOA2 是一款基于 NODEJS 的 HTTP 中间件框架

  • 最大特点:避免异步嵌套
  • 要求版本 node > 7.6(完全支持 async/await)
  • 源码 4个文件 application.js、context.js、request.js、response.js

1、初步启动服务

const koa = require('koa');
const app = new koa();

app.use(middleware);
app.use(async (ctx) => { // 匹配任意路由
    ctx.body = 'hello koa2';
}); // 应用级中间件

app.listen(3000);

app.use 功能:

  • 传两个参数 app.use(‘url’, handler) - 匹配第一个参数指定的路由 【路由级中间件】
    • 使用场景:router.get(‘/’, myMiddleware, async ((ctx) => { ctx.body=‘1’ }))
  • 传一个参数 app.use(handler) - 匹配任意路由,包括未声明的路由 【应用级中间件/错误处理中间件】

ctx 的主要结构:

ctx = {
    request: {
        method: 'GET',
        url: '/',
        header: {
            host: 'localhost:3002',
            connection: 'keep-alive','user-agent': 'Mozilla/5.0...',
            accept: 'text/html...'
        }
    },
    response: {
        status: 404,
        message: 'Not Found',
        header: [Object: null prototype] {}
	},
    app: { subdomainOffset: 2, proxy: false, env: 'development' },
	originalUrl: '/',
	req: '<original node req>',
	res: '<original node res>',
	socket: '<original node socket>'
}

2、Koa-router 路由

路由模块:koa-router

// 路由实现方式 - 引入并实例化 router.METHOD(PATH, HANDLER)
const Router = require('koa-router');
const router = new Router();   //【关键】
router.get('/star', async (ctx) => {
    ctx.body = 'hello 千玺'; // 相当于 res.writeHead()
})

路由相关的参数获取

localhost:3000/?id=123&name=Jackson // id=123&name=Jackson 链接参数
localhost:3000/star/212 			// 212 动态路由

链接参数 ctx.query

ctx.query 		// 返回的是格式化的参数对象 { id: '123', name: 'Jackson' }
ctx.querystring // 返回的是请求字符串 id=123&name=Jackson
// 也可以从 ctx.url / ctx.request 中获取参数

动态路由 ctx.params

router.get('/star/:id', async (ctx) => {
    console.log(ctx.params); //{ id: '212' }
    ctx.body = 'hello 千玺';
})

3、中间件

时机:早于 路由处理函数(router.get) 执行

中间件机制:即 函数式组合 的概念,将一组需要顺序执行的函数,复合为一个函数,外层函数的参数实际是内层函数的返回值,即洋葱圈模型 + async/await (避免回调地狱)

const mid = async (ctx, next) => { // ctx 上下文对象 next 下一个中间件
    // 来到中间件,洋葱圈左边
    await next(); // 进入其他中间件
    // 再次来到中间件,洋葱圈右边
}
// 最内层的中间件处理完请求后,会将控制权返回给上一个中间件

image-20250224200006400

1、组合函数(同步实现)
// 组合函数(同步实现)
const add = (x,y) => x + y;
const square = z => z * z; // 先加后平方
// 方案一: const fn = (x, y) => square(add(x, y));
// 方案二: const compose = (fn1, fn2) => (...args) => fn2(fn1(...args));
// 方案三:
const compose = (...[first, ...other]) => (...args) => {
    let res = first(...args);
    other.forEach(fn => {
        res = fn(res);
    })
    return res;
}
const fn = compose(add, square);
2、组合函数(异步实现)

compose 源码:

  • 核心:① 将 context 一路传下去 ② 将 middleware 中的下一个中间件 fn 作为未来 next 的返回值
  • 比较关键的就是 dispatch 函数 - 遍历整个 middleware,然后将ctx和dispatch(i + 1)传给下一个方法
async function fn1(next){ console.log('fn1'); next(); console.log('end fn1');}
async function fn2(next){ console.log('fn2'); next(); console.log('end fn2');}
async function fn3(next){ console.log('fn3');}
function compose(middlewares) {
    return function () {
        return dispatch(0);
        // 执⾏第0个 - 递归
        function dispatch(i) {
            let fn = middlewares[i];
            if (!fn) {
                return Promise.resolve();
            }
            return Promise.resolve(
                fn(function next() {
                    // promise完成后,再执⾏下⼀个
                    return dispatch(i + 1);
                })
            );
        }
    };
}
const middlewares = [fn1, fn2, fn3];
const finalFn = compose(middlewares);
finalFn(); // fn1 - fn2 - fn3 - end fn1 - end fn2

4、模版引擎 - 在服务器端生成动态 HTML 页面

① ejs模版引擎

  • 语法:使用 <% %>包裹 js 代码, <%= %> 输出变量的值

是一种JS模版引擎,主要使用 koa-views、ejs 实现

const views = require('koa-views');
// step1: 使用 koa-views 配置中间件
// 第一种 - 对应的路径下,文件后缀为ejs:views/index.ejs 【推荐】
app.use(views('views', { map: { html: 'ejs' }}));
// 第二种 - 对应的路径下,文件后缀为html:views/index.html
app.use(views('views', { extension: 'ejs' }))

// step2: 在 KOA2 路由中使用 ejs
router.get('/add',async (ctx)=>{ 
    let title = 'hello';
    await ctx.render('index',{ 
        title // 可以将数据渲染到对应静态页面 views/index.ejs 上的 title 变量
    }) 
})
<h1><%=title%></h1> <!-- 补充 views/index.ejs 中的代码 -->

补充一些 ejs 的设定

  • ejs中的公共数据,在每个路由 render 里面都渲染一个公共数据:ctx.state

      app.use(async (ctx, next) => {
          ctx.state = { name: '千纸鹤'};
          await next();
      });
    
  • 其他一些功能

    ```html

      <%for(var i=0;i<list.length;i++) { %>
    • <%=list[i] %>
    • <%}%>

    <%-html%>

    <% if(num>20){ %> <div>大于20</div> <%} else{ %> <div>小于20</div> <%} %>

② art-template模版引擎

  • 语法:使用 包裹变量和表达式,使用 逻辑控制
  • 和 ejs 模版引擎的比较
    • 同:都是在服务器端生成动态 HTML 页面的模板引擎
    • 异:ejs 适合模版需要编写复杂逻辑;art-template 适合简洁语法和高性能
      • ①语法
      • ②性能:ejs 比较弱,因为要动态解析 js 和执行;art-template 采用了 预编译技术,将模版编译成js函数,执行时直接执行函数,避免了重复解析模版
      • ③功能特性:ejs 侧重于提供简单的模版语法和基本逻辑,优势是和 JS无缝集成;art-template 除了基本模版,还有一些高级特性,例如模版继承、子模板引入、过滤器
      • ④社区:ejs Express

主要使用 art-template、koa-art-template

const render = require('koa-art-template');
render(app, {
    root: path.join(__dirname, 'views'),    		// 视图的位置
    extname: '.html',                       		// 后缀名
    debug: process.env.NODE_ENV !== 'production' 	// 是否开启调试
});

5、koa-bodyparser【post类型】

作用:获取 post 提交的数据

依赖:koa-bodyparser

var bodyParser = require('koa-bodyparser');
app.use(bodyParser()); // 中间件配置

router.post('/toAdd', (ctx) => { // 接收 post 中提交的数据
    ctx.body = ctx.request.body;
});

6、koa-static 资源

功能:静态资源托管 - 静态资源路径直接加载,不匹配路由

解决的问题:直接引入静态资源,根据路径加载资源过程中,由于没有对应路由,会 404 找不到对应资源

依赖:koa-static

const static = require('koa-static');
app.use(static(__dirname + 'public')));
app.use(static('static'))) // 可配置多个

过程解析:访问 http://localhost:3000/css/basis.css ,使用 上述配置后

  • 先去 public 下查找
    • 如果能找到则返回对应文件
    • 如果找不到,就 next() 继续匹配路由(底层封装)

7、ctx.cookies

定义:存储于计算机浏览器中的变量,可以在同一浏览器中访问 同一域名 时共享数据

ctx.cookies.set(name, value, [options])
ctx.cookies.get('name');

options 参数

image-20250224205956578

8、session

定义:是另一种记录客户状态的机制,保存在服务器上

依赖:koa-session

app.keys = ['some secret hurr']; // 默认
const CONFIG = { 
  	key: 'koa:sess', //cookie key (默认)
    maxAge: 86400000, 	// cookie 的过期时间 ms,默认 1天
  	overwrite: true, 	//是否可以 overwrite (默认 true) 
  	httpOnly: true, 	//cookie 是否只有服务器端可以访问 (默认 true) 
  	signed: true, 		//签名默认 true 
  	rolling: true, 		//在每次请求时强行设置 cookie,重置过期时间,默认 false
  	renew: false, 		//renew session when session is nearly expired 默认 false
}
app.use(session(CONFIG, app));

设置 ctx.session.username = “张三” 后获取 ctx.session.username 流程

F5D9E8E0-1B3B-48D1-A8B0-4E3D99D9607F

首次访问:

  • ① 先在服务端创建对应 session
  • ② 并将 session 中的 key 值返回给 客户端,客户端存储在 cookie 中

再次访问:

  • ① 客户端 从 cookie 中获取对应的 key 值 发送给服务端
  • ② 服务端根据对应的 key 值查询 session 中的 value 值
  • ③ 服务端将 value 值发送给客户端

9、koa应用生成器

功能:通过应用 koa 脚手架生成工具 可以快速创建一个基于 koa2 的应用的骨架

1全局安装 `npm install koa-generator -g`
2创建项目 `koa your_project_name`
3进入到项目中安装依赖 `npm install`
4启动项目 `npm start`

10、KOA2 源码解析

源码一共做了两件事

  • 1、通过get、set方法简化了API的实现

  • 2、引入中间件机制 - 洋葱圈模型

① listen use 方法的封装 - http 包

如下,是用 http 包实现的请求,KOA2 基于该模型,将业务逻辑 可变部分 作为参数传入,框架则封装起来

  • 目标是用更简单化、流程化、模块化的方法实现回调部分
// 框架 - 用 http 包实现的请求
const http = require('http');
const server = http.createServer((req, res) => {
    // 业务逻辑
    res.writeHead(200);
    res.end('hi');
})
server.listen(3000, () => { console.log('监听端口3000') })

KOA2 源码中,将 http 包封装成 listen 和 use:

const http = require('http');
class KKB { // 模块化
    listen(...arg) {
        const server = http.createServer((req, res) => { this.callback(req, res) })
        server.listen(...arg);
    }
    use(callback) { this.callback = callback };
}
module.exports = KKB;
// 调用
const KKB = require('./kkb');
const app = new KKB();
app.use((req, res) => {
    res.writeHead(200);
    res.end('hi');
})
server.listen(3000, () => { console.log('监听端口3000') })

② context 的封装

为了简化 API ,KOA2引入了上下文的概念,将原始请求对象 req 和响应对象 res 封装并挂载 到 context,并设置了 getter 和 setter

原理图如下:

image-20250224211848785

③ 引入中间件机制

引入了组合函数的概念, 代码中引入了这个包:koa-compose

简单实现一下包里面的源码逻辑就是 compose 方法

源码的四个文件,request.js response.js都是 get 和set 方法对 http 原生的方法进行了封装、简化

request.js

module.exports = {
    get url() { return this.req.url },
    get method() { return this.req.method.toLowerCase() }
}

response.js

module.exports = {
    get body() { return this._body },
    set body(val) { this._body = val }
}

context.js

module.exports = {
    get url() { return this.request.url },
    get body() { return this.response.body },
    set body(val) { this.response.body = val },
    get method() { return this.request.method }
}

application.js

// 导⼊这三个类
const context = require("./context");
const request = require("./request");
const response = require("./response");
class Application extends Emitter {
    constructor() {
        this.middlewares = [];
    }
    //  构建上下⽂, 把res和req都挂载到ctx之上,并且在ctx.req和ctx.request.req同时保存
    createContext(req, res) {
        const ctx = Object.create(context);
        ctx.request = Object.create(request);
        ctx.response = Object.create(response);
        ctx.req = ctx.request.req = req;
        ctx.res = ctx.response.res = res;
        return ctx;
    },
    listen(...args) {
        const server = http.createServer((req, res) => {
            let ctx = this.createContext(req, res); // 创建上下⽂
            // 组合函数,将所有中间件调用的函数合成
            const fn = this.compose(this.middlewares);
            // 数据响应
            res.end(ctx.body);
        });
    },
    use(middleware) { // 将中间件加到数组⾥
        this.middlewares.push(middleware);
    },
    compose(middlewares) {
        // 看上文
    }
}

调用的时候只需要写

app.use((ctx) => {
    ctx.body = '测试';
});
app.use(async (ctx, next) => {
    ctx.body = "1";
    await next();
    ctx.body += "5";
});
app.use(async (ctx, next) => {
    ctx.body += "2";
    await next();
    ctx.body += "4";
});
app.use(async (ctx, next) => {
    ctx.body += "3";
});
1,2,3,4,5