Koa 原理学习路径与设计哲学

Posted SegmentFault

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Koa 原理学习路径与设计哲学相关的知识,希望对你有一定的参考价值。

本文基于 Koa@2.5.0

Koa简介(废话篇)

Koa是基于 Node.jsHTTP框架,由 Express原班人马打造。是下一代的 HTTP框架,更简洁,更高效。

我们来看一下下载量(2018.3.4)

Koa:471,451 downloads in the last monthExpress:18,471,701 downloads in the last month

说好的 Koa是下一代框架呢,为什么下载量差别有这么大呢, Express一定会说:你大爷还是你大爷!。

确实,好多知名项目还是依赖 Express的,比如webpack的dev-server就是使用的 Express,所以还是看场景啦,如果你喜欢DIY,喜欢绝对的控制一个框架,那么这个框架就应该什么功能都不提供,只提供一个基础的运行环境,所有的功能由开发者自己实现。

正式由于 Koa的高性能和简洁,好多知名项目都在基于 Koa,比如阿里的 eggjs,360奇舞团的 thinkjs

所以,虽然从使用范围上来讲, Express对于 Koa你大爷还是你大爷!,但是如果 Express很好,为什么还要再造一个 Koa呢?接下来我们来了解下 Koa到底带给我们了什么, Koa到底做了什么。

如何着手分析Koa

先来看两段demo。

下面是 Node官方给的一个HTTP的示例。

 
   
   
 
  1. const http = require('http');

  2. const hostname = '127.0.0.1';

  3. const port = 3000;

  4. const server = http.createServer((req, res) => {

  5.  res.statusCode = 200;

  6.  res.setHeader('Content-Type', 'text/plain');

  7.  res.end('Hello World\n');

  8. });

  9. server.listen(port, hostname, () => {

  10.  console.log(`Server running at http://${hostname}:${port}/`);

  11. });

下面是最简单的一个 Koa的官方实例。

 
   
   
 
  1. const Koa = require('koa');

  2. const app = new Koa();

  3. app.use(async ctx => {

  4.  ctx.body = 'Hello World';

  5. });

  6. app.listen(3000);

Koa是一个基于 Node的框架,那么底层一定也是用了一些 Node的API。

jQuery很好用,但是 jQuery也是基于DOM,逃不过也会用 element.appendChild这样的基础API。 Koa也是一样,也是用一些 Node的基础API,封装成了更好用的HTTP框架。

那么我们是不是应该看看 Koahttp.createServer的代码在哪里,然后顺藤摸瓜,了解整个流程。

Koa核心流程分析

Koa的源码有四个文件:

  • application.js // 核心逻辑

  • context.js // 上下文,每次请求都会生成一个

  • request.js // 对原生HTTP的req对象进行包装

  • response.js // 对原生HTTP的res对象进行包装

我们主要关心 application.js中的内容,直接搜索 http.createServer,会搜到

 
   
   
 
  1.  listen(...args) {

  2.    debug('listen');

  3.    const server = http.createServer(this.callback());

  4.    return server.listen(...args);

  5.  }

刚好和 Koa中的这行代码 app.listen(3000);关联起来了。

找到源头,现在我们就可以梳理清楚主流程,大家对着源码看我写的这个流程

 
   
   
 
  1. fn:listen

  2. fn:callback

  3. [fn:compose] // 组合中间件 会生成后面的 fnMiddleware

  4. fn:handleRequest // (@closure in callback)

  5. [fn(req, res):createContext] // 创建上下文 就是中间件中用的ctx

  6. fn(ctx, fnMiddleware):handleRequest // (@koa instance)

  7. code:fnMiddleware(ctx).then(handleResponse).catch(onerror);

  8. fn:handleResponse

  9. fn:respond

  10. code:res.end(body);

从上面可以看到最开始是 listen方法,到最后HTTP的 res.end方法。

listen可以理解为初始化的方法,每一个请求到来的时候,都会经过从 callbackrespond的生命周期。

在每个请求的生命周期中,做了两件比较核心的事情:

  1. 将多个中间件组合

  2. 创建ctx对象

多个中间件组合后,会先后处理ctx对象,ctx对象中既包含的req,也包含了res,也就是每个中间件的对象都可以处理请求和响应。

