zoukankan      html  css  js  c++  java
  • 手写Koa.js源码

    Node.js写一个web服务器,我前面已经写过两篇文章了:

    Express的源码还是比较复杂的,自带了路由处理和静态资源支持等等功能,功能比较全面。与之相比,本文要讲的Koa就简洁多了,Koa虽然是Express的原班人马写的,但是设计思路却不一样。Express更多是偏向All in one的思想,各种功能都集成在一起,而Koa本身的库只有一个中间件内核,其他像路由处理和静态资源这些功能都没有,全部需要引入第三方中间件库才能实现。下面这张图可以直观的看到Expresskoa在功能上的区别,此图来自于官方文档

    image-20201029144409936

    基于Koa的这种架构,我计划会分几篇文章来写,全部都是源码解析:

    • Koa的核心架构会写一篇文章,也就是本文。
    • 对于一个web服务器来说,路由是必不可少的,所以@koa/router会写一篇文章。
    • 另外可能会写一些常用中间件,静态文件支持或者bodyparser等等,具体还没定,可能会有一篇或多篇文章。

    本文可运行迷你版Koa代码已经上传GitHub,拿下来,一边玩代码一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore

    简单示例

    我写源码解析,一般都遵循一个简单的套路:先引入库,写一个简单的例子,然后自己手写源码来替代这个库,并让我们的例子顺利运行。本文也是遵循这个套路,由于Koa的核心库只有中间件,所以我们写出的例子也比较简单,也只有中间件。

    Hello World

    第一个例子是Hello World,随便请求一个路径都返回Hello World

    const Koa = require("koa");
    const app = new Koa();
    
    app.use((ctx) => {
      ctx.body = "Hello World";
    });
    
    const port = 3001;
    app.listen(port, () => {
      console.log(`Server is running on http://127.0.0.1:${port}/`);
    });
    

    logger

    然后再来一个logger吧,就是记录下处理当前请求花了多长时间:

    app.use(async (ctx, next) => {
      const start = Date.now();
      await next();
      const ms = Date.now() - start;
      console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
    });
    

    注意这个中间件应该放到Hello World的前面。

    从上面两个例子的代码来看,KoaExpress有几个明显的区别:

    • ctx替代了reqres
    • 可以使用JS的新API了,比如asyncawait

    手写源码

    手写源码前我们看看用到了哪些API,这些就是我们手写的目标:

    • new Koa():首先肯定是Koa这个类了,因为他使用new进行实例化,所以我们认为他是一个类。
    • app.useappKoa的一个实例,app.use看起来是一个添加中间件的实例方法。
    • app.listen:启动服务器的实例方法
    • ctx:这个是Koa的上下文,看起来替代了以前的reqres
    • asyncawait:支持新的语法,而且能使用await next(),说明next()返回的很可能是一个promise

    本文的手写源码全部参照官方源码写成,文件名和函数名尽量保持一致,写到具体的方法时我也会贴上官方源码地址。Koa这个库代码并不多,主要都在这个文件夹里面:https://github.com/koajs/koa/tree/master/lib,下面我们开始吧。

    Koa类

    Koa项目的package.json里面的main这行代码可以看出,整个应用的入口是lib/application.js这个文件:

    "main": "lib/application.js",
    

    lib/application.js这个文件就是我们经常用的Koa类,虽然我们经常叫他Koa类,但是在源码里面这个类叫做Application。我们先来写一下这个类的壳吧:

    // application.js
    
    const Emitter = require("events");
    
    // module.exports 直接导出Application类
    module.exports = class Application extends Emitter {
      // 构造函数先运行下父类的构造函数
      // 再进行一些初始化工作
      constructor() {
        super();
    
        // middleware实例属性初始化为一个空数组,用来存储后续可能的中间件
        this.middleware = [];
      }
    };
    

    这段代码我们可以看出,Koa直接使用class关键字来申明类了,看过我之前Express源码解析的朋友可能还有印象,Express源码里面还是使用的老的prototype来实现面向对象的。所以Koa项目介绍里面的Expressive middleware for node.js using ES2017 async functions并不是一句虚言,它不仅支持ES2017新的API,而且在自己的源码里面里面也是用的新API。我想这也是Koa要求运行环境必须是node v7.6.0 or higher的原因吧。所以到这里我们其实已经可以看出KoaExpress的一个重大区别了,那就是:Express使用老的API,兼容性更强,可以在老的Node.js版本上运行;Koa因为使用了新API,只能在v7.6.0或者更高版本上运行了。

    这段代码还有个点需要注意,那就是Application继承自Node.js原生的EventEmitter类,这个类其实就是一个发布订阅模式,可以订阅和发布消息,我在另一篇文章里面详细讲过他的源码。所以他有些方法如果在application.js里面找不到,那可能就是继承自EventEmitter,比如下图这行代码:

    image-20201029151525287

    这里有this.on这个方法,看起来他应该是Application的一个实例方法,但是这个文件里面没有,其实他就是继承自EventEmitter,是用来给error这个事件添加回调函数的。这行代码if里面的this.listenerCount也是EventEmitter的一个实例方法。

    Application类完全是JS面向对象的运用,如果你对JS面向对象还不是很熟悉,可以先看看这篇文章:https://juejin.im/post/6844904069887164423

    app.use

    从我们前面的使用示例可以看出app.use的作用就是添加一个中间件,我们在构造函数里面也初始化了一个变量middleware,用来存储中间件,所以app.use的代码就很简单了,将接收到的中间件塞到这个数组就行:

    use(fn) {
      // 中间件必须是一个函数,不然就报错
      if (typeof fn !== "function")
        throw new TypeError("middleware must be a function!");
    
      // 处理逻辑很简单,将接收到的中间件塞入到middleware数组就行
      this.middleware.push(fn);
      return this;
    }
    

    注意app.use方法最后返回了this,这个有点意思,为什么要返回this呢?这个其实我之前在其他文章讲过的:类的实例方法返回this可以实现链式调用。比如这里的app.use就可以连续点点点了,像这样:

    app.use(middlewaer1).use(middlewaer2).use(middlewaer3)
    

    为什么会有这种效果呢?因为这里的this其实就是当前实例,也就是app,所以app.use()的返回值就是appapp上有个实例方法use,所以可以继续点app.use().use()

    app.use的官方源码看这里: https://github.com/koajs/koa/blob/master/lib/application.js#L122

    app.listen

    在前面的示例中,app.listen的作用是用来启动服务器,看过前面用原生API实现web服务器的朋友都知道,要启动服务器需要调用原生的http.createServer,所以这个方法就是用来调用http.createServer的。

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

    这个方法本身其实没有太多可说的,只是调用http模块启动服务而已,主要的逻辑都在this.callback()里面了。

    app.listen的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L79

    app.callback

    this.callback()是传给http.createServer的回调函数,也是一个实例函数,这个函数必须符合http.createServer的参数形式,也就是

    http.createServer(function(req, res){})
    

    所以this.callback()的返回值必须是一个函数,而且是这种形式function(req, res){}

    除了形式必须符合外,this.callback()具体要干什么呢?他是http模块的回调函数,所以他必须处理所有的网络请求,所有处理逻辑都必须在这个方法里面。但是Koa的处理逻辑是以中间件的形式存在的,对于一个请求来说,他必须一个一个的穿过所有的中间件,具体穿过的逻辑,你当然可以遍历middleware这个数组,将里面的方法一个一个拿出来处理,当然也可以用业界更常用的方法:compose

    compose一般来说就是将一系列方法合并成一个方法来方便调用,具体实现的形式并不是固定的,有面试中常见的用reduce实现的compose,也有像Koa这样根据自己需求单独实现的composeKoacompose也单独封装了一个库koa-compose,这个库源码也是我们必须要看的,我们一步一步来,先把this.callback写出来吧。

    callback() {
      // compose来自koa-compose库,就是将中间件合并成一个函数
      // 我们需要自己实现
      const fn = compose(this.middleware);
    
      // callback返回值必须符合http.createServer参数形式
      // 即 (req, res) => {}
      const handleRequest = (req, res) => {
        const ctx = this.createContext(req, res);
        return this.handleRequest(ctx, fn);
      };
    
      return handleRequest;
    }
    

    这个方法先用koa-compose将中间件都合成了一个函数fn,然后在http.createServer的回调里面使用reqres创建了一个Koa常用的上下文ctx,然后再调用this.handleRequest来真正处理网络请求。注意这里的this.handleRequest是个实例方法,和当前方法里面的局部变量handleRequest并不是一个东西。这几个方法我们一个一个来看下。

    this.callback对应的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L143

    koa-compose

    koa-compose虽然被作为了一个单独的库,但是他的作用却很关键,所以我们也来看看他的源码吧。koa-compose的作用是将一个中间件组成的数组合并成一个方法以便外部调用。我们先来回顾下一个Koa中间件的结构:

    function middleware(ctx, next) {}
    

    这个数组就是有很多这样的中间件:

    [
      function middleware1(ctx, next) {},
      function middleware2(ctx, next) {}
    ]
    

    Koa的合并思路并不复杂,就是让compose再返回一个函数,返回的这个函数会开始这个数组的遍历工作:

    function compose(middleware) {
      // 参数检查,middleware必须是一个数组
      if (!Array.isArray(middleware))
        throw new TypeError("Middleware stack must be an array!");
      // 数组里面的每一项都必须是一个方法
      for (const fn of middleware) {
        if (typeof fn !== "function")
          throw new TypeError("Middleware must be composed of functions!");
      }
    
      // 返回一个方法,这个方法就是compose的结果
      // 外部可以通过调用这个方法来开起中间件数组的遍历
      // 参数形式和普通中间件一样,都是context和next
      return function (context, next) {
        return dispatch(0); // 开始中间件执行,从数组第一个开始
    
        // 执行中间件的方法
        function dispatch(i) {
          let fn = middleware[i]; // 取出需要执行的中间件
    
          // 如果i等于数组长度,说明数组已经执行完了
          if (i === middleware.length) {
            fn = next; // 这里让fn等于外部传进来的next,其实是进行收尾工作,比如返回404
          }
    
          // 如果外部没有传收尾的next,直接就resolve
          if (!fn) {
            return Promise.resolve();
          }
    
          // 执行中间件,注意传给中间件接收的参数应该是context和next
          // 传给中间件的next是dispatch.bind(null, i + 1)
          // 所以中间件里面调用next的时候其实调用的是dispatch(i + 1),也就是执行下一个中间件
          try {
            return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
          } catch (err) {
            return Promise.reject(err);
          }
        }
      };
    }
    

    上面代码主要的逻辑就是这行:

    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
    

    这里的fn就是我们自己写的中间件,比如文章开始那个logger,我们稍微改下看得更清楚:

    const logger = async (ctx, next) => {
      const start = Date.now();
      await next();
      const ms = Date.now() - start;
      console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
    };
    
    app.use(logger);
    

    那我们compose里面执行的其实是:

    logger(context, dispatch.bind(null, i + 1));
    

    也就是说logger接收到的next其实是dispatch.bind(null, i + 1),你调用next()的时候,其实调用的是dispatch(i + 1),这样就达到了执行数组下一个中间件的效果。

    另外由于中间件在返回前还包裹了一层Promise.resolve,所以我们所有自己写的中间件,无论你是否用了Promisenext调用后返回的都是一个Promise,所以你可以使用await next()

    koa-compose的源码看这里:https://github.com/koajs/compose/blob/master/index.js

    app.createContext

    上面用到的this.createContext也是一个实例方法。这个方法根据http.createServer传入的reqres来构建ctx这个上下文,官方源码长这样:

    image-20201029163710087

    这段代码里面contextctxresponseresrequestreqapp这几个变量相互赋值,头都看晕了。其实完全没必要陷入这堆面条里面去,我们只需要将他的思路和骨架拎清楚就行,那怎么来拎呢?

    1. 首先搞清楚他这么赋值的目的,他的目的其实很简单,就是为了使用方便。通过一个变量可以很方便的拿到其他变量,比如我现在只有request,但是我想要的是req,怎么办呢?通过这种赋值后,直接用request.req就行。其他的类似,这种面条式的赋值我很难说好还是不好,但是使用时确实很方便,缺点就是看源码时容易陷进去。
    2. requestreq有啥区别?这两个变量长得这么像,到底是干啥的?这就要说到Koa对于原生req的扩展,我们知道http.createServer的回调里面会传入req作为请求对象的描述,里面可以拿到请求的header啊,method啊这些变量。但是Koa觉得这个req提供的API不好用,所以他在这个基础上扩展了一些API,其实就是一些语法糖,扩展后的req就变成了request。之所以扩展后还保留的原始的req,应该也是想为用户提供更多选择吧。所以这两个变量的区别就是requestKoa包装过的reqreq是原生的请求对象。responseres也是类似的。
    3. 既然requestresponse都只是包装过的语法糖,那其实Koa没有这两个变量也能跑起来。所以我们拎骨架的时候完全可以将这两个变量踢出去,这下骨架就清晰了。

    那我们踢出responserequest后再来写下createContext这个方法:

    // 创建上下文ctx对象的函数
    createContext(req, res) {
      const context = Object.create(this.context);
      context.app = this;
      context.req = req;
      context.res = res;
    
      return context;
    }
    

    这下整个世界感觉都清爽了,context上的东西也一目了然了。但是我们的context最初是来自this.context的,这个变量还必须看下。

    app.createContext对应的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L177

    context.js

    上面的this.context其实就是来自context.js,所以我们先在Application构造函数里面添加这个变量:

    // application.js
    
    const context = require("./context");
    
    // 构造函数里面
    constructor() {
    	// 省略其他代码
      this.context = context;
    }
    

    然后再来看看context.js里面有啥,context.js的结构大概是这个样子:

    const delegate = require("delegates");
    
    module.exports = {
      inspect() {},
      toJSON() {},
      throw() {},
      onerror() {},
    };
    
    const proto = module.exports;
    
    delegate(proto, "response")
      .method("set")
      .method("append")
      .access("message")
      .access("body");
    
    delegate(proto, "request")
      .method("acceptsLanguages")
      .method("accepts")
      .access("querystring")
      .access("socket");
    

    这段代码里面context导出的是一个对象proto,这个对象本身有一些方法,inspecttoJSON之类的。然后还有一堆delegate().method()delegate().access()之类的。嗯,这个是干啥的呢?要知道这个的作用,我们需要去看delegates这个库:https://github.com/tj/node-delegates,这个库也是tj大神写的。一般使用是这样的:

    delegate(proto, target).method("set");
    

    这行代码的作用是,当你调用proto.set()方法时,其实是转发给了proto[target],实际调用的是proto[target].set()。所以就是proto代理了对target的访问。

    那用在我们context.js里面是啥意思呢?比如这行代码:

    delegate(proto, "response")
      .method("set");
    

    这行代码的作用是,当你调用proto.set()时,实际去调用proto.response.set(),将proto换成ctx就是:当你调用ctx.set()时,实际调用的是ctx.response.set()。这么做的目的其实也是为了使用方便,可以少写一个response。而且ctx不仅仅代理response,还代理了request,所以你还可以通过ctx.accepts()这样来调用到ctx.request.accepts()。一个ctx就囊括了responserequest,所以这里的context也是一个语法糖。因为我们前面已经踢了responserequest这两个语法糖,context作为包装了这两个语法糖的语法糖,我们也一起踢掉吧。在Application的构造函数里面直接将this.context赋值为空对象:

    // application.js
    constructor() {
    	// 省略其他代码
      this.context = {};
    }
    

    现在语法糖都踢掉了,整个Koa的结构就更清晰了,ctx上面也只有几个必须的变量:

    ctx = {
      app,
      req,
      res
    }
    

    context.js对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/context.js

    app.handleRequest

    现在我们ctxfn都构造好了,那我们处理请求其实就是调用fnctx是作为参数传给他的,所以app.handleRequest代码就可以写出来了:

    // 处理具体请求
    handleRequest(ctx, fnMiddleware) {
      const handleResponse = () => respond(ctx);
    
      // 调用中间件处理
      // 所有处理完后就调用handleResponse返回请求
      return fnMiddleware(ctx)
        .then(handleResponse)
        .catch((err) => {
        console.log("Somethis is wrong: ", err);
      });
    }
    

    我们看到compose库返回的fn虽然支持第二个参数用来收尾,但是Koa并没有用他,如果不传的话,所有中间件执行完返回的就是一个空的promise,所以可以用then接着他后面处理。后面要进行的处理就只有一个了,就是将处理结果返回给请求者的,这也就是respond需要做的。

    app.handleRequest对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L162

    respond

    respond是一个辅助方法,并不在Application类里面,他要做的就是将网络请求返回:

    function respond(ctx) {
      const res = ctx.res; // 取出res对象
      const body = ctx.body; // 取出body
    
      return res.end(body); // 用res返回body
    }
    

    大功告成

    现在我们可以用自己写的Koa替换官方的Koa来运行我们开头的例子了,不过logger这个中间件运行的时候会有点问题,因为他下面这行代码用到了语法糖:

    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
    

    这里的ctx.methodctx.url在我们构建的ctx上并不存在,不过没关系,他不就是个req的语法糖嘛,我们从ctx.req上拿就行,所以上面这行代码改为:

    console.log(`${ctx.req.method} ${ctx.req.url} - ${ms}ms`);
    

    总结

    通过一层一层的抽丝剥茧,我们成功拎出了Koa的代码骨架,自己写了一个迷你版的Koa

    这个迷你版代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore

    最后我们再来总结下本文的要点吧:

    1. KoaExpress原班人马写的一个新框架。
    2. Koa使用了JS的新API,比如asyncawait
    3. Koa的架构和Express有很大区别。
    4. Express的思路是大而全,内置了很多功能,比如路由,静态资源等,而且Express的中间件也是使用路由同样的机制实现的,整个代码更复杂。Express源码可以看我之前这篇文章:手写Express.js源码
    5. Koa的思路看起来更清晰,Koa本身的库只是一个内核,只有中间件功能,来的请求会依次经过每一个中间件,然后再出来返回给请求者,这就是大家经常听说的“洋葱模型”。
    6. 想要Koa支持其他功能,必须手动添加中间件。作为一个web服务器,路由可以算是基本功能了,所以下一遍文章我们会来看看Koa官方的路由库@koa/router,敬请关注。

    参考资料

    Koa官方文档:https://github.com/koajs/koa

    Koa源码地址:https://github.com/koajs/koa/tree/master/lib

    文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

    作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

    作者掘金文章汇总:https://juejin.im/post/5e3ffc85518825494e2772fd

    我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~

  • 相关阅读:
    052 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 14 Eclipse下程序调试——debug2 多断点调试程序
    051 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 13 Eclipse下程序调试——debug入门1
    050 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 12 continue语句
    049 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 11 break语句
    048 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 10 案例——阶乘的累加和
    047 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 09 嵌套while循环应用
    046 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 08 for循环的注意事项
    045 01 Android 零基础入门 01 Java基础语法 05 Java流程控制之循环结构 07 for循环应用及局部变量作用范围
    剑指OFFER----面试题04.二维数组中的查找
    剑指OFFER----面试题03. 数组中重复的数字
  • 原文地址:https://www.cnblogs.com/dennisj/p/13947650.html
Copyright © 2011-2022 走看看