阅读 232

手写简易koa2

前言

koa是一个基于nodejs的web开发框架,与其兄弟express相比具备小而精的优势,看看官网是怎么介绍的:

通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

既然吹的这么狠,其背后又是怎么样的实现机制呢? 本文将依样画葫芦地实现一个简易版koa,造一个初级的轮子以加深理解,如果你对koa仍不熟悉,那么十分有必要先花少许时间在官网🔖

源码结构

作为一个31k star的优秀项目,其源码简洁到只有4个文件,加起来不到两千行的代码,简直是沉不下心读源码的人的福音。

image.png

其中,application是入口文件,context.js是上下文对象相关,request.js是请求对象相关,response.js是响应对象相关。

第一步,在一个空文件夹下创建上述四个文件,开始拙劣的模仿之路

基础功能

先回顾一下官网koa的用法:

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

app.use((ctx, next) => {
  ctx.body = 'Hello World';
});

app.listen(3000); 

复制代码

上述几行代码就完成了三件事情:

  1. 通过listen方法创建了我们的http服务器,端口是3000
  2. 通过use方法注册了一个koa中间件
  3. 封装ctx作为中间件参数,其上可以获取res和req

我们知道koa是基于nodejs开发的,那么在application.js中要做的就是用nodejs实现同样功能

启动服务

// application.js
class Koa {
  // 初始化
  constructor() {}
  // 注册中间件
  use() {}
  // 监听
  listen(...args) {
    http
      .createServer(function (req, res) {
        // console.log(res, req);
        res.end('hello world')
      })
      .listen(...args);
  }
}
复制代码

本质上还是用node的http模块创建了一个server,因为server的listen方法接受多参数,所以这里直接解构所有参数就可以了。新建test.js作为测试文件,运行之后访问本地3000端口页面显示正常,说明服务已经成功启动。

// test.js
let Koa = require("../lib/application");
let app = new Koa();
app.use((ctx, next) => {
  console.log('这是一个中间件');
});
app.listen(3000, () => {
  console.log("app listen in port 3000");
});
复制代码

注册中间件

上文中我们并没有实现use方法,use方法接受一个函数作为参数,可以注册多个中间件,所以应该有一个数组进行管理。函数执行需要我们接收ctx作为参数,这个ctx是koa给我们构造的上下文对象,可以通过ctx拿到req和res对象,改造代码如下

class Koa {
  constructor() {
    this.middleWares = [];
  }
  use(cb) {
    if (typeof cb !== "function")
      throw new TypeError("argument must be a function");
    this.middleWares.push(cb);
  }

  handleRequest(req, res) {
    this.middleWares.forEach((cb) => {
      cb({ req, res });
    });
  }
  listen(...args) {
    http.createServer(this.handleRequest.bind(this)).listen(...args);
  }
}
复制代码

以上代码我们将req,res封装成上下文ctx,作为中间件的执行参数,但是koa中的ctx里面绑定了很多请求和相应相关的数据和方法,例如ctx.request.req、ctx.path、ctx.query、ctx.body等等,极大的为开发提供了便利,所以下一步我们应该对ctx的属性做进一步拓展。

委托模式

Koa Context 将 node 的 request 和 response 对象封装到单个对象中,为编写 Web 应用程序和 API 提供了许多有用的方法。这对于将 ctx 添加到整个应用程序中使用的属性或方法非常有用,而更多地依赖于ctx,这可以被认为是一种反模式

为方便起见许多上下文的访问器和方法直接委托给它们的 ctx.request或 ctx.response,例如 ctx.type 和 ctx.length 委托给 response 对象,ctx.path 和 ctx.method 委托给 request。

我们回顾一下一些常见的用法,拿获取请求url举例:

  1. ctx.req.url 原生的req
  2. ctx.request.req.url 原生的req
  3. ctx.request.url 封装在request的url
  4. ctx.url 代理在ctx上的url,等同ctx.request.url

由此可见,ctx.request就是我们自定义的request,在其上挂载一些属性如query,path方便取用:

