自定义一个Koa

1,083 阅读9分钟

前言

Koa和Express 都是基于 Node.js 平台的下一代 web 开发框架,由 Express 幕后的原班人马打造,

express和Koa的两者区别简述:

  • Express 源码是 es5 写的,koa 源码基于 es6 写的
  • Express 比较全 内置了很多功能;koa 内部核心非常小巧(我们可以通过拓展的插件进行扩展)
  • Express 和 Koa 都是可以自己去使用实现 mvc 功能的,没有约束
  • Express 处理异步的方式是回调函数,函数处理异步的方式是 async+await

Koa的启动

初始化项目

npm init -y

Koa 的安装

npm install koa

Koa 的use 方法

  • use 是一个中间件,每次执行(请求)会产生一个上下文 ctx
  • 可以通过ctx.body 来实现node 中res.en()的效果
  • 上下文 ctx包含了主要部分
    1. app 当前应用实例,
    2. req,res对应 原生 node 中的 req,res
    3. koa 自己封装的 request 和 response 是对原生的 req 和 res 进行的一层抽象,和扩展,功能更多
const Koa = require("koa");
const app = new Koa();
app.use((ctx) => 
   ctx.body = "hello1111"; //ctx.body 相当于node 里sever中 res.en()的功能 给浏览器写入数据
   console.log(ctx);//打印ctx,查看属性
});
app.listen(3000, function () {
  console.log("server start 3000");
}); //监听端口号 ,同我们的的node中http的listen方法

ctx上下的打印结果:
{
request: {
method: 'GET',
url: '/',
header: {
host: 'localhost:3000',
connection: 'keep-alive',
'cache-control': 'max-age=0',
'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="90", "Google Chrome";v="90"',
'sec-ch-ua-mobile': '?0',
'upgrade-insecure-requests': '1',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,_/_;q=0.8,application/signed-exchange;v=b3;q=0.9',
'sec-fetch-site': 'none',
'sec-fetch-mode': 'navigate',
'sec-fetch-user': '?1',
'sec-fetch-dest': 'document',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'zh-CN,zh;q=0.9',
cookie: '\_uab_collina=161916550781026855009784; zoe=27'
}
},
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>'
}

话不多说 从上下文入手开始Koa的实现

Koa上下文的实现

目录创建

参考下 koa 的源码 lib 下的文件结构 依次新建对应文 image.png

package.json

设置入口文件

{
    "main":"lib/application.js"
}

context.js,request.js,response.js 依次创建

const response = {};
module.exports = response;

application.js

const http = require("http");
const context = require("./context");
const request = require("./request");
const response = require("./response");
class Application {
  constructor() {
  }
}
module.exports = Application;

koa启动server时listen实现

思路:

  1. 接收两个参数 端口号和callback
  2. 内部封装node的 listen 并将实例上listen参数传入

实现:

//handleRequest待实现
  listen(...args) {
    const server = http.createServer(this.handleRequest);
    server.listen(...args);
  }
  • 调用:同Koa框架相同
app.listen(3000, function () {
  console.log("server start 3000");
});

use实现

use的作用

每次执行(请求)会产生一个上下文 ctx

思路:

  • use用于保存用户写入的函数
  • 服务启动时候 触发用户保存的use,创建上下文ctx
  • 每一个实例每一个请求对应的use里的ctx都要相互独立
  • ctx 里要包含 node 原生的req和res 还有ctx自己扩展的request和response

