前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >koa源码解析,理解洋葱模型

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

原创
作者头像
brzhang
修改2020-07-30 14:57:51
5990
修改2020-07-30 14:57:51
举报
文章被收录于专栏:玩转全栈

之前,我一直在使用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 <%aE>' | 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-20200612173819628
image-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-20200612231322626
image-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,然后所有的事情都交给了中间件处理,这就极大的提升了灵活性,把这部分开放出来交给开发者,可以玩出无限多的可能。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
消息队列 TDMQ
消息队列 TDMQ (Tencent Distributed Message Queue)是腾讯基于 Apache Pulsar 自研的一个云原生消息中间件系列,其中包含兼容Pulsar、RabbitMQ、RocketMQ 等协议的消息队列子产品,得益于其底层计算与存储分离的架构,TDMQ 具备良好的弹性伸缩以及故障恢复能力。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档