koa源码解析,理解洋葱模型

2020-07-30 14:57:51 浏览数 (1)

之前,我一直在使用express做简单的后台server,写一些api,给自己做的前端来提供服务,觉着吧挺好用的,虽然koa也出来挺久的,但是我一直没有更换过,直到今天看到一个项目中别人是使用koa来做后端代理的,所以,我才想,是否需要了解一下koa的源码呢。其实,我并不是一个喜欢尝鲜的人,因为我总觉得新鲜的事物一般有他没有考虑到的地方,或许会有很多大大的坑等着我们。但是突然发现,koa其实已经好几年的历史了,沉淀的也差不多了,是时候了解一下,并切换到koa上来了。

了解一个框架最好的方式莫过于直接下载他的源码,然后,跑他最简单的例子,找到入口位置,一步一步的跟踪下去。

首先,koa的入口文件在lib/application.js中,这个是他的package.json文件中告诉我的,node的工程就是这点好,打开package.json文件,大概就知道入口健在在哪了,很方便跟踪源代码。

我们在package.json中可以看到scripts下面配置了这样一些命令,

代码语言:txt复制
 "scripts": {
    "test": "egg-bin test test",
    "test-cov": "egg-bin cov test",
    "lint": "eslint benchmarks lib test",
    "bench": "make -C benchmarks",
    "authors": "git log --format='%aN <�>' | sort -u > AUTHORS"
  },

testtest-cov分别就是做测试,和覆盖率测试用的,简单的说,test就是测试你工程目录test中的文件,挨个挨个挨个拿出来盘一遍,而test-cov是会出一个报告的,你通过率是多少,当然我试了下,100%通过,这说明koa质量确实杠杠的,可以考虑切换。

之前自己写项目,从来就没有考虑写过测试,大佬就是大佬,我们不妨随便看一个测试用例先。就说说 /test/application/context.js这个吧,这里面代码是:

代码语言:txt复制
'use strict';

const request = require('supertest');
const assert = require('assert');
const Koa = require('../..');

describe('app.context', () => {
  const app1 = new Koa();
  app1.context.msg = 'hello';
  const app2 = new Koa();

  it('should merge properties', () => {
    app1.use((ctx, next) => {
      assert.equal(ctx.msg, 'hello');
      ctx.status = 204;
    });

    return request(app1.listen())
      .get('/')
      .expect(204);
  });

  it('should not affect the original prototype', () => {
    app2.use((ctx, next) => {
      assert.equal(ctx.msg, undefined);
      ctx.status = 204;
    });

    return request(app2.listen())
      .get('/')
      .expect(204);
  });
});

他是为了说明,两个实例的context是互相不会影响的。

image-20200612173819628image-20200612173819628

这个测试是通过的,其他就就不一一过了,因为本文毕竟是将源码分析的,其实我还是想啰嗦一句:

test目录下的测试用例都看过一遍之后,你其实对koa的特性就等于基本了解了一遍,以后遇到什么问题,其实都不用上Google或许都可以解决,直接到真的个目录搜索关键字,通过测试用例,就能发现也许是自己某些配置导致的,我也是近期才发现,原来还可以这样定位问题。

那就废话不多说了,我们还是聊聊源码吧,首先,我们看到koa官网给我们的那个极致简单的例子:

代码语言:txt复制
const Koa = require('koa');
const app = new Koa();

// response
app.use(ctx => {
  ctx.body = 'Hello Koa';
});

app.listen(3000);

可以分解为3步:

  • New 了一个koa实例
  • 给实例use了一个中间件
  • 把这个server绑定到3000端口并启动。

首先,我们看一下new Koa做了些什么:

代码语言:txt复制
constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
    this.maxIpsCount = options.maxIpsCount || 0;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }

options中哪些key就不一一介绍,这里有一个keys,表示使用签名的cookie,这样方式被篡改。

然后,这里初始化了一个中间件的数组,用来存储一会用use注册的中间件,等会我们来看这里,先打一个记号。

然后,对context,request,response,但是这里使用的是Ojbect.create,可以了解一下,既:

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。 (请打开浏览器控制台以查看运行结果。)

那,这就意味着this.context的原型其实就是我们import进来的那个context,同理,this.request,this.response也是如此。这种做法明显就比较省内存,同时将context,request,response独立出来,做到了解耦,复用,感觉完美至极。

好吧,koa实例实际上就这么初始化了,其实,我们记得,主要是绑了context,request,response给这个实例,然后做了一个装中间件的数组容器。

那么,接下来,我们看看中间件是如何注册的。

代码语言:txt复制
use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. '  
                'See the documentation for examples of how to convert old middleware '  
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
  }

还是贴源码比价过瘾一点,中间件就是通过这个函数注册的,这里他已经不建议注册那种迭代器函数了,至于神马是迭代器函数,可以参考这里。

这里是注册了,那么,哪里执行的中间件呢?