实现:

  • application.js
    constructor() {
        // 初始化 创建实每一个实例自己的上下文,原理是用了原型继承
        this.context = Object.create(context);
        //同理 初始化request和response
        this.request = Object.create(request);
        this.response = Object.create(response);
     }
    use(fn) {
        // 保存用户写的函数
        this.fn = fn;
      }
   // 上下文的实现
    createContext(req, res) {
    let ctx = Object.create(this.context);
    // 注意这里 为什么不用 ctx=context  怕上下文相互影响
    // Object.create 做中间层包装(隔离) ,可以保证每一个请求产生的上下文是独立空间是自己的上下文,但又可以获取同一个公共上下文
    // 同理 创建每次请求自己的request 和response
    let request = Object.create(this.request);
    let response = Object.create(this.response);
    // ctx自己扩展的请求和响应
    ctx.request = request;
    ctx.response = response;

    // 原生请求
    ctx.req = ctx.request.req = req; //默认上下文包含原生的req,且自己的requst需要可以取到原生的req
    ctx.res = res; //默认上下文包含原生的res
    return ctx;
  }
   handleRequest = (req, res) => {
    //每次请求都会执行次方法,然后创建自己的上下文
    let ctx = this.createContext(req, res);
    // 服务启动时候 触发用户保存的use
    this.fn(ctx); 
  };

  • request.js 用于实现上下文ctx的request
const url = require("url");

const request = {
  get url() {
  //application里 request 是被上下文ctx调用的 所以此处this指向上下文 通过上下文去取到node 原生的re.url就ok
    return this.req.url; 
  },
  //同理实现 通过request 获取请求path和请求query
  get path() {
    return url.parse(this.req.url).pathname;
  },
  get query() {
    return url.parse(this.req.url).query;
  },
};
module.exports = request;

测试

调用

  • 1.server.js
const Koa = require("./koa");

// 使用koa就是创造一个应用实例
const app = new Koa();
app.use((ctx) => {
console.log(ctx.req.url)
console.log(ctx.request.req.url)
console.log(ctx.request.url)//app里request 被Object.create 包了两次 寻找url 相当于:ctx.request.__proto__.__proto__
console.log(ctx.url)
console.log('--------本次console end')
});
app.listen(3000, function () {
  console.log("server start 3000");
}); //监听端口号 ,同我们的的node中http的listen方法

测试结果

image.png

image.png

console.log(ctx.url) 没有打印成功

ctx.url实现
  • context.js
  1. 同request.js的实现思想相同 我们这里的this指向的是最终调用的那个对象 也就是我们的上下文ctx
  2. url 就去取我们的this上的request的url 属性
const context = {
  get url() {
    return this.request["url"];
  },
};
module.exports = context;
  • console.log(ctx.url)的打印结果

image.png

  • 但是目前context的实现 如果 要实现其他path或者query的获取 就要复制粘帖,这。。不能忍。于是参考了下 koa的源码里 context 获取的实现

image.png 1.核心defineGetter 不过 mdn已经提示被废弃,将来可能停止支持。但 主要 研究 koa 的实现 此处不对该接口深入探讨

  • 参考源码后做调整
const context = {

}
function defienGetter(target,key) {
  context.__defineGetter__(key, function () {
    // tips :这里的function 不能是 箭头函数 ,否则 this 就变成指向 context了,就变成了一个空对象
    // this 应该指向调用它的对象,也就是实例中每次请求方法中自己对应的那个ctx
    return this[target][key];
  });
}
defienGetter("request", "url");
defienGetter("request", "path");

module.exports = context;

  • 测试url,path的console结果

image.png

Koa响应体的实现

原生koa里 ctx.body的使用

  • server.js
app.use((ctx) => {
  ctx.body = "hello ZOE";
  //ctx.response.body ="hello ZOE"
});

发现 ctx.body和ctx.response.body都能在页面中写入

ctx.body和ctx.response.body

两者是什么关系??

  • 测试:use里换成以下代码 &查看控制台
  ctx.response.body = " ctx.response.body 实现的 hello Zoe";
  console.log("ctx.body :", ctx.body, ctx.response.body === ctx.body);
  • 测试结果: ctx.body和ctx.response.body指向同一个东西 image.png

  • 小结:ctx.body值的set应该就是代理了ctx.response.body的set

koa里 ctx.body的实现

response的处理

ctx.response指向原生的res

