10月12, 2022

Koa源码分析

Koa -- 基于 Node.js 平台的下一代 web 开发框架

在juejin.cn有幸看到一个程序员讲述如何阅读源代码,主要分为三步:领悟思想、把握设计、体会细节。

领悟思想:只需体会作者设计框架的初衷和目的 把握设计:只需体会代码的接口和抽象类以及宏观的设计 体会细节:是基于顶层的抽象接口设计,逐渐展开代码的画卷 基于上述三步法,迫不及待的拿Express开刀了。本次源码解析有什么不到位的地方各位读者可以在下面留言,我们一起交流。

以上是内容是参考执鸢者文章聊一聊三步法解析Express源码。私以为言之有理,决定将该内容作为自己每篇源码分析的开头模板。

领悟思想

在Koa官网上,介绍Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。在这句话里面可以得到解读出以下几点含义:

  1. Express内置了许多中间件可供使用,而koa没有。

  2. Express包含路由,视图渲染等特性,而koa只有http模块。

  3. Express的中间件模型为线型,而koa的中间件模型为U型,也可称为洋葱模型构造中间件。

  4. Express通过回调实现异步函数,在多个回调、多个中间件中写起来容易逻辑混乱。

Koa是Web框架,解决了Express的一些问题,相对于Express更小。

线性模型和洋葱模型

Express是线性模型,Koa是洋葱模型。

引用网友总结:其实中间件执行逻辑没有什么特别的不同,都是依赖函数调用栈的执行顺序,抬杠一点讲都可以叫做洋葱模型。Koa 依靠 async/await(generator + co)让异步操作可以变成同步写法,更好理解。最关键的不是这些中间的执行顺序,而是响应的时机,Express 使用 res.end() 是立即返回,这样想要做出些响应前的操作变得比较麻烦;而 Koa 是在所有中间件中使用 ctx.body 设置响应数据,但是并不立即响应,而是在所有中间件执行结束后,再调用 res.end(ctx.body) 进行响应,这样就为响应前的操作预留了空间,所以是请求与响应都在最外层,中间件处理是一层层进行,所以被理解成洋葱模型,个人拙见。

