我們來一步一步分析koa2的原始碼?

fanyang發表於2018-04-02

koa2 原始碼分析

最近想做一個關於NodeJS 服務端相關的總結,思前想後覺得可以從原始碼分析作為切入點。於是首先便選擇了koa2.

注:由於書寫習慣原因 ,下文中所有出現koa的字眼 皆指的是koa2.x版本。如果是1.x版本則用koa1.x標明。

本文章寫在我的github倉庫如果喜歡 歡迎關注 一起學習? github.com/yangfan0095…

首先進入koa2 的檔案目錄 ,我們可以看到只有只有四個檔案 github.com/koajs/koa/t…. 分別是

  • application.js
  • context.js
  • request.js
  • response.js

application.js 是專案的入口檔案,對外輸出一個class ,這個class 就是koa 例項繼承自node核心模組event。 原始碼如下:

module.exports = class Application extends Emitter {
  /**
   * Initialize a new `Application`.
   *
   * @api public
   */

  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);
  }
  ...
複製程式碼

先從外層剖析

首先我們先看看koa2的正常使用邏輯。 以下是一個有koa2腳手架生成的一個初始化專案


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

// middlewares
app.use(bodyparser({
  enableTypes:['json', 'form', 'text']
}))
app.use(json())
app.use(logger())
app.use(require('koa-static')(__dirname + '/public'))

app.use(views(__dirname + '/views', {
  extension: 'pug'
}))

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

// routes
app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())

// error-handling
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
});


var http = require('http');
var server = http.createServer(app.callback());
server.listen(port);


複製程式碼

可以看到koa的核心非常簡要。就是初始化一個例項 在例項中傳入一系列中介軟體。 然後我們建立一個http伺服器, 將koa例項下的回撥函式傳入其中。 即app.callback(); 然後就可以開始監聽服務了。 先對比一下原生http 服務的建立

const http = require('http')
const server = http.createServer((req, res) => {
})
server.listen(3000, () => {
  console.log('server listening at 3000 port!')
})

複製程式碼

這裡我們可以關注到看兩點

  • 1 koa 服務主要是在做中介軟體處理
  • 2 koa 對原生http 建立方法的回撥做了處理,原生是 (req,res) =>{ }, koa 對齊做了封裝 封裝成了 koa.callback()。

進入application.js 檢視koa例項

構造

我們重點關注中介軟體處理和對http 的 req res 請求返回流的處理。 下面來逐一分析 application 的原始碼

module.exports = class Application extends Emitter {
  /**
   * Initialize a new `Application`.
   *
   * @api public
   */

  constructor() {
    super();

    this.proxy = false;// 是否信任 proxy header 引數,預設為 false
    this.middleware = []; //儲存中介軟體函式的陣列
    this.subdomainOffset = 2;// 不懂
    this.env = process.env.NODE_ENV || 'development';// 環境變數
    
    // 將 context.js  request.js response.js 分別賦值到該例項下
    this.context = Object.create(context); 
    this.request = Object.create(request); 
    this.response = Object.create(response);
  }
  ...
複製程式碼

listen 方法

listen 方法 我們可以看到 listen 是已經封裝好了一個 建立http服務的方法 這個方法傳入一個 該例項的回撥 即 app.callback() ,返回一個監聽方法。所以服務 也可以直接通過app.listen(...arg) 啟動

  /**
   * Shorthand for:
   *
   *    http.createServer(app.callback()).listen(...)
   *
   * @param {Mixed} ...
   * @return {Server}
   * @api public
   */

  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  
複製程式碼

use

接下來就是use方法 主要做的事情就是 傳入一箇中介軟體方法 將中介軟體push到this.middleware 陣列. this.middleware.push(fn); 其實use 最關鍵的的就只有這一行程式碼。

除此之外作者還提醒我們,傳入中介軟體的Fn不要寫成generator 函式。 原因是因為 koa2是基於 async await 處理非同步。 async 和 await 是ES7 新增的語法 本質是對 generator 函式做的一層封裝 。 實現了非同步變同步的寫法, 更夠更清晰的反映出函式控制流。相似功能的庫在koa1.x 還有一個co 庫 非常有名,實現原理也很簡單 主要是兩種方式 用thunk 或者 Promise 結合遞迴都可以實現。 大家感興趣可以看阮一峰老師的ES6標準 裡面就有提到 Generator 函式的非同步應用 傳送門

 /**
   * Use the given middleware `fn`.
   *
   * Old-style middleware will be converted.
   *
   * @param {Function} fn
   * @return {Application} self
   * @api public
   */

  use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
  }
  
