从零实现TypeScript版Koa

这篇文章会讲些什么?

  • 如何从零开始完成一个涵盖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我们的仓库,我们每周都会推出几篇高质量的大前端领域相关文章。

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 本节将结合例子和源码对koa2的中间件机制做一介绍。 什么是中间件? 中间件的本质就是一种在特定场景下使用的函数,...
    空无一码阅读 1,430评论 0 2
  • 参考资料 https://chenshenhai.github.io/koa2-note/note/static/...
    JunChow520阅读 10,475评论 1 8
  • 弄懂js异步 讲异步之前,我们必须掌握一个基础知识-event-loop。 我们知道JavaScript的一大特点...
    DCbryant阅读 2,693评论 0 5
  • 看到标题,也许您会觉得奇怪,redux跟Koa以及Express并不是同一类别的框架,干嘛要拿来做类比。尽管,例如...
    Perkin_阅读 1,712评论 0 4
  • 陆陆续续用了koa和co也算差不多用了大半年了,大部分的场景都是在服务端使用koa来作为restful服务器用,使...
    Sunil阅读 1,523评论 0 3