这样,一次HTTP请求,接连经过各个中间件的处理,再到返回给客户端,就完成了一次完美的请求。

Koa中的ctx

 
   
   
 
  1. app.use(async ctx => {

  2.  ctx.body = 'Hello World';

  3. });

上面的代码是一个最简答的中间件,每个中间件的第一个参数都是 ctx,下面我们说一下这个 ctx是什么。

创建 ctx的代码:

 
   
   
 
  1.  createContext(req, res) {

  2.    const context = Object.create(this.context);

  3.    const request = context.request = Object.create(this.request);

  4.    const response = context.response = Object.create(this.response);

  5.    context.app = request.app = response.app = this;

  6.    context.req = request.req = response.req = req;

  7.    context.res = request.res = response.res = res;

  8.    request.ctx = response.ctx = context;

  9.    request.response = response;

  10.    response.request = request;

  11.    context.originalUrl = request.originalUrl = req.url;

  12.    context.cookies = new Cookies(req, res, {

  13.      keys: this.keys,

  14.      secure: request.secure

  15.    });

  16.    request.ip = request.ips[0] || req.socket.remoteAddress || '';

  17.    context.accept = request.accept = accepts(req);

  18.    context.state = {};

  19.    return context;

  20.  }

直接上代码,Koa每次请求都会创建这样一个ctx对象,以提供给每个中间件使用。

参数的 req,res是Node原生的对象。

下面解释下这三个的含义:

  • context:Koa封装的带有一些和请求与相应相关的方法和属性

  • request:Koa封装的req对象,比如提了供原生没有的 host属性。

  • response:Koa封装的res对象,对返回的 bodyhook了getter和setter。

其中有几行一堆 xx=xx=xx,这样的代码。

是为了让ctx、request、response,能够互相引用。

举个例子,在中间件里会有这样的等式

 
   
   
 
  1. ctx.request.ctx === ctx

  2. ctx.response.ctx === ctx

  3. ctx.request.app === ctx.app

  4. ctx.response.app === ctx.app

  5. ctx.req === ctx.response.req

  6. // ...

为什么会有这么奇怪的写法?其实只是为了互相调用方便而已,其实最常用的就是ctx。

打开 context.js,会发现里面写了一堆的 delegate

 
   
   
 
  1. /**

  2. * Response delegation.

  3. */

  4. delegate(proto, 'response')

  5.  .method('attachment')

  6.  .method('redirect')

  7.  .method('remove')

  8.  .method('vary')

  9.  .method('set')

  10.  .method('append')

  11.  .method('flushHeaders')

  12.  .access('status')

  13.  .access('message')

  14.  .access('body')

  15.  .access('length')

  16.  .access('type')

  17.  .access('lastModified')

  18.  .access('etag')

  19.  .getter('headerSent')

  20.  .getter('writable');

  21. /**

  22. * Request delegation.

  23. */

  24. delegate(proto, 'request')

  25.  .method('acceptsLanguages')

  26.  .method('acceptsEncodings')

  27.  .method('acceptsCharsets')

  28.  .method('accepts')

  29.  .method('get')

  30.  .method('is')

  31.  .access('querystring')

  32.  .access('idempotent')

  33.  .access('socket')

  34.  .access('search')

  35.  .access('method')

  36.  .access('query')

  37.  .access('path')

  38.  .access('url')

  39.  .getter('origin')

  40.  .getter('href')

  41.  .getter('subdomains')

  42.  .getter('protocol')

  43.  .getter('host')

  44.  .getter('hostname')

  45.  .getter('URL')

  46.  .getter('header')

  47.  .getter('headers')

  48.  .getter('secure')

  49.  .getter('stale')

  50.  .getter('fresh')

  51.  .getter('ips')

  52.  .getter('ip');

是为了把大多数的 requestresponse中的属性也挂在 ctx下,我们为了拿到请求的路径需要 ctx.request.path,但是由于代理过 path这个属性, ctx.path也是可以的,即 ctx.path===ctx.request.path

ctx模块大概就是这样,没有讲的特别细,这块是重点不是难点,大家有兴趣自己看看源码很方便。

一个小tip: 有时候我也会把 context.js中最下面的那些 delegate当成文档使用,会比直接看文档快一点。

