Koa2 原始碼學習(上)

Coder丶發表於2018-04-25

引言

最近讀了一下Koa2的原始碼;在閱讀Koa2 (2.3.0) 的原始碼的過程中,我的感受是整個程式碼設計精巧,思路清晰,是一個小而精的 nodejs web服務框架。

設計理念

作為web服務框架,都是要圍繞核心服務而展開的。那什麼是核心服務呢?其實就是接收客戶端的一個http的請求,對於這個請求,除了接收以外,還有解析這個請求。所以說會有

HPPT:接收 -> 解析 -> 響應

在響應客戶端的時候,也有很多種方式,比如返回一個html頁面,或者json文字。在解析請求和響應請求的中間,會有一些第三方的中介軟體,比如 日誌、表單解析等等來增強 koa 的服務能力,所以 koa 至少要提供 "請求解析"、"響應資料"、"中介軟體處理" 這三種核心能力的封裝,同時還需要有一個串聯他們執行環境的上下文(context)

  • HTTP
  • 接收
  • 解析
  • 響應
  • 中介軟體
  • 執行上下文

上下文可以理解為是http的請求週期內的作用域環境來託管請求響應和中介軟體,方便他們之間互相訪問。

以上分析是站在單個http請求的角度來看一個web服務能力。那麼站在整個網站,站在整個後端服務的角度來看的話,能夠提供 "請求"、"響應"、"解析"、"中介軟體"、"http流程全鏈路" 這些服務能力的綜合體,可以看做是一個應用服務物件。如果把這些全放到 koa 裡的話,那麼對應的就是:

  • Application
  • Context
  • Request
  • Response
  • Middlewares
  • Session
  • Cookie

Koa的組成結構

首先看下koa的目錄結構

Koa2 原始碼學習(上)

  • application.js:框架入口;負責管理中介軟體,以及處理請求
  • context.js:context物件的原型,代理request與response物件上的方法和屬性
  • request.js:request物件的原型,提供請求相關的方法和屬性
  • response.js:response物件的原型,提供響應相關的方法和屬性
// application.js

const isGeneratorFunction = require('is-generator-function'); // 判斷當前傳入的function是否是標準的generator function
const debug = require('debug')('koa:application'); // js除錯工具
const onFinished = require('on-finished'); // 事件監聽,當http請求關閉,完成或者出錯的時候呼叫註冊好的回撥
const response = require('./response'); // 響應請求
const compose = require('koa-compose'); // 中介軟體的函式陣列
const isJSON = require('koa-is-json'); // 判斷是否為json資料
const context = require('./context'); // 執行服務上下文
const request = require('./request'); // 客戶端的請求
const statuses = require('statuses'); // 請求狀態碼 
const Cookies = require('cookies');
const accepts = require('accepts'); // 約定可被服務端接收的資料,主要是協議和資源的控制
const Emitter = require('events'); // 事件迴圈
const assert = require('assert'); // 斷言
const Stream = require('stream');
const http = require('http');
const only = require('only'); // 白名單選擇
const convert = require('koa-convert'); // 相容舊版本koa中介軟體
const deprecate = require('depd')('koa'); // 判斷當前在執行koa的某些介面或者方法是否過期,如果過期,會給出一個升級的提示
複製程式碼

以上是koa入口檔案的依賴分析。接下來我們進行原始碼分析,首先我們利用刪減法來篩出程式碼的核心實現即可,不用上來就盯細節! 我們只保留constructor

// application.js

module.exports = class Application extends Emitter {
  constructor() {
    super();

    this.proxy = false; // 是否信任 proxy header 引數,預設為 false
    this.middleware = []; //儲存通過app.use(middleware)註冊的中介軟體
    this.subdomainOffset = 2; // 子域預設偏移量,預設為 2
    this.env = process.env.NODE_ENV || 'development'; // 環境引數,預設為 NODE_ENV 或 ‘development’
    this.context = Object.create(context); //context模組,通過context.js建立
    this.request = Object.create(request); //request模組,通過request.js建立
    this.response = Object.create(response); //response模組,通過response.js建立
  }

  // ...
}
複製程式碼

我們可以看到,這段程式碼暴露出一個類,建構函式內預先宣告瞭一些屬性,該類繼承了Emitter,也就是說這個類可以直接為自定義事件註冊回撥函式和觸發事件,同時可以捕捉到其他地方觸發的事件。

除了這些基本屬性之外,還有一些公用的api,最重要的兩個一個是==listen==,一個是==use==。koa的每個例項上都會有這些屬性和方法。

// application.js