複製程式碼

接下來我們就看到了 最關鍵的callback 方法了. callback 對node原生http返回一個handler callback。 第一行程式碼 將存放中介軟體函式的陣列 this.middleware 通過compose 函式處理得到一個 fn。

callback 方法


 /**
  * Return a request handler callback
  * for node's native http server.
  *
  * @return {Function}
  * @api public
  */

 callback() {
   const fn = compose(this.middleware);

   if (!this.listeners('error').length) this.on('error', this.onerror);

   const handleRequest = (req, res) => {
     const ctx = this.createContext(req, res);
     return this.handleRequest(ctx, fn);
   };

   return handleRequest;
 }
 
複製程式碼

compose 函式

compose 函式前端用過 redux 的同學肯定都很熟悉。redux 通過compose來處理 中介軟體 。 原理是 藉助陣列的 reduce 對陣列的引數進行迭代,而我們來看看kos實現compose的方法。感覺扯遠了。

// redux 中的compose 函式

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

複製程式碼

言歸正傳,koa 例項中的compose是作為一個包引入的 , koa-compose 原始碼如下


/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

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

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  return function (context, next) {
    // last called middleware #
    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
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}


複製程式碼

這個compose函式 返回一個函式, 這個函式執行則可以 通過一個遞迴去遍歷執行所有的中介軟體函式。通過在Promise.resolve(fn)的回撥中執行fn 即實現了對非同步函式的處理。我們可以關注一下 最初是執行的是 dispatch(0) 也就是this.middleware陣列中下標為0的函式,也就是說 最先進入的中介軟體函式會最先被執行 就像一個執行佇列。

執行完成以後 執行next() 到下一步處理。 這個時候我們再看第一行 const fn = compose(this.middleware); 。 fn 實際上是一個待執行所有中介軟體的方法 。 我們再回顧一下callback()

  callback() {
   const fn = compose(this.middleware);

   if (!this.listeners('error').length) this.on('error', this.onerror);

   const handleRequest = (req, res) => {
     const ctx = this.createContext(req, res);
     return this.handleRequest(ctx, fn);
   };

   return handleRequest;
 }
 
複製程式碼

首先 我們取到了 能夠一次性執行所有中介軟體函式的fn . callback 返回一個方法。 這個方法輸入 原生建立http函式的 req,res 流 並對其進行封裝成一個context 物件。並呼叫handleRequest 方法返回 handleRequest(ctx,fn)

看到這裡其實關鍵步驟就已經很清晰了剩下只關注

  • koa 如何將req res 包裝到ctx
  • handleRequest 如何處理ctx 和 fn

createContext方法

createContext 將req 和 res 分別掛載到context 物件上。

    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
複製程式碼

並對req 上一些關鍵的屬性進行處理和簡化 掛載到該物件本身,簡化了對這些屬性的呼叫。

  /**
   * Initialize a new context.
   *
   * @api private
   */

  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;
  }
  
複製程式碼

handleRequest 方法

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

  /**
  * Handle request in callback.
  *
  * @api private
  */

 handleRequest(ctx, fnMiddleware) {
   const res = ctx.res;
   res.statusCode = 404;
   const onerror = err => ctx.onerror(err);
   const handleResponse = () => respond(ctx);
   onFinished(res, onerror);
   return fnMiddleware(ctx).then(handleResponse).catch(onerror);
 }
 
複製程式碼

失敗回撥執行

  /**
   * Default error handler.
   *
   * @param {Error} err
   * @api private
   */

  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();
  }
};

複製程式碼

成功回撥執行的方法

/**
 * Response helper.
 */

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;

  // ignore body
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }

  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);
  }

  // responses
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}

複製程式碼

講到這裡 其實主要的程式碼就講完了。可以看出 koa2 的思想非常簡潔。一句話就是由中介軟體控制所有流程。所以被形象的稱為洋蔥模型。同時還有一些特色就是 非核心程式碼都寫成了第三方依賴,這樣便於生態的發展。 這也是如今很多框架react vue 等的發展的趨勢。

最後,我寫這個的目的也是為了學習,且深感看原始碼簡單要理解真正的精髓還是很難很難。且學且珍惜吧 哈哈 ?

相關文章