Koa中间件机制

中间件函数的参数解释
  • ctx:上面讲过的在请求进来的时候会创建一个给中间件处理请求和响应的对象,比如读取请求头和设置响应头。

  • next:暂时可以理解为是下一个中间件,实际上是被包装过的下一个中间件。

一个小栗子

我们来看这样的代码:

 
   
   
 
  1. // 第一个中间件

  2. app.use(async(ctx, next) => {

  3.  console.log('m1.1', ctx.path);

  4.  ctx.body = 'Koa m1';

  5.  ctx.set('m1', 'm1');

  6.  next();

  7.  console.log('m1.2', ctx.path);

  8. });

  9. // 第二个中间件

  10. app.use(async(ctx, next) => {

  11.  console.log('m2.1', ctx.path);

  12.  ctx.body = 'Koa m2';

  13.  ctx.set('m2', 'm2');

  14.  next();

  15.  debugger

  16.  console.log('m2.2', ctx.path);

  17. });

  18. // 第三个中间件

  19. app.use(async(ctx, next) => {

  20.  console.log('m3.1', ctx.path);

  21.  ctx.body = 'Koa m3';

  22.  ctx.set('m3', 'm3');

  23.  next();

  24.  console.log('m3.2', ctx.path);

  25. });

会输出什么呢?来看下面的输出:

 
   
   
 
  1. m1.1 /

  2. m2.1 /

  3. m3.1 /

  4. m3.2 /

  5. m2.2 /

  6. m1.2 /

来解释一下上面输出的现象,由于将 next理解为是下一个中间件,在第一个中间件执行 next的时候,第一个中间件就将 执行权限给了第二个中间件,所以 m1.1后输出的是 m2.1,在之后是 m3.1

那么为什么 m3.1后面输出的是 m3.2呢?第三个中间件之后已经没有中间件了,那么第三个中间件里的 next又是什么?

我先偷偷告诉你,最后一个中间件的 next是一个立刻resolve的Promise,即 returnPromise.resolve(),一会再告诉你这是为什么。

所以第三个中间件(即最后一个中间件)可以理解成是这样子的:

 
   
   
 
  1. app.use(async (ctx, next) => {

  2.    console.log('m3.1', ctx.path);

  3.    ctx.body = 'Koa m3';

  4.    ctx.set('m3', 'm3');

  5.    new Promise.resolve(); // 原来是next

  6.    console.log('m3.2', ctx.path);

  7. });

从代码上看, m3.1后面就会输出 m3.2

那为什么 m3.2之后又会输出 m2.2呢?,我们看下面的代码。

 
   
   
 
  1. let f1 = () => {

  2.  console.log(1.1);

  3.  f2();

  4.  console.log(1.2);

  5. }

  6. let f2 = () => {

  7.  console.log(2.1);

  8.  f3();

  9.  console.log(2.2);

  10. }

  11. let f3 = () => {

  12.  console.log(3.1);

  13.  Promise.resolve();

  14.  console.log(3.2);

  15. }

  16. f1();

  17. /*

  18.  outpout

  19.  1.1

  20.  2.1

  21.  3.1

  22.  3.2

  23.  2.2

  24.  1.2

  25. */

这段代码就是纯函数调用而已,从这段代码是不是发现,和上面一毛一样,对一毛一样,如果将 next理解成是下一个中间件的意思,就是这样。

中间件组合的过程分析

用户使用中间件就是用 app.use这个API,我们看看做了什么:

 
   
   
 
  1.  // 精简后去掉非核心逻辑的代码

  2.  use(fn) {

  3.    this.middleware.push(fn);

  4.    return this;

  5.  }

可以看到,当我们应用中间件的时候,只是把中间件放到一个数组中,然后返回this,返回this是为了能够实现链式调用。

那么Koa对这个数组做了什么呢?看一下核心代码。

 
   
   
 
  1. const fn = compose(this.middleware); // @callback line1

  2. // fn 即 fnMiddleware

  3. return fnMiddleware(ctx).then(handleResponse).catch(onerror); // @handleRequest line_last

可以看到用 compose处理了 middleware数组,得到函数 fnMiddleware,然后在 handleRequest返回的时候运行 fnMiddleware,可以看到 fnMiddleware是一个 Promiseresolve的时候就会处理完请求,能猜到 compose将多个中间件组合成了一个返回 Promise的函数,这就是奇妙之处,接下来我们看看吧。

