源码实现之koa

我正在参与掘金创作者训练营第 4 期(带链接:juejin.cn/post/706419… ), 点击了解活动详情,一起学习吧!

一、前言

本篇文章可能需要你具备一些源码基础,在下面我会推荐一些资料。

推荐学习资料

标题 链接
Koa 官网 koajs.com/
Koa 源码 github.com/koajs/koa
Koa2 开发快速入门 juejin.cn/post/704418…
Koa2 原理解析 juejin.cn/post/706907…

Koa 是什么 ?有哪些部分组成 ?

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。

当我们从github Koa 仓库克隆下 Koa 框架源码, 通过 lib 文件可以看到只有application.jscontext.jsrequest.jsresponse.js 四部分, Koa 正是由这四部分组成。

它们有什么联系 ?

  1. 通过 Koa 源码 的 package.json 文件,得到入口文件 lib/application.js, 可知 application.js 是 Koa 入口,也是应用库, 将 context、request、response 挂载到 Application 实例上
  2. context.js 是上下文库
  3. request.js 是 request 库
  4. response.js 是 response 库

实现框架

当我们在读完源码后,就会很好的体会到框架编码风格,这时我们可能会想 Koa 框架精简,设计巧妙,能不能自己实现一个简易版本的 Koa,当然自己实现一个简易版本的 Koa,才能更好理解这门框架巧妙的设计

怎样去实现 Koa 呢,首先需要摸清作者的惯用手法,提纲挈领,找到入口, 绘制架构图。通过流程图或架构图的方式去理清整个框架脉络,再去一一实现功能。

当然也可以 “先临摹,再想着自创方式” 去实现。

你将学到哪些知识 ?

  1. 洋葱模型解析及简单实现
  2. application 类核心实现
  3. context 类核心实现
  4. request 类核心实现
  5. response 类核心实现
  6. delegate 委托模式简单实现

二、Koa 最小系统

每一门语言开头总是从 Hello World 开始的, 像这样:

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

app.use((ctx, res) => {
  res.end("Hello World");
});

app.listen(3000);
复制代码

通过这个简单示例, 我们试着去分析下:

  • 凭借前端工程化的经验可以猜测得知 Koa 的入口文件应该在 package.jsonmain 属性有标明;
  • Koa 实例是通过 new 关键字创建的, 也就是说在入口文件中, 默认导出的就是 Koa 应用类;
  • app 是 Koa 实例, 这个实例上挂载了uselisten等方法。

那我们就简单实现一下, Koa 类, 具备 uselisten 方法;

class Koa {
  constructor() {}
  middleware = () => {};
  listen(port, cb) {
    const server = http.createServer((req, res) => {
      this.middleware(req, res);
    });
    return server.listen(port, cb);
  }
  use(middlewareFn) {
    this.middleware = middlewareFn;
    return this;
  }
}

const app = new Koa();
app.use((ctx, res) => {
  res.end("Hello World");
});

app.listen(3000);
复制代码

简单解析

listen实质就是对 http.createServer 的一层封装, 在创建成功后执行通过 use 方法挂载的函数middleware

这样我们就得到了 Koa 的最小系统, 后面我们的工作也是将这个思路一步步拆分。

想一想我们要实现的功能有哪些 ?

  1. Koa 中不止一个中间件, 而是会有很多中间件, 我们需要有一个中间件队列去存储中间件。
  2. 可以通过 use 方法, 将中间件全部保存下来, 方便后续操作。
  3. 在诸多中间件中, 每一个中间件都有自己的 ctx
  4. 原生 req 和 res 比较难用, 那就需要再处理下 request 请求和 response 响应。
  5. 对于一些比较复杂的功能需要单独抽离, 如洋葱模型, Koa 源码中是直接引用的koa-compose 插件。

对于一些插件, 如 koa-composedelegates,我们会手动实现的。

需要把握的主线

我们本节会从三个主线去讲怎样实现一个 Koa 核心功能, 分别是

  1. 一条是沿 http.createServer 封装原生 Node 方向。
  2. 一条是沿构造 context, request, response 及错误处理 的方向。
  3. 最后一条是中间件机制解析与简单实现。

