从零实现TypeScript版Koa

作者: 人人贷大前端技术中心 | 来源:发表于2019-09-17 00:08 被阅读0次

    这篇文章会讲些什么?

    • 如何从零开始完成一个涵盖Koa核心功能的Node.js类库
    • 从代码层面解释Koa一些代码写法的原因:如中间件为什么必须调用next函数、ctx是怎么来的和一个请求是什么关系

    我们知道Koa类库主要有以下几个重要特性:

    • 支持洋葱圈模型的中间件机制
    • 封装request、response提供context对象,方便http操作
    • 异步函数、中间件的错误处理机制

    第一步:基础Server运行

    目标:完成基础可行新的Koa Server

    • 支持app.listen监听端口启动Server
    • 支持app.use添加类middleware处理函数

    核心代码如下:

    class Koa {
      private middleware: middlewareFn = () => {};
      constructor() {}
      listen(port: number, cb: noop) {
        const server = http.createServer((req, res) => {
          this.middleware(req, res);
        });
        return server.listen(port, cb);
      }
      use(middlewareFn: middlewareFn) {
        this.middleware = middlewareFn;
        return this;
      }
    }
    
    const app = new Koa();
    app.use((req, res) => {
      res.writeHead(200);
      res.end("A request come in");
    });
    app.listen(3000, () => {
      console.log("Server listen on port 3000");
    });
    

    第二步:洋葱圈中间件机制实现

    目标:接下来我们要完善listen和use方法,实现洋葱圈中间件模型

    如下面代码所示,在这一步中我们希望app.use能够支持添加多个中间件,并且中间件是按照洋葱圈(类似深度递归调用)的方式顺序执行

    app.use(async (req, res, next) => {
      console.log("middleware 1 start");
      // 具体原因我们会在下面代码实现详细讲解
      await next();
      console.log("middleware 1 end");
    });
    app.use(async (req, res, next) => {
      console.log("middleware 2 start");
      await next();
      console.log("middleware 2 end");
    });
    app.use(async (req, res, next) => {
      res.writeHead(200);
      res.end("An request come in");
      await next();
    });
    app.listen(3000, () => {
      console.log("Server listen on port 3000");
    });
    

    上述Demo有三个需要我们注意的点:

    • 在中间件中next()函数必须且只能调用一次
    • 调用next函数时必须使用await

    我们会在接下来的代码中逐个分析这些使用方法的原因,下面我们来看一看具体怎么实现这种洋葱圈机制:

    class Koa {
      ...
      use(middlewareFn: middlewareFn) {
        // 1、调用use时,使用数组存贮所有的middleware
        this.middlewares.push(middlewareFn);
        return this;
      }
      listen(port: number, cb: noop) {
        // 2、 通过composeMiddleware将中间件数组转换为串行[洋葱圈]调用的函数,在createServer中回调函数中调用
        // 所以真正的重点就是 composeMiddleware,如果做到的,我们接下来看该函数的实现
        // BTW: 从这里可以看到 fn 是在listen函数被调用之后就生成了,这就意味着我们不能在运行时动态的添加middleware
        const fn = composeMiddleware(this.middlewares);
        const server = http.createServer(async (req, res) => {
          await fn(req, res);
        });
        return server.listen(port, cb);
      }
    }
    
    // 3、洋葱圈模型的核心:
    // 入参:所有收集的中间件
    // 返回:串行调用中间件数组的函数
    function composeMiddleware(middlewares: middlewareFn[]) {
      return (req: IncomingMessage, res: ServerResponse) => {
        let start = -1;
        // dispatch:触发第i个中间件执行
        function dispatch(i: number) {
          // 刚开始可能不理解这里为什么这么判断,可以看完整个函数在来思考这个问题
          // 正常情况下每次调用前 start < i,调用完next() 应该 start === i
          // 如果调用多次next(),第二次及以后调用因为之前已完成start === i赋值,所以会导致 start >= i
          if (i <= start) {
            return Promise.reject(new Error("next() call more than once!"));
          }
          if (i >= middlewares.length) {
            return Promise.resolve();
          }
          start = i;
          const middleware = middlewares[i];
          // 重点来了!!!
          // 取出第i个中间件执行,并将dispatch(i+1)作为next函数传给各下一个中间件
          return middleware(req, res, () => {
            return dispatch(i + 1);
          });
        }
        return dispatch(0);
      };
    }
    

    主要涉及到Promise几个知识点:

    • async 函数返回的是一个Promise对象【所以的中间件都会返回一个promise对象】
    • async 函数内部遇到 await 调用时会暂停执行await函数,等待返回结果后继续向下执行
    • async 函数内部发生错误会导致返回的Promise变为reject状态

    现在我们在回顾之前提出的几个问题:

    1. koa中间件中为什么必须且只能调用一次next函数

       可以看到如果不调用next,就不会触发dispatch(i+1),下一个中间件就没办法触发,造成假死状态最终请求超时
       
       调用多次next则会导致下一个中间件执行多次
      
    2. next() 调用为什么需要加 await

       这也是洋葱圈调用机制的核心,当执行到 await next(),会执行next()【调用下一个中间件】等待返回结果,在接着向下执行
      

    第三步:Context提供

    目标:封装Context,提供request、response的便捷操作方式

    // 1、 定义KoaRequest、KoaResponse、KoaContext
    interface KoaContext {
      request?: KoaRequest;
      response?: KoaResponse;
      body: String | null;
    }
    const context: KoaContext = {
      get body() {
        return this.response!.body;
      },
      set body(body) {
        this.response!.body = body;
      }
    };
    
    function composeMiddleware(middlewares: middlewareFn[]) {
      return (context: KoaContext) => {
        let start = -1;
        function dispatch(i: number) {
          // ..省略其他代码..
          // 2、所有的中间件接受context参数
          middleware(context, () => {
            return dispatch(i + 1);
          });
        }
        return dispatch(0);
      };
    }
    
    class Koa {
      private context: KoaContext = Object.create(context);
      listen(port: number, cb: noop) {
        const fn = composeMiddleware(this.middlewares);
        const server = http.createServer(async (req, res) => {
          // 3、利用req、res创建context对象
          // 这里需要注意:context是创建一个新的对象,而不是直接赋值给this.context
          // 因为context适合请求相关联的,这里也保证了每一个请求都是一个新的context对象
          const context = this.createContext(req, res);
          await fn(context);
          if (context.response && context.response.res) {
            context.response.res.writeHead(200);
            context.response.res.end(context.body);
          }
        });
        return server.listen(port, cb);
      }
      // 4、创建context对象
      createContext(req: IncomingMessage, res: ServerResponse): KoaContext {
        // 为什么要使用Object.create而不是直接赋值?
        // 原因同上需要保证每一次请求request、response、context都是全新的
        const request = Object.create(this.request);
        const response = Object.create(this.response);
        const context = Object.create(this.context);
        request.req = req;
        response.res = res;
        context.request = request;
        context.response = response;
        return context;
      }
    }
    

    第四步:异步函数错误处理机制

    目标:支持通过 app.on("error"),监听错误事件处理异常

    我们回忆下在Koa中如何处理异常,代码可能类似如下:

    app.use(async (context, next) => {
      console.log("middleware 2 start");
      // throw new Error("出错了");
      await next();
      console.log("middleware 2 end");
    });
    
    // koa统一错误处理:监听error事件
    app.on("error", (error, context) => {
      console.error(`请求${context.url}发生了错误`);
    });
    

    从上面的代码可以看到核心在于:

    • Koa实例app需要支持事件触发、事件监听能力
    • 需要我们捕获异步函数异常,并触发error事件

    下面我们看具体代码如何实现:

    // 1、继承EventEmitter,增加事件触发、监听能力
    class Koa extends EventEmitter {
      listen(port: number, cb: noop) {
        const fn = composeMiddleware(this.middlewares);
        const server = http.createServer(async (req, res) => {
          const context = this.createContext(req, res);
          // 2、await调用fn,可以使用try catch捕获异常,触发异常事件
          try {
            await fn(context);
            if (context.response && context.response.res) {
              context.response.res.writeHead(200);
              context.response.res.end(context.body);
            }
          } catch (error) {
            console.error("Server Error");
            // 3、触发error时提供context更多信息,方面日志记录,定位问题
            this.emit("error", error, context);
          }
        });
        return server.listen(port, cb);
      }
    }
    

    总结

    至此我们已经使用TypeScript完成简版Koa类库,支持了

    • 洋葱圈中间件机制
    • Context封装request、response
    • 异步异常错误处理机制

    完整Demo代码可以参考koa2-reference

    更多精彩文章,欢迎大家Star我们的仓库,我们每周都会推出几篇高质量的大前端领域相关文章。

    参考资料

    相关文章

      网友评论

        本文标题:从零实现TypeScript版Koa

        本文链接:https://www.haomeiwen.com/subject/jdfquctx.html