一起来Koa源码学习

笔者本身水平一般,接触 Koa 的时间也不是很久。最近学校因为疫情的原因依旧没有开学,便有时间想给大家以一个菜鸟的角度,来解读一下 Koa 的源码,希望大家能有所收获。

前言:

想必大家了解 Node 的朋友也多少接触过 Koa 这个服务端库,相较于 Express 而言, Koa 默认使用 async/await 语法来处理异步代码,使得整个过程轻松愉快。同时,说到 Koa 就不得提一提它的洋葱结构:

const Koa = require('koa');

const app = new Koa();

//middleware#1
app.use(async (ctx, next) => {
    console.log('1');
    await next();
    console.log('2');
})

//middleware#2
app.use(async (ctx, next) => {
    console.log('3');
    await next();
    console.log('4');
})

app.listen(3000);
复制代码

此时,触发服务器,控制台会打印:

1
3
4
2
复制代码

到执行中间件代码的时候,如果遇到执行 next 函数,就会暂停执行目前代码,转而执行下一个中间件代码。这里就不多赘述,想必大家肯定都了解。

了解项目整体:

我们将源码从 github 上克隆下来,打开 /lib 文件夹,惊讶的发现,一共只有4个模块,他们分别是:

-lib
    -application.js
    -context.js
    -request.js
    -response.js
复制代码

实际上, Koa 本身非常精简,以至于都不能被称之为是一个 Web Framework ,常见的 router、body 等中间件并没继承到其中,它们都有非常多的第三方库可供使用,我们也可以非常容易的开发自己的中间件。

我们先分别了解一下这4个模块的作用:

  1. application.js

    这个模块的作用可以看成一般库的 index.js ,它负责创造 Koa 类,并且将剩下的3个模块集成到类中。

  2. context.js

    这个模块其实主要就是生成 Koa 的上下文,也就是对应我们中间件入参里面的 ctx 参数。

  3. request.js

    这个模块想必大家应该能猜到它的作用:它生成了 Koa 自己的请求对象,这模块件里,主要是对 Node 暴露的原生 req 对象,做了一些处理,以方便我们使用。

  4. response.js

    对应的,这个模块生成了 Koa 自己的响应对象,它的作用本身也是对 Node 原生 res 对象的一个代理。

了解完这4个模块的作用之后,大家心里肯定对 Koa 有了一个大概的认识,它本质就是这么简练。现在让我们分别对这些模块作进一步的认识。

笔者不会详细的分析每一个函数的作用,只会把笔者目前水平认为重要的地方给大家分析一下,如果大家有更好的建议,欢迎赐教。

application.js

这个模块一共275行,看着这里默默松了口气,它主要的逻辑可以精简为:

//省略其他的模块输入
const context = require('./context');
const request = require('./request');
const response = require('./response');
const compose = require('koa-compose');

class Koa extends EventEmitter {
    constructor() {
        super();
        
        //存储中间件函数
        this.middleware = [];
        //使用我们引入的模块
        this.context = context;
        this.request = request;
        this.response = response;
    }

	//注册中间件的函数到middleware数组
	use(fn) {
            //
	}

	//触发执行middleware数组的中间件函数,并且实现洋葱模型的执行逻辑
	callback() {
            //
	}

	//创建上下问环境,这里Node原生的req、res作为入参传入
	createContext(req, res) {
            //
	}

	//处理原始请求
	handleRequest(ctx, fnMiddleware) {
            //
	}

	//启动服务,监听端口
	listen(...arg) {
            //
	}
}

//最后少不了的,暴露Koa类
module.exports = Koa;
复制代码