三、封装原生

这条主线是根据 http.createServer 封装原生 Node 来进行的, 这个主线我们在进行下分解:

  1. 构建 Application 实例, 也是 Koa 入口文件;
  2. 创建一个 http 服务, 成功时回调;
  3. 回调函数是通过 use 注册传入的函数, 可以通过 listen 方法监听端口。

具体实现

// application.js
const http = require("http");
class Application extends Emitter {
  constructor() {
    super();
    this.callbackFn;
  }

  listen(...args) {
    let server = http.createServer(this.callback());
    server.listen(...args);
  }

  // 收集中间件
  use(fn) {
    this.callbackFn = fn;
    return this;
  }

  callback() {
    return (req, res) => {
      this.callbackFunc(req, res);
    };
  }
}

module.exports = Application;
复制代码

这实际上和我们前面实现的最小系统极为相似, 也比较简单。

四、构造对象及容错

在我们学习完主线一时,req 和 res 实际还是 Node 原生的 req 和 res,我们无法像 Koa 源码那样使用。这时构造contextrequest, response对象的必要性就显现出来了。

这个主线我们在进行下分解:

  1. 使用 koa 时,会有contextrequest, response 对象, 构造这些对象;
  2. 通过源码分析,我们已知context 对象主要功能是委托代理及框架层错误处理,request对象是对 path、url 等属性的一些处理, response对象是对 body、status 等属性的一些处理。
  3. 怎样把contextrequest, response串联起来的

构造 context

context 对象主要功能是对属性进行委托代理 —— 如ctx.response.body 代理后可以通过 ctx.body 拿到,以及一些中间层的错误捕获。

// context.js
let proto = {
  onerror(err) {
    console.log("框架层捕获函数");

    this.app.emit("error", err);
    // 中间层捕获异常后, 需要响应
    this.status = 500;
    let msg = "服务器内部错误";
    this.res.setHeader("Content-Type", "text/plain; charset=utf-8");
    this.res.setHeader("Content-Length", Buffer.byteLength(msg));
    this.res.end(msg);
  },
};

/**
 * 用于获取对象上的属性
 * @param {object} property
 * @param {string} name
 */
function delegateGet(property, name) {
  proto.__defineGetter__(name, function () {
    return this[property][name];
  });
}

/**
 * 用于挂载对象上的属性和值
 * @param {object} property
 * @param {string} name
 */
function delegateSet(property, name) {
  proto.__defineSetter__(name, function (val) {
    this[property][name] = val;
  });
}

let requestGet = ["query"];
let requestSet = [];

let responseGet = ["body", "status"];
let responseSet = responseGet;

requestGet.forEach((ele) => delegateGet("request", ele));
requestSet.forEach((ele) => delegateSet("request", ele));
responseGet.forEach((ele) => delegateGet("response", ele));
responseSet.forEach((ele) => delegateSet("response", ele));

module.exports = proto;
复制代码

此时 context 对象上已经有了 onerror 方法, 这个等会再聊, 接着往下看。

构造 request

request对象是对 path、url 等属性的一些处理。

// request.js
const url = require("url");

module.exports = {
  get query() {
    // 导出了一个对象,其中包含了一个query的读取方法,通过url.parse方法解析url中的参数,并以对象的形式返回。
    return url.parse(url.req.url).query;
  },
};
复制代码

构造 response

response对象是对 body、status 等属性的一些处理。

// response.js
module.exports = {
  get body() {
    return this._body;
  },

  set body(data) {
    this._body = data;
  },

  get status() {
    return this.res.statusCode;
  },

  set status(statusCode) {
    if (typeof statusCode !== "number") {
      throw new Error("status code must be number type");
    }
    this.res.statusCode = statusCode;
  },
};
复制代码

串联对象

  1. contextrequestresponse挂载到 Application 实例上
  2. 使用 koa 时,会有 ctx 对象,构造中间层 ctx 对象
  3. 将 ctx 对象传递给回调函数

改造 application.js

const http = require("http");
const Emitter = require("events");
const context = require("./context");
const request = require("./request");
const response = require("./response");

