koa2核心原始碼淺析

samzher發表於2019-02-13

koa是一個輕量級的web應用框架。其實現非常精簡和優雅,核心程式碼僅有區區一百多行,非常值得我們去細細品味和學習。

在開始分析原始碼之前先上demo~

DEMO 1

const Koa = require('../lib/application');
const app = new Koa();

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

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

app.use(async (ctx, next) => {
  console.log('m3-1');
  ctx.body = 'there is a koa web app';
  await next();
  console.log('m3-2');
});

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

上面程式碼最終會在控制檯依次輸出

m1-1
m2-1
m3-1
m3-2
m2-2
m1-2
複製程式碼

當在中介軟體中呼叫next()時,會停止當前中介軟體的執行,轉而進行下一個中介軟體。當下一箇中介軟體執行完後,才會繼續執行next()後面的邏輯。

DEMO 2

我們改一下第一個中介軟體的程式碼,如下所示:

app.use(async (ctx, next) => {
  console.log('m1-1');
  // await next();
  console.log('m1-2');
});
複製程式碼

當把第一個中介軟體的await next()註釋後,再次執行,在控制檯的輸出如下:

m1-1
m2-1
複製程式碼

顯然,如果不執行next()方法,程式碼將只會執行到當前的中介軟體,不過後面還有多少箇中介軟體,都不會執行。

這個next為何會具有這樣的魔力呢,下面讓我們開始愉快地分析koa的原始碼,一探究竟~

程式碼結構

分析原始碼之前我們先來看一下koa的目錄結構,koa的實現檔案只有4個,這4個檔案都在lib目錄中。

koa2核心原始碼淺析

  • application.js — 定義了一個類,這個類定義了koa例項的方法和屬性
  • context.js — 定義了一個proto物件,並對proto中的屬性進行代理。中介軟體中使用的ctx物件,其實就是繼承自proto
  • request.js — 定義了一個物件,該物件基於原生的req擴充了一些屬性和方法
  • response.js - 定義了一個物件,該物件基於原生的res擴充了一些屬性和方法

通過package.json檔案得知,koa的入口檔案是lib/application.js,我們先來看一下這個檔案做了什麼。

定義koa類

開啟application.js檢視原始碼可以發現,這個檔案主要就是定義了一個類,同時定義了一些方法。