代码语言:txt复制
callback() {
    const fn = compose(this.middleware);

    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

我们可以看到是在callback函数中,而callback又是在启动httpServer时注册的回调函数,这就意味着来一个请求就会触发这个回调,进而会调用到我们的callback,然而。这里有一个问题:

那就是我们注册了一堆的中间件,他是以怎么样的方式来执行呢?

可以看到中间件数组被compose了一下,这个compose是干啥的呢,一开始我没有看出个所以然,不过,看了这篇文章之后,我大概就明白了。然来Koa.js 的中间件通过这个工具函数组合后,按 app.use() 的顺序同步执行,也就是形成了 洋葱圈 式的调用。如图所示

image-20200612231322626image-20200612231322626

部分比较重要的代码看下面,所有源码都在这

代码语言:txt复制
function compose (middleware) {
  //...

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i   1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

可以看到,中间件需要实现next方法,否则,你会将这个链路断开,到时其他注册的走不了。

至于怎么理解洋葱圈式的调用,可以参考我们的测试用例

代码语言:txt复制
describe('app.use(fn)', () => {
  it('should compose middleware', async() => {
    const app = new Koa();
    const calls = [];

    app.use((ctx, next) => {
      calls.push(1);
      return next().then(() => {
        calls.push(6);
      });
    });

    app.use((ctx, next) => {
      calls.push(2);
      return next().then(() => {
        calls.push(5);
      });
    });

    app.use((ctx, next) => {
      calls.push(3);
      return next().then(() => {
        calls.push(4);
      });
    });

    const server = app.listen();

    await request(server)
      .get('/')
      .expect(404);

    assert.deepEqual(calls, [1, 2, 3, 4, 5, 6]);
  });

  it('should compose mixed middleware', async() => {
    process.once('deprecation', () => {}); // silence deprecation message
    const app = new Koa();
    const calls = [];

    app.use((ctx, next) => {
      calls.push(1);
      return next().then(() => {
        calls.push(6);
      });
    });

    app.use(function * (next){
      calls.push(2);
      yield next;
      calls.push(5);
    });

    app.use((ctx, next) => {
      calls.push(3);
      return next().then(() => {
        calls.push(4);
      });
    });

    const server = app.listen();

    await request(server)
      .get('/')
      .expect(404);

    assert.deepEqual(calls, [1, 2, 3, 4, 5, 6]);
  });

所以,我们知道,上面的测试用例输出的是1,2,3,4,5,6了。

最后,绑定3000端口,启动起来就不用怎么解释了,这个是node原生代码,理解起来并无难度。

那么,这就玩了么,有我不是进场用express做静态代理吗?同样的道理,koa也可以,那么使用的中间件就是这个啦。

我们看下他的源码关键部分:

代码语言:txt复制
if (!opts.defer) {
    return async function serve (ctx, next) {
      let done = false

      if (ctx.method === 'HEAD' || ctx.method === 'GET') {
        try {
          done = await send(ctx, ctx.path, opts)
        } catch (err) {
          if (err.status !== 404) {
            throw err
          }
        }
      }

      if (!done) {
        await next()
      }
    }
  }

  return async function serve (ctx, next) {
    await next()

    if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
    // response is already handled
    if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

    try {
      await send(ctx, ctx.path, opts)
    } catch (err) {
      if (err.status !== 404) {
        throw err
      }
    }
  }

总共也没有多少行源码,这里的逻辑是指,静态代理是否需要推迟执行,如果不推迟执行,那就在next执行之前就执行,如果defer执行,那么先让给其他中间件先处理,处理完回来之后,我在处理。

其实,还有一个中间件,甚至是非常重要的一个,那就是路由中间件,那么他实现的大概原理是啥呢?

代码太多,就看看关键部分

代码语言:txt复制
Router.prototype.routes = Router.prototype.middleware = function () {
  const router = this;

  let dispatch = function dispatch(ctx, next) {
    debug('%s %s', ctx.method, ctx.path);

    const path = router.opts.routerPath || ctx.routerPath || ctx.path;
    const matched = router.match(path, ctx.method);
    let layerChain;

    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }

    ctx.router = router;

    if (!matched.route) return next();

    const matchedLayers = matched.pathAndMethod
    const mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
    ctx._matchedRoute = mostSpecificLayer.path;
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name;
    }

    layerChain = matchedLayers.reduce(function(memo, layer) {
      memo.push(function(ctx, next) {
        ctx.captures = layer.captures(path, ctx.captures);
        ctx.params = layer.params(path, ctx.captures, ctx.params);
        ctx.routerName = layer.name;
        return next();
      });
      return memo.concat(layer.stack);
    }, []);

    return compose(layerChain)(ctx, next);
  };

  dispatch.router = this;

  return dispatch;
};

可以看到最终return的那个方法dispatch ,参数签名包括ctx,next两者,这其实和我们之前看到的中间件定义的方式是一致的。

其实就是去匹配method和path,如果找到就处理,否则直接调用next,交给其他中间件处理,注意,路由本身是中间件。

总结,这里其实可以看到,koa框架本身非常简洁,核心上来看,就是处理了context,request,response,然后所有的事情都交给了中间件处理,这就极大的提升了灵活性,把这部分开放出来交给开发者,可以玩出无限多的可能。

0 人点赞