精简后的 compose源码

 
   
   
 
  1. // 精简后去掉非核心逻辑的代码

  2. 00    function compose (middleware) {

  3. 01      return function (context, next) { // fnMiddleware

  4. 02        return dispatch(0)

  5. 03        function dispatch (i) {

  6. 04          let fn = middleware[i] // app.use的middleware

  7. 05          if (!fn) return Promise.resolve()

  8. 06          return fn(context, function next () {

  9. 07            return dispatch(i + 1)

  10. 08          })

  11. 09        }

  12. 10      }

  13. 11    }

精简后代码只有十几行,但是我认为这是 Koa最难理解、最核心、最优雅、最奇妙的地方。

看着各种function,各种return有点晕是吧,不慌,不慌啊,一行一行来。

compose返回了一个匿名函数,这个匿名函数就是 fnMiddleware

刚才我们是有三个中间件,你们准备好啦,请求已经过来啦!

当请求过来的时候, fnMiddleware就运行了,即运行了 componse返回的匿名函数,同时就会运行返回的 dispatch(0),那我们看看 dispatch(0)做了什么,仔细一看其实就是

 
   
   
 
  1. // dispatch(0)的时候,fn即middleware[0]

  2. return middleware[0](context, function next (){

  3.  return dispatch(1);

  4. })

  5. // 上面的context和next即中间件的两个参数

  6. // 第一个中间件

  7. app.use(async(ctx, next) => {

  8.  console.log('m1.1', ctx.path);

  9.  ctx.body = 'Koa m1';

  10.  ctx.set('m1', 'm1');

  11.  next(); // 这个next就是dispatch(1)

  12.  console.log('m1.2', ctx.path);

  13. });

同理,在第二个中间件里面的 next,就是 dispatch(2),也就是用上面的方法被包裹一层的第三个中间件。

现在来看第三个中间件里面的 next是什么?

可以看到精简过的 compose05有个判断,如果 fn不存在,会返回 Promise.resolve(),第三个中间件的 nextdispatch(3),而一共就有三个中间件,所以middleware[3]是 undefined,触发了分支判断条件,就返回了 Promise.resolve()

再来复盘一下:

  1. 请求到来的事情,运行 fnMiddleware(),即会运行 dispatch(0)调起第一个中间件。

  2. 第一个中间件的 next是 dispatch(1),运行 next的时候就调起 第二个中间件

  3. 第二个中间件的 next是 dispatch(2),运行 next的时候就调起 第三个中间件

  4. 第三个中间件的 next是 dispatch(3),运行 next的时候就调起 Promise.resolve()。可以把 Promise.resolve()理解成一个空的什么都没有干的中间件。

到此,大概知道了多个中间件是如何被 compose成一个大中间件的了吧。

中间件的类型

koa2中,支持三种类型的中间件:

  • commonfunction:普通的函数,需要返回一个 promise

  • generatorfunction:需要被 co包裹一下,就会返回一个 promise

  • asyncfunction:直接使用,会直接返回 promise

可以看到,无论哪种类型的中间件,只要返回一个 promise就好了,因为这行关键代码 returnfnMiddleware(ctx).then(handleResponse).catch(onerror);,可以看到 KoafnMiddleware的返回值认为是 promise。如果传入的中间件运行后没有返回 promise,那么会导致报错。

结语

Koa的原理就解析到这里啦,欢迎交流讨论。为了更好地让大家学习 Koa,我写了一个mini版本的 Koa,大家可以看一下:https://github.com/geeknull/tiny-koa


以上是关于Koa 原理学习路径与设计哲学的主要内容,如果未能解决你的问题,请参考以下文章

KOA2框架原理解析和实现

《Koa.js 设计模式-学习笔记》Koa.js第二本开源电子书完结

社区说|Kotlin Flow 的原理与设计哲学

郑捷《机器学习算法原理与编程实践》学习笔记(第七章 预测技术与哲学)7.1 线性系统的预测

郑捷《机器学习算法原理与编程实践》学习笔记(第七章 预测技术与哲学)7.3 岭回归

深入解析Koa之核心原理