参考链接:(Koa与Express的中间件机制揭秘)[https://cloud.tencent.com/developer/article/1467268]

Express对async和await支持不好

通过一个Express例子来看

   const Express = require('express');
    const app = new Express();
    const sleep = () => new Promise(resolve => setTimeout(function(){resolve(1)}, 2000));
    const port = 8210;
    function f1(req, res, next) {
      console.log('this is function f1....');
      next();
      console.log('f1 fn executed done');
    }
    
    function f2(req, res, next) {
      console.log('this is function f2....');
      next();
      console.log('f2 fn executed done');
    }
    
    async function f3(req, res) {
      await sleep();
      console.log('f3 send to client');
      res.send('Send To Client Done');
    }
    app.use(f1);
    app.use(f2);
    app.use(f3);
    app.get('/', f3)
    app.listen(port, () => console.log(`Example app listening on port ${port}!`))

Express的执行顺序,console.log('f2 fn executed done');console.log('f3 send to client');前执行,原因next()不是返回的promise对象,否则可以通过await实现同步。

    this is function f1....
    this is function f2....
    f1 fn executed done
    f2 fn executed done
    f3 send to client

再通过一个Koa例子来看:

const Koa = require('koa');
const app = new Koa();
const sleep = () => new Promise(resolve => setTimeout(function(){resolve()}, 1000))
app.use(async (ctx, next) => {
    console.log('middleware 1 start');
    await next();
    console.log('middleware 1 end');
});
app.use(async (ctx, next) => {
    await sleep();
    console.log('middleware 2 start');
    await next();
    console.log('middleware 2 end');
});

app.use(async (ctx, next) => {
    console.log('middleware 3 start')
    ctx.body = 'test middleware executed';
})

不出意料,执行顺序如下:

middleware 1 start
middleware 2 start
middleware 3 start
middleware 2 end
middleware 1 end

原因是Koa内部next返回是一个Promise对象,await next()所以能够控制顺序的执行。

把握设计

理解了作者设计的思想,下面从源码目录、核心设计原理及抽象接口三个层面来对Koa进行整体的把握。

2.1源码目录

如下所示是Koa的源码目录,非常的简单,只有4个js文件。

├─application.js---Koa类的文件(核心) 
├─context.js--- Context类的文件
├─request.js---Request类的文件
├─response.js---Response类的文件

2.2接口抽象

下面UML类图描述了Koa中各个类的关系,Application对象包含Context、Request和Response三个对象。(图中仅展示必要的属性和方法,详细细节可以查看源码)

2.3设计原理

我们先看一段代码:

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

// middleware1

app.use(async (ctx, next) => {
  console.log("middleware1 start");
  await next();
  console.log("middleware1 end");
});

// middleware2

app.use(async (ctx, next) => {
  console.log("middleware2 start");
  await next();
  console.log("middleware2 end");
});

// middleware3

app.use(async (ctx, next) => {
  console.log("middleware3 start");
  ctx.body = "hello world";
  console.log("middleware3 end");
});

app.listen(3000);

这段代码的运行流程如下图所示。类似一个洋葱从外到里,再从里到外。

了解完上述概念后,结合该幅图,就大概能对整个流程有了直观感受。首先启动服务,然后客户端发起了http://localhost:3000的请求,该过程应该如何运行呢?

  1. 启动服务的时候,会生成一个Application对象。同时对中间件进行注册的时候(调用use方法),会对处理函数进行存储。

  2. 对相应的地址进行监听,等待请求到达。

  3. 请求到达后,按照洋葱模型调用每一个处理函数。(由于Koa没有内置路由,这里就分析最简单的中间件调用)

** 上述解释的比较简单,后续会在细节部分进一步阐述。**

体会细节

通过上述对Koa设计原理的分析,下面将从两个方面做进一步的源码解读,下面流程图是一个常见的Koa项目的过程,首先会进行app实例初始化、然后调用一系列中间件,最后建立监听。对于整个工程的运行来说,主要分为两个阶段:初始化阶段、请求处理阶段,下面将以app.use()为例来阐述一下该核心细节。

3.1初始化阶段

下面利用app.use()这个中间件来了解一下工程的初始化阶段。

1.在Application构造函数,创建了context、request、response和middlware对象。

constructor (options) {
  this.middleware = []
  this.context = Object.create(context)
  this.request = Object.create(request)
  this.response = Object.create(response)
}

2.在app.use函数中,将中间件函数保存到数组。

// middleware1

app.use(async (ctx, next) => {
  console.log("middleware1 start");
  await next();
  console.log("middleware1 end");
});

// middleware2

app.use(async (ctx, next) => {
  console.log("middleware2 start");
  await next();
  console.log("middleware2 end");
});

// middleware3

app.use(async (ctx, next) => {
  console.log("middleware3 start");
  ctx.body = "hello world";
  console.log("middleware3 end");
});

use (fn) {
  this.middleware.push(fn)
  return this
}

3.app.listen(),其实就是调用this.callback()返回的函数。(this.callback()下面继续分析)

listen (...args) {
  const server = http.createServer(this.callback())
  return server.listen(...args)
}

通过上述的分析,可以看出初始化阶段主要做一件事情:将中间件函数保存到middleware数组。

3.2请求处理阶段

当服务启动后即进入监听状态,等待请求到达后进行处理。

1.compose函数,对中间件数组进行合并,compose函数来自koa-compose包。

const compose = require('koa-compose')

callback () {
  const fn = this.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
}

2.从下面函数可以看出,compose函数最后返回一个function(context,next)函数。

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  times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
function(context,next){
    //
}

3.调用Application.handleRequest函数。可以看出将ctx对象传入上一步骤生成的函数function(context,next)fnMiddleware,并且调用。

handleRequest (ctx, fnMiddleware) {
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

4.调用dispatch函数,即调用中间件函数。dispatch(0),fn是第一个中间件,并且返回一个Promise对象,第一个中间件中next函数是 dispatch.bind(null,1),因此调用next函数,即调用下一个中间件函数。同时因为dispatch(0)返回是Promise对象,因此可以使用await。

function dispatch (i) {
  try {
    return Promise.resolve(fn(context, dispatch.bind(null,1)));
  } catch (err) {
    return Promise.reject(err)
  }
}

5.fnMiddleware依次调用中间件函数后,最后有handleResponse和onerror,分别进行最后的处理。handleResponse处理Response body,最后调用res.end(body);onerror处理异常。

handleRequest (ctx, fnMiddleware) {
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

通过上述的分析,可以看出初始化阶段主要做两件事情:

  1. compose 中间件函数数组。
  2. next依次执行下一个中间件函数。

本文链接:https://imyoyo.xyz/post/koa-source-code-analysis.html

-- EOF --

Comments