module.exports = class Application extends Emitter {
  constructor() {
    super();

    this.proxy = false;
    this.middleware = [];
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  listen() {
    const server = http.createServer(this.callback());
    return server.listen.apply(server, arguments);
  }

  use(fn) {
    this.middleware.push(fn);
    return this;
  }
}
複製程式碼

listen 方法內部通過 http.createServer 建立了一個http服務的例項,通過這個例項去 listen 要監聽的埠號,http.createServer 的引數傳入了 this.callback 回撥

// application.js

module.exports = class Application extends Emitter {
  ...
  callback() {
    const fn = compose(this.middleware); // 把所有middleware進行了組合,使用了koa-compose

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn); // 返回了本身的回撥函式
    };

    return handleRequest;
  }
}
複製程式碼

可以看到,handleRequest 返回了本身的回撥,接下來看 handleRequest 。

handleRequest 方法直接作為監聽成功的呼叫方法。已經拿到了 包含 req res 的 ctx 和可以執行所有中介軟體函式的 fn。 首先一進來預設設定狀態碼為==404== . 然後分別宣告瞭 成功函式執行完成以後的成功 失敗回撥方法。這兩個方法實際上就是再將 ctx 分化成 req res。 分別調這兩個物件去客戶端執行內容返回。 ==context.js request.js response.js== 分別是封裝了一些對 ctx req res 操作相關的屬性,我們以後再說。

// application.js

module.exports = class Application extends Emitter {
  ...
  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res; // 拿到context.res
    res.statusCode = 404; // 設定預設狀態嗎404
    const onerror = err => ctx.onerror(err); // 設定onerror觸發事件
    const handleResponse = () => respond(ctx); // 向客戶端返回資料
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
}
複製程式碼

失敗執行的回撥

onerror(err) {
  assert(err instanceof Error, `non-error thrown: ${err}`);

  if (404 == err.status || err.expose) return;
  if (this.silent) return;

  const msg = err.stack || err.toString();
  console.error();
  console.error(msg.replace(/^/gm, '  '));
  console.error();
}
複製程式碼

成功執行的回撥

function respond(ctx) {
  ...
}
複製程式碼

return fnMiddleware(ctx).then(handleResponse).catch(onerror); 我們拆分理解,首先 return fnMiddleware(ctx) 返回了一箇中介軟體陣列處理鏈路,then(handleResponse) 等到整個中介軟體陣列全部完成之後把返回結果通過 then 傳遞到 handleResponse。

// application.js

module.exports = class Application extends Emitter {
  ...
  createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    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.cookies = new Cookies(req, res, {
      keys: this.keys,
      secure: request.secure
    });
    request.ip = request.ips[0] || req.socket.remoteAddress || '';
    context.accept = request.accept = accepts(req);
    context.state = {};
    return context;
  }
}
複製程式碼

這裡我們不用去太深入去摳程式碼,理解原理就行。createContext 建立 context 的時候,還會將 req 和 res 分別掛載到context 物件上,並對req 上一些關鍵的屬性進行處理和簡化 掛載到該物件本身,簡化了對這些屬性的呼叫。我們通過一張圖來直觀地看到所有這些物件之間的關係。

Koa2 原始碼學習(上)

  • 最左邊一列表示每個檔案的匯出物件
  • 中間一列表示每個Koa應用及其維護的屬性
  • 右邊兩列表示對應每個請求所維護的一些列物件
  • 黑色的線表示例項化
  • 紅色的線表示原型鏈
  • 藍色的線表示屬性

createContext 簡單理解就是掛載上面的物件,方便整個上下游http能及時訪問到進出請求及特定的行為。

// application.js

module.exports = class Application extends Emitter {
  ...
}
function respond(ctx) {
  // allow bypassing koa
  if (false === ctx.respond) return;

  const res = ctx.res;
  if (!ctx.writable) return;

  let body = ctx.body;
  const code = ctx.status; // 賦值服務狀態碼

  if ('HEAD' == ctx.method) { // 請求頭方法判斷
    if (!res.headersSent && isJSON(body)) {
      ctx.length = Buffer.byteLength(JSON.stringify(body));
    }
    return res.end();
  }

  // status body
  if (null == body) {
    body = ctx.message || String(code);
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // 通過判斷body型別來呼叫,這裡的res.end就是最終向客戶端返回資料的動作
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // 返回為json資料
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}
複製程式碼

respond 函式是 handleRequest 成功處理的回撥,內部做了合理性校驗,諸如狀態碼,內容的型別判斷,最後向客戶端返回資料。

結語

以上就是我們對application.js檔案的分析,通過上面的分析,我們已經可以大概得知Koa處理請求的過程:當請求到來的時候,會通過 req 和 res 來建立一個 context (ctx) ,然後執行中介軟體。

相關文章