module.exports = class Application extends Emitter {

  constructor() {
    super();
    this.middleware = []; // 中介軟體陣列
  }
  
  listen (...args) {
    // 啟用一個http server並監聽指定埠
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  
  use (fn) {
    // 把中間新增到中介軟體陣列
    this.middleware.push(fn);
    return this;
  }
  
}
複製程式碼

我們建立完一個koa物件之後,通常只會使用兩個方法,一個是listen,一個是use。listen負責啟動一個http server並監聽指定埠,use用來新增我們的中介軟體。

當呼叫listen方法時,會建立一個http server,這個http server需要一個回撥函式,當有請求過來時執行。上面程式碼中的this.callback()就是用來返回這樣的一個函式:這個函式會讀取應用所有的中介軟體,使它們按照傳入的順序依次執行,最後響應請求並返回結果。

callback方法的核心程式碼如下:

  callback() {
    const fn = compose(this.middleware);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }
複製程式碼

回撥函式callback的執行流程

callback函式會在應用啟動時執行一次,並且返回一個函式handleRequest。每當有請求過來時,handleRequest都會被呼叫。我們將callback拆分為三個流程去分析:

  1. 把應用的所有中介軟體合併成一個函式fn,在fn函式內部會依次執行this.middleware中的中介軟體(是否全部執行,取決於是否有呼叫next函式執行下一個中介軟體)
  2. 通過createContext生成一個可供中介軟體使用的ctx上下文物件
  3. 把ctx傳給fn,並執行,最後對結果作出響應

koa中介軟體執行原理

const fn = compose(this.middleware);
複製程式碼

原始碼中使用了一個compose函式,基於所有可執行的中介軟體生成了一個可執行函式。當該函式執行時,每一箇中介軟體將會被依次應用。compose函式的定義如下:

function compose (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!')
  }

  return function (context, next) {
    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 //個人認為對在koa中這裡的fn = next並沒有意義
      if (!fn) return Promise.resolve() // 執行到最後resolve出來
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
複製程式碼

它會先執行第一個中介軟體,執行過程中如果遇到next()呼叫,就會把控制權交到下一個中介軟體並執行,等該中介軟體執行完後,再繼續執行next()之後的程式碼。這裡的dispatch.bind(null, i + 1)就是next函式。到這裡就能解答,為什麼必須要呼叫next方法,才能讓當前中介軟體後面的中介軟體執行。(有點拗口…)匿名函式的返回結果是一個Promise,因為要等到中介軟體處理完之後,才能進行響應。

context模組分析

中介軟體執行函式生成好之後,接下來需要建立一個ctx。這個ctx可以在中介軟體裡面使用。ctx提供了訪問reqres的介面。 建立上下文物件呼叫了一個createContext函式,這個函式的定義如下:

/**
 * 建立一個context物件,也就是在中介軟體裡使用的ctx,並給ctx新增request, respone屬性
 */
  createContext(req, res) {
    const context = Object.create(this.context); // 繼承自context.js中export出來proto
    const request = context.request = Object.create(this.request); // 把自定義的request作為ctx的屬性
    const response = context.response = Object.create(this.response);// 把自定義的response作為ctx的屬性
    context.app = request.app = response.app = this;
    // 為了在ctx, request, response中,都能使用httpServer回撥函式中的req和res
    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.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }
複製程式碼

ctx物件實際上是繼承自context模組中定義的proto物件,同時新增了requestresponse兩個屬性。requestresponse也是物件,分別繼承自request.jsresponse.js定義的物件。這兩個模組的功能是基於原生的reqres封裝了一些gettersetter,原理比較簡單,下面就不再分析了。

我們重點來看看context模組。

const proto = module.exports = {

  inspect() {
    if (this === proto) return this;
    return this.toJSON();
  },
  
  toJSON() {
    return {
      request: this.request.toJSON(),
      response: this.response.toJSON(),
      app: this.app.toJSON(),
      originalUrl: this.originalUrl,
      req: '<original node req>',
      res: '<original node res>',
      socket: '<original node socket>'
    };
  },

  assert: httpAssert,

  throw(...args) {
    throw createError(...args);
  },
  
  onerror(err) {
    if (null == err) return;
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
    let headerSent = false;
    if (this.headerSent || !this.writable) {
      headerSent = err.headerSent = true;
    }
    // delegate
    this.app.emit('error', err, this);
    if (headerSent) {
      return;
    }
    const { res } = this;
    // first unset all headers
    /* istanbul ignore else */
    if (typeof res.getHeaderNames === 'function') {
      res.getHeaderNames().forEach(name => res.removeHeader(name));
    } else {
      res._headers = {}; // Node < 7.7
    }

    // then set those specified
    this.set(err.headers);

    // force text/plain
    this.type = 'text';

    // ENOENT support
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    this.res.end(msg);
  },

  get cookies() {
    if (!this[COOKIES]) {
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        secure: this.request.secure
      });
    }
    return this[COOKIES];
  },

  set cookies(_cookies) {
    this[COOKIES] = _cookies;
  }
};
複製程式碼

context模組定義了一個proto物件,該物件定義了一些方法(eg: throw)和屬性(eg: cookies)。我們上面通過createContext函式建立的ctx物件,就是繼承自proto。因此,我們可以在中介軟體中直接通過ctx訪問proto中定義的方法和屬性。

值得一提的點是,作者通過代理的方式,讓開發者可以直接通過ctx[propertyName]去訪問ctx.requestctx.response上的屬性和方法。

實現代理的關鍵邏輯

/**
 * 代理response一些屬性和方法
 * eg: proto.response.body => proto.body
 */
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .access('body')
  .access('length')
  // other properties or methods
  
/**
 * 代理request的一些屬性和方法
 */
delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  // other properties or methods
複製程式碼

實現代理的邏輯也非常簡單,主要就是使用了__defineGetter____defineSetter__這兩個物件方法,當setget物件的某個屬性時,呼叫指定的函式對屬性值進行處理或返回。

最終的請求與響應

ctx(上下文物件)和fn(執行中介軟體的合成函式)都準備好之後,就能真正的處理請求並響應了。該步驟呼叫了一個handleRequest函式。

  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404; // 狀態碼預設404
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    // 執行完中介軟體函式後,執行handleResponse處理結果
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
複製程式碼

handleRequest函式會把ctx傳入fnMiddleware並執行,然後通過respond方法進行響應。這裡預設把狀態碼設為了404,如果在執行中介軟體的過程中有返回,例如對ctx.body進行負責,koa會自動把狀態碼設成200,這一部分的邏輯是在response物件的body屬性的setter處理的,有興趣的朋友可以看一下response.js

respond函式會對ctx物件上的body或者其他屬性進行分析,然後通過原生的res.end()方法將不同的結果輸出。

最後

到這裡,koa2的核心程式碼大概就分析完啦。以上是我個人總結,如有錯誤,請見諒。歡迎一起交流學習!

相關文章