5分鐘實現一個Koa

GeoffZhu虛筆發表於2018-06-03

原文地址

週五組內同學討論搞一些好玩的東西,有人提到了類似『5分鐘實現koa』,『100行實現react』的創意,仔細想了以後,5分鐘實現koa並非不能實現,遂有了這篇部落格。

準備

先開啟koa官網,隨意找出了一個代表koa核心功能的的demo就可以,如下

const Koa = require('koa');
const app = new Koa();

// x-response-time
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

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

// response
app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);
複製程式碼

最終要實現的效果是實現的一個5min-koa模組,直接將程式碼中第一行替換為const Koa = require('./5min-koa');,程式可以正常執行就可以了。

Koa的核心

通過koa官網得知,app.listen方法實際上是如下程式碼的簡寫

const http = require('http');
const Koa = require('koa');
const app = new Koa();
http.createServer(app.callback()).listen(3000);
複製程式碼

所以我們可以先把app.listen實現出來

class Koa {
  constructor() {}
  callback() {
    return (req, res) => {
      // TODO
    }
  }
  listen(port) {
    http.createServer(this.callback()).listen(port);
  }
}
複製程式碼

koa的核心分為四部分,分別是

  • context 上下文
  • middleware 中介軟體
  • request 請求
  • responce 響應

Context

我們先來實現一個最簡化版的context,如下

class Context {
  constructor(app, req, res) {
    this.app = app
    this.req = req
    this.res = res
    // 為了儘可能縮短實現時間,我們直接使用原生的res和req,沒有實現ctx上的ctx.request ctx.response
    // ctx.request ctx.response只是在原生res和req上包裝處理了一層
  }
  // 實現一些demo中使用到的ctx上代理的方法
  get set() { return this.res.setHeader }
  get method() { return this.req.method }
  get url() { return this.req.url }
}
複製程式碼

這樣就完成了一個最基本的Context,別看小,已經夠用了。 每一次有新的請求,都會建立一個新的ctx物件。

Middleware

koa的中介軟體是一個非同步函式,接受兩個引數,分別是ctx和next,其中ctx是當前的請求上下文,next是下一個中介軟體(也是非同步函式),這樣想來,我們需要一個維護中介軟體的陣列,每次呼叫app.use就是往陣列中push一個一步函式。所以use方法實現如下

use(middleware) {
  this.middlewares.push(middleware)
}
複製程式碼

每次有新的請求,我們都需要把這次請求的上下文灌進陣列中的每一箇中介軟體裡。單單灌進ctx還不夠,還要使每個中介軟體都能通過next函式呼叫到下一個中介軟體。當我們呼叫next函式時,一般是不需要傳引數的,而被呼叫的中介軟體中一定會接收到ctx和next兩個引數。

呼叫方不需要傳參,被呼叫方卻能接到引數,這讓我立刻想到bind方法,只要將每一箇中介軟體所需要的ctx和next都提前繫結好,問題就解決了。下面的程式碼就是通過bind方法,將使用者傳入的middleware列表轉換成next函式列表

let bindedMiddleware = []

for (let i = middlewares.length - 1; i >= 0; i--) {
  if (middlewares.length == i + 1) {
    // 最後一箇中介軟體,next方法設定為Promise.resolve
    bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, Promise.resolve))
  } else {
    bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, bindedMiddleware[0]))
  }
}
複製程式碼

最後我們就得到了一個next函式陣列,也就是bindedMiddleware這個變數了。

Request

http.createServer中的回撥函式,每次接收到請求的時候會被呼叫,所以我們在上面callback方法的TODO位置,編寫處理請求的程式碼, 並將上面的middleware列表轉next函式列表的程式碼放入其中。

function handleRequest(ctx, middlewares) {
  if (middlewares && middlewares.length > 0) {
    let bindedMiddleware = []
    for (let i = middlewares.length - 1; i >= 0; i--) {
      if (middlewares.length == i + 1) {
        bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, Promise.resolve))
      } else {
        bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, bindedMiddleware[0]))
      }
    }
    return bindedMiddleware[0]()
  } else {
    return Promise.resolve()
  }
}
複製程式碼

Responce

我們簡單出來下相應就好了,直接將ctx.body傳送給客戶端。

function handleResponse (ctx) {
  return function() {
    ctx.res.writeHead(200, { 'Content-Type': 'text/plain' });
    ctx.res.end(ctx.body);
  }
}
複製程式碼

完成Koa類的實現

koa的app例項上面帶有on,emit等方法,這是node events模組實現好的東西。直接讓Koa類繼承自events模組就好了。 我們再將上面實現出來的handleRequest和handleResponse方法放入koa類的callback方法中,得到最終我們實現的Koa,一共58行程式碼,如下

const http = require('http');
const Emitter = require('events');

class Context {
  constructor(app, req, res) {
    this.app = app;
    this.req = req;
    this.res = res;
  }
  get set() { return this.res.setHeader }
  get method() { return this.req.method }
  get url() { return this.req.url }
}

class Koa extends Emitter{
  constructor(options) {
    super();
    this.options = options
    this.middlewares = [];
  }
  use(middleware) {
    this.middlewares.push(middleware);
  }
  callback() {
    return (req, res) => {
      let ctx = new Context(this, req, res);
      handleRequest(ctx, this.middlewares).then(handleResponse(ctx));
    }
  }
  listen(port) {
    http.createServer(this.callback()).listen(port);
  }
}

function handleRequest(ctx, middlewares) {
  if (middlewares && middlewares.length > 0) {
    let bindedMiddleware = [];
    for (let i = middlewares.length - 1; i >= 0; i--) {
      if (middlewares.length == i + 1) {
        bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, Promise.resolve));
      } else {
        bindedMiddleware.unshift(middlewares[i].bind(ctx.app, ctx, bindedMiddleware[0]));
      }
    }
    return bindedMiddleware[0]();
  } else {
    return Promise.resolve();
  }
}

function handleResponse (ctx) {
  return function() {
    ctx.res.writeHead(200, { 'Content-Type': 'text/plain' });
    ctx.res.end(ctx.body);
  }
}

module.exports = Koa;
複製程式碼

試試跑一下篇首的Demo,沒什麼問題。

結語

簡版實現,碼糙理不糙,展示出了koa核心的東西,但少了錯誤處理,也完全沒有考慮效能啥的,需要完善的地方還很多很多。

筆者在寫了這個5分鐘koa以後去看了koa原始碼,發現實現思路基本就是這樣,相信經過我的這個5分鐘koa的洗禮,你去看koa原始碼一樣小菜一碟。

Done!

相關文章