回到自定义的application.js文件 createContext函数里新增response的指向原生的res

  // 响应体的2.
  ctx.response = response;
  //默认的上下文包含原生的res,那么可以在我们的response对象中,通过this.res拿到原生的res,和
  ctx.res = ctx.response.res = res;
   

ctx.response.body的set和get

response.js: set 和get中的value 还要指向同一个值 ,response对象另外设置一个_body变量 ,set 和get都对_body做操作

const response = {
      _body: undefined,
      get body() {
        return this._body;
      },
      set body(value) {
        this._body = value;
      },
    };

    module.exports = response;

server.js:

app.use((ctx) => {
 ctx.response.body = " Zoe 自定义的koa 的响应体测试";
 console.log(ctx.response.body);
});

测试结果:

image.png 但是 ctx.body 不等同res.use,所以页面上并没有展示我们body 写入的内容

ctx.response.body实现写入页面

application.js

  • handleRequest 方法里新增 res.end(ctx.response.body);

server.js:

app.use((ctx) => {
  ctx.response.body = " hello Zoe";
});
  • 页面输出结果

image.png

ctx.body的set 和get实现

  • get
    1. 回顾 request的实现 ctx.req是做了ctx.request.req的代理;同理 ctx.body 也同样可以代理ctx.response.body

    2. 加一个defienGetter("response", "body");取 ctx.body 的时候 就去取当前this指向的那个ctx 下的response.body

  • set
    1. 要实现的是 在ctx.body 赋值的时候 也做一层代理proxy 实际是做了ctx.response.body的set

context.js 原来的基础上新增代码