class Application extends Emitter {
  constructor() {
    super();
    this.callbackFunc;
    // 每次都会构建新的context、request、response;
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  listen(...args) {
    let server = http.createServer(this.callback());
    server.listen(...args);
  }

  // 收集中间件
  use(fn) {
    this.callbackFn = fn;
  }

  callback() {
    // 框架错误处理
    if (!this.listenerCount("error")) this.on("error", this.onerror);
    return (req, res) => {
      let ctx = this.createContext(req, res);
      this.callbackFn(ctx);
      let respond = () => this.responseBody(ctx);
      // 中间层处理错误
      let onerror = (err) => ctx.onerror(err);
    };
  }

  /**
   * 用于构造 ctx 对象
   * @param {Object} req Node原生的req实例
   * @param {Object} res Node原生的res实例
   */
  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;
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    return ctx;
  }

  /**
   * 处理响应请求
   * @param {Object} ctx
   */
  responseBody(ctx) {
    let content = ctx.body;
    if (typeof content === "string") {
      ctx.res.end(content);
    } else {
      ctx.res.end(JSON.stringify(content));
    }
  }

  onerror(err) {
    let msg = err.stack || err.toString();
    console.error(msg.replace(/^/g, " "));
  }
}

module.exports = Application;
复制代码
  1. Emitter 是原生 events 的类, 原生事件驱动模块;
  2. 在每一个 Koa 实例构造函数上构建新的contextrequestresponse;
  3. createContext 方法是将contextrequestresponse三个对象都放到 ctx 对象上,
  • ctx.appctx.request.appctx.response.app都可以拿 this对象,
  • ctx.reqctx.request.req 可以拿到 req对象;
  • ctx.resctx.response.res可以拿到 res对象;
  1. responseBody 方法是对响应体的处理
  2. onerror方法 是对错误信息的格式化

错误处理

实际上中间层在执行过程中出现错误, 捕捉到错误然后传递到框架进行处理, 目前虽然我们写了错误处理, 并未使用到, 我会在下面讲中间件机制时进行说明.

五、Koa-compose 解析及简单实现

图片已删除

图片已删除

相信这两幅图你也曾看到过, 这也是面试中常常探讨的中间件机制 (洋葱模型) ,也是面向切面编程 (AOP) 典型案例。

koa-compose 解析