// request.js
let url = require("url");
let request = {
  get url() {
    // this => ctx.request
    return this.req.url;
  },
  get path() {
    return url.parse(this.req.url).pathname;
  },
  get query() {
    return url.parse(this.req.url).query;
  },
};
// 等同于以下写法
// Object.defineProperty(request, "url", {
//   get() {
//     return this.req.url;
//   },
// });
module.exports = request;
复制代码

同样的为了在ctx上代理这些属性,我们改造context.js

let proto = {};

function defineGetter(target, key) {
  proto.__defineGetter__(key, function () {
    return this[target][key];
  });
}

function defineSetter(target, key) {
  proto.__defineSetter__(key, function (val) {
    this[target][key] = val; 
  });
}

defineGetter("request", "url");
defineSetter("response", "body"); // 代理,实际上是给ctx.response.body赋值
module.exports = proto;

复制代码

image.png

__defineGetter__这种用法MDN不是很推荐,不知为何源码中的代理要用这个实现,更推荐用Object.defineProperty或者get语法。

  let context = require("./context");
  let request = require("./request");
  let response = require("./response");
  
class Koa {
  constructor() {
    this.middleWares = [];

    this.context = context;
    this.request = request;
    this.response = response;
  }
  // ...
  handleRequest(req, res) {
    let ctx = this.createContext(req, res);
    this.middleWares.forEach((cb) => {
      cb(ctx);
    });
  }
  // 构造上下文ctx
  createContext(req, res) {
    // 使用原型链继承 避免影响内部的属性和方法
    let ctx = Object.create(this.context);
    // 
    ctx.request = Object.create(this.request);
    ctx.response = Object.create(this.response);
    ctx.app = ctx.request.app = ctx.response.app = this // 挂载koa实例
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    ctx.request.ctx = ctx.response.ctx = ctx;
    ctx.req = req;
    ctx.res = res;

    return ctx;
  }
}

复制代码

测试文件如下,访问url属性,运行后输出是一致的,符合我们预期。当访问path属性时,因为req对象并没有该属性,所以前两者都将输出undefined。

// test.js
let Koa = require("./lib/application.js");

let app = new Koa();

app.use((ctx, next) => {
  // ctx.req.url 等价于 ctx.request.req.url(node原生的req)
  // ctx.url 等价于 ctx.request.url(koa封装的request)
  console.log(ctx.req.url);
  console.log(ctx.request.req.url);
  console.log(ctx.request.url);
  console.log(ctx.url);
  ctx.body = "hello world";
});

app.listen(3000);
复制代码

此时页面没有返回hello world,我们仍需要改造response.js,当中间件执行完毕后,如果ctx.body已经被赋值了,应该修改res状态码为200并调用res.end()在页面输出这个值,否则应该以404的状态码返回错误。

// response.js
let response = {
  set body(val) {
    if (typeof val !== "string") return;
    this.res.statusCode = 200;
    this._body = val;
  },
  get body() {
    return this._body;
  },
};

module.exports = response;
复制代码
 handleRequest(req, res) {
    res.statusCode = 404;
    let ctx = this.createContext(req, res);
    this.middleWares.forEach((cb) => {
      cb(ctx);
    });
    let body = ctx.body;
    if (typeof body == "undefined") {
      res.end("404 Not Found");
    } else {
      res.end(ctx.body);
    }
  }
复制代码

中间件机制(洋葱模型)

当一个中间件调用 next() 则该函数暂停并将控制传递给定义的下一个中间件。当在下游没有更多的中间件执行后,堆栈将展开并且每个中间件恢复执行其上游行为。

image.png

在执行时调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身,这样代码形成回形针式的级联。这也就是经典的洋葱模型。 在中间件自身,我们还可以使用async函数,这样可以让异步转同步更方便,用一段代码举个例子:

    app.use((crx, next) => {
        console.log(1)
        next()
        console.log(2)
    })
    app.use((crx, next) => {
        console.log(3)
        next()
        console.log(4)
    })
    app.use((crx, next) => {
        console.log(5)
        next()
        console.log(6)
    })