可以看到,最主要的,可以支撑起这个类的函数,一共就只有这5个。是不是感觉又松了一个气,现在让我们分别看看这5个函数具体又有啥作用。同样的,我也会对这些函数的逻辑做一些简化。

  1. use(fn)

    这个函数的作用就非常简单,他接受一个中间件函数作为入参,并且把它添加到类的中间件数组 middleware 中。来看看简化的逻辑:

    use(fn) {
         //把中间件函数存入中间件数组
         this.middleware.push(fn);
     
         return this;
     }
    复制代码

    没错,主要的逻辑就是这么简单。这里源码中还会去判断传入的函数是不是一个生成器函数,如果是,会提醒开发者下一个版本将不会支持生成器函数作为入参了。我们知道 Koa1 其实是用生成器函数来处理异步的,到了 Koa2 便开始支持新的 async/await 语法,使得异步的处理更加友好。对于异步的处理,大家有兴趣可以去看看我的另一篇水文。

  2. createContext()

    这个函数的主要功能,其实就是创建 Koa 的上下文 ctx 。其代码可以简化为:

    createContext(req, res) {
         //模块开始引入的context,request和response在这里派上用场了
         const context = Object.create(this.context);
         const request = context.request = Object.create(this.request);
         const response = context.response = Object.create(this.response);
         //添加ctx的各种属性
         context.app = request.app = response.app = this;
         context.req = request.req = response.req = req;
         context.res = request.res = response.res = res;
         request.ctx = response.ctx = context;
         request.response = response;
         response.request = request;
         //返回context对象
         return context;
    }
    复制代码

    看到上面的代码,大家难免心头一紧,这眼花缭乱的赋值到底是在干嘛?

    其实它干的事情,基本上就是代码中注释的事情。你只要清楚,这个函数就是根据从 context 模块引入的 context 对象,生成新的上下文对象,并且将剩下模块中处理好的 requestresponse 对象,添加到上下文对象上,成为其属性。最后返回了这个上下文对象。

    至于 contextrequestresponse 模块中的逻辑,接下来会作介绍。

  3. handleRequest(ctx, fnMiddleware)

    这个函数顾名思义,就知道它是处理原始请求的函数,咱们看看他的逻辑:

    handleRequest(ctx, fnMiddleware) {
         //取得Node原生res对象
         const res = ctx.res;
         //默认返回状态码404
         res.statusCode = 404;
         //接下来的3行,主要是对响应做一些处理,
         //他们的逻辑,我们略去,有兴趣的可以了解一下
         //简单来说,就是中间件处理完之后,就由这3行代码返回给客户端结果
         const onerror = err => ctx.onerror(err);
         const handleResponse = () => respond(ctx);
         onFinished(res, onerror);
         //最后这个地方,会在callback()函数中分析
         return fnMiddleware(ctx).then(handleResponse).catch(onerror);
    }
    复制代码

    可以看到,第一个入参是我们处理好的上下文对象,第二个入参暂时不太清楚,它是在 callback 函数中生成的。整个函数主要的作用见注释。

    实际上,整个 handleRequest 函数,也都是在 callback 函数中调用的。

  4. callback()

    接下来就是最重要的 callback 函数了,这个函数就是实现 Koa 洋葱模型调用的重要函数了。实际上,它主要是调用了我们在模块开头引入的 compose 函数来实现功能的,这里我就改写一下,做一个简单的实现:

    callback() {
     //调用compose函数
         const fn = compose(this.middleware);
         
         //实现一个简单的compose函数
         function compose(middleware) {
         //返回一个入参为(ctx, next)的函数,有中间件函数那味儿了
             return function (context, next) {
                 //索引index从-1开始
                 let index = -1;
                 //调用dispatch函数,并返回函数的结果
                 return dispatch(0);
                 
                 //dispatch函数的实现,可以看到,这个函数总是返回一个promise对象,
                 //以支持async/await语法
                 function dispatch (i) {
                     if (i <= index) return Promise.reject(new Error('next() called multiple times'));
                     //第一次调用时,这个i = 0,故index = 0
                     index = i;
                     //第一次调用时,fn取得中间件函数的第一个函数
                     let fn = middleware[i];
                     //如果此时i和中间件数组的长度一样了,
                     //也就是中间件都执行完了,那么就将fn赋值为传入next函数
                     if (i === middleware.length) fn = next;
                     //如果fn不存在,返回状态为resolve的promise对象
                     if (!fn) return Promise.resolve();
                     //尝试执行fn,也就是我们取得的中间件函数
                     try {
                     //这一句就很重要了,返回一个状态为resolve的promise对象,尝试执行fn中间件函数
                     //文章一开始演示的中间件函数,入参就为(ctx, next),
                     //与这里的(context, dispatch.bind(null, i + 1))对应,
                     //next形参传入dispatch.bind(null, i + 1)函数,
                     //我们中间件有一句await next(),执行next(),也就是执行了
                     //dispatch.bind(null, i + 1)(),并且返回的是一个promise对象
                     //完美的契合了async/await语法,perfect!
                     return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
                     //我们来思考一下最后一个中间件函数调用的情况:
                     //最后一个中间件函数执行到了await next(),
                     //触发调用dispatch.bind(null, i + 1)()
                     //执行到if (i === middleware.length) fn = next;时,条件成立,
                     //fn被赋值为next,而next为null,因为在handleRequest函数中没有传这个参数,
                     //因而返回Promise.resolve();
                     } catch (err) {
                     return Promise.reject(err);
                     }
                 }
             }
         }
     }
    
         //声明一个匿名函数的变量,它以Node原生req和res作为输入,
         //这里我们不难猜到,他可能是用在Node中http或者https模块中,
         //作为createServer()函数的入参
         const handleRequest = (req, res) => {
            const ctx = this.createContext(req, res);
            return this.handleRequest(ctx, fn);
         }
         
         //返回定义好的对象
         return handleRequest;
     }
    复制代码

    这个 compose 函数的逻辑应该是这些函数里面最难以理解的。它以一个中间件数组作为入参,然后返回一个以 (ctx, next) 作为入参的函数,形式非常像我们的中间件函数,然后把这个函数 fn 作为入参调用上面介绍的 handleRequest 函数:

    //在handleRequest函数中,最后调用了
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
    复制代码

    这里的 fnMiddleware(ctx) 就是将上下文对象作为入参传给我们 compose 函数返回的函数,注意,这里并没有给 next 这个形参赋值,原因见注释。那么如果执行了 compose 函数返回的函数,会发生什么呢?

    我们可以看到,它执行了 dispatch 函数,并返回结果。而实际上,这个 dispatch(0) 的执行,就像是推倒了多米诺骨牌的第一张牌。

    如果看到代码中的注释,你依旧很难理解其中的逻辑,不比担心,后面会以一个请求的开始到返回响应,演示一遍整体的流程,到那个时候,我想你应该会明白。

  5. listen()

    这个函数的逻辑相信大家都猜得到,它创建了服务并监听端口:

    listen(...args) {
         const server = http.createServer(this.callback());
         return server.listen(...args);
     }
    复制代码

    其实,我们大多数的服务端库都是在 createServer 这个函数的入参做文章,因为它可以取到 Node 原生的 reqres 对象。我们将 callback 函数的调用结果传入 createServer 函数来开启服务,而 callback 的调用结果是这样一个函数:

    //在callback函数中
     const handleRequest = (req, res) => {
         const ctx = this.createContext(req, res);
         return this.handleRequest(ctx, fn);
     }
    复制代码