function compose(middleware) {
  if (!Array.isArray(middleware))
    throw new TypeError("Middleware stack must be an array!");
  for (const fn of middleware) {
    if (typeof fn !== "function")
      throw new TypeError("Middleware must be composed of functions!");
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */
  // 1. 返回出去的是一个匿名函数
  // 2. 该函数的内部返回了一个函数
  // Promise.resolve reject
  return function (context, next) {
    // 初始索引 -1
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      // 为了确保next只调用一次, 同一函数调用两次就会报错
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      // 获取到中间件
      let fn = middleware[i];
      // 到了Promise的最后一个,让fn = 传递进来的 next, 退出条件
      if (i === middleware.length) fn = next;
      // next => undefined (koa调用时const fn = compose(this.middleware);)
      if (!fn) return Promise.resolve();
      // try catch 用于保证错误在Promise的情况能够正常捕获
      try {
        // 1. fn为一项中间件,  dispatch.bind(null, i + 1))是 next 函数
        // 2. 当中间件队列有两个中间件时,会先挂载第一个。
        // 3. 第一个中间件使用时会调用next(); 如: app.use(async (ctx, next) => { await next();})
        // 4. 此时next() 就是调用 dispatch.bind(null, i + 1), 以此来调用下一个中间件;
        // 5. 当第二个中间件使用时调用next, 再触发dispatch函数, 此时 i 等于 middleware.length 就会退回
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}
复制代码
  1. 该函数返回一个函数, fn ==> fnMiddleware(ctx)
  2. middleware 须为数组队列, 且每一项都为 function
  3. 该函数 function (context, next);

compose 实现

  // 简单实现洋葱模型
  compose() {
    return async (ctx) => {
      let len = this.middlewares.length;
      let next = async () => Promise.resolve();

      for (let i = len - 1; i >= 0; i--) {
        let curentMiddleware = this.middlewares[i];
        next = createNext(curentMiddleware, next);
      }
      await next();

      function createNext(middlewares, oldNext) {
        return async () => await middlewares(ctx, oldNext);
      }
    };
  }
复制代码
  1. 就是将中间件队列进行循环,取到每一项;
  2. createNext 实际就是将控制权传递给下一个中间件

六、完整的Application类

class Application extends Emitter {
  constructor() {
    super();
    // this.callbackFunc;
    this.middlewares = [];
    // 每次都会构建新的context、request、response;
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  listen(...args) {
    let server = http.createServer(this.callback());
    server.listen(...args);
  }

  // 收集中间件
  use(fn) {
    // this.callbackFunc = fn;
    this.middlewares.push(fn);
  }

  callback() {
    // 框架错误处理
    if (!this.listenerCount("error")) this.on("error", this.onerror);
    return (req, res) => {
      let ctx = this.createContext(req, res);
      // this.callbackFunc(ctx);
      let respond = () => this.responseBody(ctx);
      // 中间层处理错误
      let onerror = (err) => ctx.onerror(err);
      // 先处理中间件,然后去响应
      let fn = this.compose();

      return fn(ctx).then(respond).catch(onerror);
    };
  }

  /**
   * 用于构造 ctx 对象
   * @param {Object} req Node原生的req实例
   * @param {Object} res Node原生的res实例
   */
  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;
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;

    return ctx;
  }

  // 简单实现洋葱模型
  compose() {
    return async (ctx) => {
      let len = this.middlewares.length;
      let next = async () => Promise.resolve();

      for (let i = len - 1; i >= 0; i--) {
        let curentMiddleware = this.middlewares[i];
        next = createNext(curentMiddleware, next);
      }
      await next();

      function createNext(middlewares, oldNext) {
        return async () => await middlewares(ctx, oldNext);
      }
    };
  }

  /**
   * 处理响应请求
   * @param {Object} ctx
   */
  responseBody(ctx) {
    let content = ctx.body;
    if (typeof content === "string") {
      ctx.res.end(content);
    } else {
      ctx.res.end(JSON.stringify(content));
    }
  }

  onerror(err) {
    let msg = err.stack || err.toString();
    console.error(msg.replace(/^/g, " "));
  }
}

module.exports = Application;

复制代码
  1. callback 方法中调用了createContext 方法拿到了完整的ctx 对象
  2. 通过responseBody方法 对响应请求进行了处理.
  3. callback 方法中对compose错误进行了捕获处理, 这个捕获会找到context对象的onerror方法, 进行冒泡。
  4. callback方法中也会进行error监听, 将错误信息格式化。

七、测试

const Koa = require("./lib/application");
const app = new Koa();

// app.use((req, res) => {
//     res.writeHeader(200, { 'Content-Type': 'application/json'})
//     res.end("hello world 水电费")
// })

app.use(async (ctx, next) => {
    console.log('1');
    await next();
    console.log('6');
})


app.use(async (ctx, next) => {
    console.log('2');
    // 异常捕获错误
    // throw new Error('New Error')
    await next();
    console.log('5');
})

app.use(async ctx => {
    console.log('3');
    ctx.res.setHeader('Content-type','text/html;charset=utf-8')
    ctx.status = 404;
    ctx.body = "服务异常!!! 请联系后端人员"
    console.log('4');
})



app.listen(3001, () => {
    console.log("hello Koa");
})
复制代码

输出

hello Koa
1
2
3
4
5
6
复制代码

八、总结

本文从Koa 是什么 ?有哪些部分组成 ?、怎样学源码及实现最小系统展开描述。

Koa 只是一个框架, 本文的学习方法可以适用在很多框架中, 当然你也有更好的学习方法, 也欢迎从评论区说出来, 一道成长。

同时,本篇文章是 Koa 系列第三篇,欢迎点赞、关注、支持一波。感谢!!!

我是前端小溪 欢迎感兴趣的同学关注下前端小溪公众号,也欢迎加我微信wxl-15153496335

猜你喜欢

转载自juejin.im/post/7069789937079418917