复制代码

上面这段代码最终会按照1、3、5、6、4、2的顺序输出,等同于下面:

  app.use((crx, next) => {
  console.log(1);
  (crx, next) => {
    console.log(3);
    (crx, next) => {
      console.log(5);
      next();
      console.log(6);
    };
    console.log(4);
  };
  console.log(2);
});
复制代码

洋葱模型的出现避免了深层嵌套的这种写法,相比之下更加直观并且优雅。对比前后两段代码,参数next其实等同于下一个中间件,执行next()等同于进入下一个函数的执行栈,层层递进到最后一个,当最里层函数执行完后,又会弹出执行栈。

我们需要用一个函数实现串行执行顺序和支持异步,这个compose函数是koa的核心点

compose(ctx, middwares) {
    function dispatch(index) {
      if (index == middwares.length) return Promise.resolve();
      return Promise.resolve(middwares[index](ctx, () => dispatch(index + 1)));
    }

    return dispatch(0);
}
复制代码

分析一下代码:

  1. compose函数接收中间件数组、ctx对象作为参数,利用递归函数将各中间件串联起来依次调用,所以我们回调的参数next其实就是()=>dispatch(index + 1)这个箭头函数,dispatch(0)开始进入第一个中间件,next执行的时候调用dispatch(index + 1),进入下一个中间件。

  2. 当回调是async函数时返回的是Promise对象,Promise.resolve参数是一个promise的时候会由promise的执行结果决定状态,把每个回调包装成Promise以实现异步,利用这些特性我们可以用同步的逻辑处理异步回调嵌套。

handleRequest函数改造一下,我们应该在compose执行完所有中间件后,根据ctx.body的类型做对应的返回处理:

handleRequest(req,res){
    res.statusCode = 404
    let ctx = this.createContext(req, res)
    let promise = this.compose(this.middlewares, ctx)
    promise.then(() => {
        if (typeof ctx.body == 'object') {
            res.setHeader('Content-Type', 'application/json;charset=utf8')
            res.end(JSON.stringify(ctx.body))
        } else if (ctx.body instanceof Stream) {
            ctx.body.pipe(res)
        } else if (typeof ctx.body === 'string') {
            res.setHeader('Content-Type', 'text/html;charset=utf8')
            res.end(ctx.body)
        } else {
            res.end('Not found')
        }
    })
}
复制代码

错误处理

Koa有一套错误处理机制,需要监听实例的error事件。主要是处理发生error时及时把err抛给app进行处理, 为了传递错误信息,我们让koa继承events模块的EventEmitter,这样可以在程序内部使用emit on分发错误事件

let EventEmitter = require('events').EventEmitter
class Koa extends EventEmitter {
...
}
复制代码

完善错误处理机制,产生错误后对ctx属性进行修改与相应的处理

handleRequest(req,res){
    res.statusCode = 404
    let ctx = this.createContext(req, res)
    this.on('error', this.onerror);  // 错误事件处理
    let promise = this.compose(this.middlewares, ctx)
    promise.then(() => {
       ...
    }).catch(err => {
        this.emit('error', err)  // 错误上抛
        res.statusCode = 500
        res.end('server error')
    })
}
// 程序对错误的统一处理
// 打印错误栈
// header设置成err.headers、msg设置为如err.message等等
onerror(err) {
  ...
  console.error(err);
}
复制代码

总结

至此,我们的简易轮子已经实现了koa的几大优势,可以看到koa的关键点集中在ctx对象和中间件的运用上。 通过委托模式将原生res和req的方法属性代理至ctx上,再挂载koa内置的request和reponse提供一些特有的属性和方便操作的方法,中间件则是使用compose将函数串联起来执行,并且用async await的语法糖使逻辑更加简洁清晰。

如有疑问或者笔者理解不对的地方欢迎各位批评指正,共同进步🔥🔥

参考连接:juejin.cn/post/684490…

文章分类
前端
文章标签