const context = {
function defienGetter(target, key) {
  context.__defineGetter__(key, function () {
    return this[target][key];
  });
}
function defineSetter(target, key) {//proxy ,defineProperty
  context.__defineSetter__(key, function (value) {
    return (this[target][key] = value);
  });
}

defienGetter("response", "body");
defineSetter("response", "body");
module.exports = context;

  • 测试 sever.js
  app.use((ctx) => {
  ctx.body = " hello Zoe";
  console.log(`${ctx.body} 来自ctx.body的测试`); 
});
  • 测试结果

image.png

ctx.body边界处理

  • ctx.body为空的时候的处理 404处理;否则 再调用 res.end 写入body

404

  • application.js
    handleRequest = (req, res) => {
      let ctx = this.createContext(req, res);
      res.statusCode = 404;//这里做默认处理404 有值则调用set body ,set body 同时改变statusCode 为200
      this.fn(ctx);
      if (ctx.body) {
        res.end(ctx.body);
      } else {
        res.end("Not Found");
      }
    };
  • response.js
set body(value) {
    //如果用户调用了 ctx.body='cxx'  就设置响应状态码200
    this.res.statusCode = 200; 
    this._body = value;
  },
  • app.use 不做ctx.body的赋值测试结果

404的处理.png

ctx.body支持多类型数据

  • 新增ctx.body type判断
  • 字符串 或者buffer 沿用原有的逻辑
  • 数字转成字符串
  • 对象的处理 JSON.stringify()

application.js

 handleRequest = (req, res) => {
    let ctx = this.createContext(req, res);
    res.statusCode = 404;
    this.fn(ctx);
    let _body = ctx.body;
    if (_body) {
      if (isString(_body) || Buffer.isBuffer(_body)) {
        return res.end(_body);
      } else if (isNumber(_body)) {
        return res.end(_body + "");
      } else if (isObject(_body)) {
        return res.end(JSON.stringify(_body));
      }
    } else {
      res.end("Not Found");
    }

数据类型 type判断 (用的是 自己的写的一个工具函数 实现 可以参考) type

测试 : ctx.response.body = { name: "ZOE" };

结果: 我想偷个懒 没有图 (有兴趣。可以跟着我的思路写一遍)

多个 use 的支持

目前为止:use 函数 只支持调用一次 现在要变成数组来保存用户写的函数

middleWares缓存数组

初始化

 constructor() {
    this.middleWares = [];
  }

use改写

  use(middleWare) {
  // this.fn = fn;
  this.middleWares.push(middleWare);
  }

handleRequest调整

需要对 middleWares 进行依次取值进行 body 写入而不是直接写入 body

  • this.compose 处理函数 将 middleWares 数组里的函数一个个执行
  • 回调处理每一个函数自己的 ctx.body写入

实现思路

  1. this.compose 返回一个大的 promise
  2. 这个 promise 里 将每一个 use 的处理函数 包装成小的内部 promise
  3. 内部小的 promise的 then 里处理自己的 body 写入
  4. 内部promise执行顺序上一个 promise 会等待返回的 promise 执行完(递归)

实现代码

handleRequest
 this.compose(ctx)
      .then(() => {
      //包裹原有_body 写入的逻辑代码
      }
compose实现
  1. 边界处理: use 一个都没有,this.middleWares.length===0 直接返回返回一个 Promise.resolve

  2. Promise.resolve (处理当前 use 保存的函数,cb 回调 下一个 use 保存的函数)

    预期效果 this.middleWares[0(ctx,this.middleWares[1]) 依次关联下一个use,执行完第一个执行第二个cb

  3. 相同use 里连续调用多个 next 的error处理

    • i 缓存每一个执行函数的 index,当下一个 dispacth 执行时 如何 index===i 说明是相同 use 里的两个 next ,reject 抛异常
compose(ctx) {
  // 组合是要将数组里的函数一个个执行
  let index = 0;
  let i = -1;
  const dispatch = () => {
    if (index === i) return Promise.reject("multiple call next()");
    // 如果use 中间件 一个都没有的边界处理
    if (this.middleWares.length === index) return Promise.resolve();
    i = index;
    return Promise.resolve(this.middleWares[index++](ctx, dispatch));
  };
  return dispatch(0);
}

测试结果:写了个寂寞,并不抛异常,因为上述实现方式里i 放于dispatch外层 ,执行到第二个next的时候 i已经是 之前next 修改了的结果,index === i条件永远满足不了

修改方向: 重新看原生 Koa 的实现 位置 /node_modules/koa-compose/index.js

  • i 唯一来源于dispatch入参

调整结果:

compose(ctx) {
    // 组合是要将数组里的函数一个个执行
    let index = -1
    const dispatch = (i) => {
      //  如果use 中间件 一个都没有的边界处理 或者一个use里多个next
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      if (this.middleWares.length === i) return Promise.resolve();
      //                                             () => dispatch(i + 1))就是use里的next
      //  一个promise 会等待返回的promise执行完
      return Promise.resolve(this.middleWares[i](ctx, () => dispatch(i + 1)));
    };
    return dispatch(0);
  }

测试:

app.use(async (ctx, next) => {
  console.log(1);
  await next();
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("zoe");
      resolve();
    }, 1000);
  });
  next();
  console.log(4);
});
app.use((ctx, next) => {
  console.log(5);
  next();
  console.log(6);
});
app.listen(3000, () => {
  console.log("server start 3000");
});

测试结果: image.png 额外的解释:为什么 dispatch 要return Promise.resolve?

因为 use里 如果写入的next没有awiat this.compose().then无法正常进行

err 的优雅处理

Koa的错误处理

支持 自定义错误信息处理

 app.on('error',function(err){
      console.log({err})
  })

实现

  • Application 继承EventEmitter (要记得 super)
  • this.compose 执行的时候 catch 捕获执行错误, 然后 this.emit("error", err);用户订阅的 error 事件
const EventEmitter = require("events");
class Application extends EventEmitter {
 constructor() {
    super();//相当于 EventEmitter.call(this) 
  }
  
  handleRequest(){
   this.compose(ctx)
      .then(() => {
       //ctx.body的设置代码,此处不重复阐述
      })
      .catch((err) => {
        this.emit("error", err);
      });
  }
}

测试结果:

image.png

小结

先写到这吧 待续....

最后如果觉得本文有帮助 记得点赞三连哦 十分感谢