现在,我们可以以我们开始的那个例子分析一下,从请求传入到返回响应,都发生了什么:

//最初的例子
const Koa = require('koa');

const app = new Koa();

//middleware#1
app.use(async (ctx, next) => {
    console.log('1');
    await next();
    console.log('2');
})

//middleware#2
app.use(async (ctx, next) => {
    console.log('3');
    await next();
    console.log('4');
})

app.listen(3000);
复制代码
  1. 首先,我们引入 Koa 类,创建了一个实例,接下来调用了两次 use 方法,还记得 use 方法做了什么吗?它将传入的中间件函数,推入实例的中间件函数数组中,那么此时,中间件函数数组有两个元素:

    //只是一个举例
    [middleware#1, middleware#2]
    复制代码
  2. 接下来,调用了实例的 listen 方法,传入端口号,启动服务, listen 方法:

    listen(...args) {
         const server = http.createServer(this.callback());
         return server.listen(...args);
     }
    复制代码

    执行 http.createServer(this.callback()) ,进而调用 callback 方法,返回了一个函数,这个函数就是 http.createServer(...) 的入参,可以看作是:

    fn = ...
     
     listen(...args) {
         //用callback函数的回调做了替换
         const server = http.createServer((req, res) => {
            const ctx = this.createContext(req, res);
            return this.handleRequest(ctx, fn);
         });
         return server.listen(...args);
     }
    复制代码

    于是开始监听3000端口。

  3. 说时迟那时快,这时一个请求在3000端口被捕捉到,马上开始调用 http.createServer(...) 的入参函数。

    首先执行第一行代码 const ctx = this.createContext(req, res); ,创建了上下文对象。

    紧接着执行第二行代码 return this.handleRequest(ctx, fn); 调用了 handleRequest 方法,我们回顾一下 handleRequest 方法的逻辑:

    handleRequest(ctx, fnMiddleware) {
         const res = ctx.res;
         res.statusCode = 404;
         const onerror = err => ctx.onerror(err);
         const handleResponse = () => respond(ctx);
         onFinished(res, onerror);
         return fnMiddleware(ctx).then(handleResponse).catch(onerror);
     }
    复制代码

    关注点在最后一行代码,它调用了 fnMiddleware(ctx) ,它是在 callback 函数里传入的,对应到 callback 函数里,就是 fn 函数:

    //在callback函数里,fn为compose函数的返回值
    const fn = compose(this.middleware);
    
    //实际上,fn为
    const fn = function (context, next, 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, dispatch.bind(null, i + 1)));
        } catch (err) {
          return Promise.reject(err);
        }
      }
    }
    复制代码

    那么 fnMiddleware(ctx).then(handleResponse).catch(onerror); ,可以理解为:

    //以下替换仅示意
    //用callback的fn做替换
    fn.then(handleResponse).catch(onerror);
    //fn又用返回的dispatch做替换
    dispatch(0).then(handleResponse).catch(onerror);
    复制代码
  4. 接下来就是:

    //开始执行
    dispatch(0)
    //执行到如下代码,便开始执行第一个中间件函数
    fn(context, dispatch.bind(null, i + 1))
    //也就是
    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
    //当第一中间件函数调用
    await next()
    //就相当于调用
    await dispatch(1)
    //然后开始重复逻辑,不出意外,第二个中间件函数调用
    await next()
    //就相当于调用
    await dispatch(2)
    //在dispatch(2)中
    if (i === middleware.length) fn = next;
    //生效,fn = null,触发下一句
    if (!fn) return Promise.resolve();
    //返回之后,第二个中间件函数结束等待,继续await语句以后的代码执行,
    //执行完毕,返回执行第一个中间件函数await语句以后的代码,
    //所以,控制台会打印
    1 3 4 2
    复制代码

    最后,如果中间件函数的执行都没有报错,就会执行 then 方法中的回调函数,返回响应给客户端,不然就会执行 catch 方法的回调函数,处理异常。如此一来,一个请求响应的流程就结束了。

接下来,我们分析剩下的3个模块,他们的功能都比较简单,笔者会快速介绍一遍,同样会做一些简化。

request.js

这个模块的功能比较简单,它主要就是对 Node 原生 req 对象做了一个代理,同时也通过对 req 的二次处理,暴露一些常用的其他属性:

const url = require('url');

const request = {
  get url() {
    return this.req.url;
  },
  get path() {
    return url.parse(this.req.url).pathname;
  }
//...
}

module.exports = request;
复制代码

当然这里省略了很多。可以看到,如果我们调用 ctx.request.url ,会触发一个 getter 函数,返回的其实就是 Node 原生 req 对象上的 url 属性。

response.js

这个模块跟 request.js 的作用其实类似,也是对 Node 原生 res 对象做了一个代理,暴露一些其他的属性:

const response = {
  get body() {
    return this._body;
  },
  set body(value) {
    this.res.statusCode = 200;
    this._body = value;
  }
//...
};

module.exports = response;
复制代码

这里也省略了很多。当我们给 ctx.response.body 赋值的时候,触发 setter 函数,将状态码设置为 200 ,为一个中间值 _body 赋值,要读取这个属性时,就返回这个值。

context.js

这个模块要稍微比上面介绍的两个模块的功能复杂一些,我们知道它主要是生成了上下文对象 ctx

想象一下,我们获得请求的 url 一般要怎么取得这个值?我们可以通过 req.url 直接通过原生对象取,或者我们刚刚学过 request.js 模块,可以通过 ctx.request.url 取。不过,在这个模块中,给上下文对象定义了相当多的 settergetter 函数,以实现类似直接通过 ctx.url 来简化关键属性的获得:

const proto = {}

//这里相当于,给proto绑定2个属性,当读取这2个属性时,会触发相应的函数,函数会返回值
function defineGetter(prop, name) {
  proto.__defineGetter__(name, function() {
    return this[prop][name];
  })
}
function defineSetter(prop, name) {
  proto.__defineSetter__(name, function(val) {
    this[prop][name] = val;
  })
}
//也就是读取proto.url时会返回proto.request.url,当然,正式使用中ctx继承了proto
defineGetter('request', 'url');
defineGetter('request', 'path');
defineGetter('response', 'body');
defineSetter('response', 'body');

module.exports = proto;
复制代码

有可能会有朋友疑惑,类似:

get url() {
  return this.req.url;
},
复制代码

是如何通过 this 取得原生对象 req 的属性的,这就要回到 application 模块的 createContext 函数了,还记得那一大串赋值语句吗:

createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    return context;
}
复制代码

就是在这里,实现了触发 getter 函数之后,通过 this 可以找到 Node 原生对象的。

结语:

不知道大家有没有大致理解 Koa 的工作流程,笔者写的过程中发现要拿捏侧重点是一件比较困难的事情,再加上笔者本身水平所限,可能没有办法给大家分析的比较清晰。如果你耐心看完了本文,有宝贵的建议可以提出来,让笔者可以做的更好。

参考:

  1. Koa
  2. node进阶——之事无巨细手写koa源码
我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章