Koa源碼淺析

Koa源碼十分精簡,只有不到2k行的代碼,總共由4個模塊文件組成,非常適合我們來學習。
koa1

參考代碼: learn-koa2

我們先來看段原生Node實現Server服務器的代碼:

const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end('hello world');
});

server.listen(3000, () => {
  console.log('server start at 3000');
});

非常簡單的幾行代碼,就實現了一個服務器Server。createServer方法接收的callback回調函數,可以對每次請求的req res對象進行各種操作,最後返回結果。不過弊端也很明顯,callback函數非常容易隨着業務邏輯的複雜也變得臃腫,即使把callback函數拆分成各個小函數,也會在繁雜的異步回調中漸漸失去對整個流程的把控。

另外,Node原生提供的一些API,有時也會讓開發者疑惑:

res.statusCode = 200;
res.writeHead(200);

修改res的屬性或者調用res的方法都可以改變http狀態碼,這在多人協作的項目中,很容易產生不同的代碼風格。

我們再來看段Koa實現Server:

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

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


app.use(async (ctx, next) => {
  console.log('2-start');
  ctx.status = 200;
  ctx.body = 'Hello World';
  console.log('2-end');
});


app.listen(3000);

// 最後輸出內容:
// 1-start
// 2-start
// 2-end
// 1-end

Koa使用了中間件的概念來完成對一個http請求的處理,同時,Koa採用了async和await的語法使得異步流程可以更好的控制。ctx執行上下文代理了原生的resreq,這讓開發者避免接觸底層,而是通過代理訪問和設置屬性。

看完兩者的對比後,我們應該會有幾個疑惑:

  • ctx.status爲什麼就可以直接設置狀態碼了,不是根本沒看到res對象嗎?
  • 中間件中的next到底是啥?爲什麼執行next就進入了下一個中間件?
  • 所有中間件執行完成後,爲什麼可以再次返回原來的中間件(洋蔥模型)?

現在讓我們帶着疑惑,進行源碼解讀,同時自己實現一個簡易版的Koa吧!

封裝http Server

參考代碼: step-1

// Koa的使用方法
const Koa = require('koa');
const app = new Koa();

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

app.listen(3000);

我們首先模仿koa的使用方法,搭建一個最簡易的骨架:

新建kao/application.js(特意使用了Kao,區別Koa,並非筆誤!!!)

const http = require('http');

class Application {
  constructor() {
    this.callbackFn = null;
  }

  use(fn) {
    this.callbackFn = fn;
  }

  callback() {
    return (req, res) => this.callbackFn(req, res)
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

新建測試文件kao/index.js

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

app.use(async (req, res) => {
  res.writeHead(200);
  res.end('hello world');
});

app.listen(3001, () => {
  console.log('server start at 3001');
});

我們已經初步封裝好http server:通過new實例一個對象,use註冊回調函數,listen啓動server並傳入回調。

注意的是:調用new時,其實沒有開啓server服務器,真正開啓是在listen調用時。

不過這段代碼有明顯的不足:

  • use傳入的回調函數,接收的參數依舊是原生的reqres
  • 多次調用use,會覆蓋上一個中間件,並不是依次執行多箇中間件

我們先來解決第一個問題

封裝req和res對象,構造context

參考代碼: step-2

先來介紹下ES6中的get和set 參考

基於普通對象的get和set

const demo = {
  _name: '',
  get name() {
    return this._name;
  },

  set name(val) {
    this._name = val;
  }
};

demo.name = 'deepred';
console.log(demo.name);

基於Class的get和set

class Demo {
  constructor() {
    this._name = '';
  }

  get name() {
    return this._name;
  }

  set name(val) {
    this._name = val;
  }
}

const demo = new Demo();
demo.name = 'deepred';
console.log(demo.name);

基於Object.defineProperty的get和set

const demo = {
  _name: ''
};

Object.defineProperty(demo, 'name', {
  get: function() {
    return this._name
  },

  set: function(val) {
    this._name = val;
  }
});

基於Proxy的get和set

const demo = {
  _name: ''
};

const proxy = new Proxy(demo, {
  get: function(target, name) {
    return name === 'name' ? target['_name'] : undefined;
  },

  set: function(target, name, val) {
    name === 'name' && (target['_name'] = val)
  }
});

還有__defineSetter____defineGetter__的實現,不過現已廢棄。

const demo = {
  _name: ''
};

demo.__defineGetter__('name', function() {
  return this._name;
});

demo.__defineSetter__('name', function(val) {
  this._name = val;
});

主要區別是,Object.defineProperty __defineSetter__ Proxy可以動態設置屬性,而其他方式只能在定義時設置。

Koa源碼中 request.jsresponse.js就使用了大量的getset來代理

新建kao/request.js

module.exports = {
  get header() {
    return this.req.headers;
  },

  set header(val) {
    this.req.headers = val;
  },

  get url() {
    return this.req.url;
  },

  set url(val) {
    this.req.url = val;
  },
}

當訪問request.url時,其實就是在訪問原生的req.url。需要注意的是,this.req原生對象此時還沒有注入!

同理新建kao/response.js

module.exports = {
  get status() {
    return this.res.statusCode;
  },

  set status(code) {
    this.res.statusCode = code;
  },

  get body() {
    return this._body;
  },

  set body(val) {
    // 源碼裏有對val類型的各種判斷,這裏省略
    /* 可能的類型
    1. string
    2. Buffer
    3. Stream
    4. Object
    */
    this._body = val;
  }
}

這裏對body進行操作並沒有使用原生的this.res.end,因爲在我們編寫koa代碼的時候,會對body進行多次的讀取和修改,所以真正返回瀏覽器信息的操作是在application.js裏進行封裝和操作

同樣需要注意的是,this.res原生對象此時還沒有注入!

新建kao/context.js

const delegate = require('delegates');

const proto = module.exports = {
  // context自身的方法
  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>'
    };
  },
}

// delegates 原理就是__defineGetter__和__defineSetter__

// method是委託方法,getter委託getter,access委託getter和setter。

// proto.status => proto.response.status
delegate(proto, 'response')
  .access('status')
  .access('body')


// proto.url = proto.request.url
delegate(proto, 'request')
  .access('url')
  .getter('header')

context.js代理了requestresponsectx.body指向ctx.response.body。但是此時ctx.response ctx.request還沒注入!

可能會有疑問,爲什麼response.jsrequest.js使用get set代理,而context.js使用delegate代理? 原因主要是: setget方法裏面還可以加入一些自己的邏輯處理。而delegate就比較純粹了,只代理屬性。

{
  get length() {
    // 自己的邏輯
    const len = this.get('Content-Length');
    if (len == '') return;
    return ~~len;
  },
}

// 僅僅代理屬性
delegate(proto, 'response')
  .access('length')

因此context.js比較適合使用delegate,僅僅是代理requestresponse的屬性和方法。

真正注入原生對象,是在application.js裏的createContext方法中注入的!!!

const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Application {
  constructor() {
    this.callbackFn = null;
    // 每個Kao實例的context request respones
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  use(fn) {
    this.callbackFn = fn;
  }

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

    return handleRequest;
  }

  handleRequest(ctx) {
    const handleResponse = () => respond(ctx);
    // callbackFn是個async函數,最後返回promise對象
    return this.callbackFn(ctx).then(handleResponse);
  }

  createContext(req, res) {
    // 針對每個請求,都要創建ctx對象
    // 每個請求的ctx request response
    // ctx代理原生的req res就是在這裏代理的
    let ctx = Object.create(this.context);
    ctx.request = Object.create(this.request);
    ctx.response = Object.create(this.response);
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    ctx.app = ctx.request.app = ctx.response.app = this;
    return ctx;
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

function respond(ctx) {
  // 根據ctx.body的類型,返回最後的數據
  /* 可能的類型,代碼刪減了部分判斷
  1. string
  2. Buffer
  3. Stream
  4. Object
  */
  let content = ctx.body;
  if (typeof content === 'string') {
    ctx.res.end(content);
  }
  else if (typeof content === 'object') {
    ctx.res.end(JSON.stringify(content));
  }
}

代碼中使用了Object.create的方法創建一個全新的對象,通過原型鏈繼承原來的屬性。這樣可以有效的防止污染原來的對象。

createContext在每次http請求時都會調用,每次調用都新生成一個ctx對象,並且代理了這次http請求的原生的對象。

respond纔是最後返回http響應的方法。根據執行完所有中間件後ctx.body的類型,調用res.end結束此次http請求。

現在我們再來測試一下:
kao/index.js

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

// 使用ctx修改狀態碼和響應內容
app.use(async (ctx) => {
  ctx.status = 200;
  ctx.body = {
    code: 1,
    message: 'ok',
    url: ctx.url
  };
});

app.listen(3001, () => {
  console.log('server start at 3001');
});

中間件機制

參考代碼: step-3

const greeting = (firstName, lastName) => firstName + ' ' + lastName
const toUpper = str => str.toUpperCase()

const fn = compose([toUpper, greeting]);

const result = fn('jack', 'smith');

console.log(result);

函數式編程有個compose的概念。比如把greetingtoUpper組合成一個複合函數。調用這個複合函數,會先調用greeting,然後把返回值傳給toUpper繼續執行。

實現方式:

// 命令式編程(面向過程)
function compose(fns) {
  let length = fns.length;
  let count = length - 1;
  let result = null;

  return function fn1(...args) {
    result = fns[count].apply(null, args);
    if (count <= 0) {
      return result
    }

    count--;
    return fn1(result);
  }
}

// 聲明式編程(函數式)
function compose(funcs) {
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

Koa的中間件機制類似上面的compose,同樣是把多個函數包裝成一個,但是koa的中間件類似洋蔥模型,也就是從A中間件執行到B中間件,B中間件執行完成以後,仍然可以再次回到A中間件。

Koa使用了koa-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!')
  }

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

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // 一箇中間件裏多次調用next
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      // fn就是當前的中間件
      let fn = middleware[i]
      if (i === middleware.length) fn = next // 最後一箇中間件如果也next時進入(一般最後一箇中間件是直接操作ctx.body,並不需要next了)
      if (!fn) return Promise.resolve() // 沒有中間件,直接返回成功
      try {
        
        /* 
          * 使用了bind函數返回新的函數,類似下面的代碼
          return Promise.resolve(fn(context, function next () {
            return dispatch(i + 1)
          }))
        */
        // dispatch.bind(null, i + 1)就是中間件裏的next參數,調用它就可以進入下一個中間件

        // fn如果返回的是Promise對象,Promise.resolve直接把這個對象返回
        // fn如果返回的是普通對象,Promise.resovle把它Promise化
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        // 中間件是async的函數,報錯不會走這裏,直接在fnMiddleware的catch中捕獲
        // 捕獲中間件是普通函數時的報錯,Promise化,這樣才能走到fnMiddleware的catch方法
        return Promise.reject(err)
      }
    }
  }
}
const context = {};

const sleep = (time) => new Promise(resolve => setTimeout(resolve, time));

const test1 = async (context, next) => {
  console.log('1-start');
  context.age = 11;
  await next();
  console.log('1-end');
};

const test2 = async (context, next) => {
  console.log('2-start');
  context.name = 'deepred';
  await sleep(2000);
  console.log('2-end');
};

const fn = compose([test1, test2]);

fn(context).then(() => {
  console.log('end');
  console.log(context);
});

遞歸調用棧的執行情況:

弄懂了中間件機制,我們應該可以回答之前的問題:

next到底是啥?洋蔥模型是怎麼實現的?

next就是一個包裹了dispatch的函數

在第n箇中間件中執行next,就是執行dispatch(n+1),也就是進入第n+1箇中間件

因爲dispatch返回的都是Promise,所以在第n箇中間件await next(); 進入第n+1箇中間件。當第n+1箇中間件執行完成後,可以返回第n箇中間件

如果在某個中間件中不再調用next,那麼它之後的所有中間件都不會再調用了

修改kao/application.js

class Application {
  constructor() {
    this.middleware = []; // 存儲中間件
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  use(fn) {
    this.middleware.push(fn); // 存儲中間件
  }

  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, dispatch.bind(null, i + 1)));
        } catch (err) {
          return Promise.reject(err)
        }
      }
    }
  }
  

  callback() {
    // 合成所有中間件
    const fn = this.compose(this.middleware);

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

    return handleRequest;
  }

  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => respond(ctx);
    // 執行中間件並把最後的結果交給respond
    return fnMiddleware(ctx).then(handleResponse);
  }

  createContext(req, res) {
    // 針對每個請求,都要創建ctx對象
    let ctx = Object.create(this.context);
    ctx.request = Object.create(this.request);
    ctx.response = Object.create(this.response);
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    ctx.app = ctx.request.app = ctx.response.app = this;
    return ctx;
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

function respond(ctx) {
  let content = ctx.body;
  if (typeof content === 'string') {
    ctx.res.end(content);
  }
  else if (typeof content === 'object') {
    ctx.res.end(JSON.stringify(content));
  }
}

測試一下

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

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

app.use(async (ctx) => {
  console.log('2-start');
  ctx.body = 'hello tc';
  console.log('2-end');
});

app.listen(3001, () => {
  console.log('server start at 3001');
});

// 1-start 2-start 2-end 1-end

錯誤處理機制

參考代碼: step-4

因爲compose組合之後的函數返回的仍然是Promise對象,所以我們可以在catch捕獲異常

kao/application.js

handleRequest(ctx, fnMiddleware) {
  const handleResponse = () => respond(ctx);
  const onerror = err => ctx.onerror(err);
  // catch捕獲,觸發ctx的onerror方法
  return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

kao/context.js

const proto = module.exports = {
  // context自身的方法
  onerror(err) {
    // 中間件報錯捕獲
    const { res } = this;

    if ('ENOENT' == err.code) {
      err.status = 404;
    } else {
      err.status = 500;
    }
    this.status = err.status;
    res.end(err.message || 'Internal error');
  }
}
const Kao = require('./application');
const app = new Kao();

app.use(async (ctx) => {
  // 報錯可以捕獲
  a.b.c = 1;
  ctx.body = 'hello tc';
});

app.listen(3001, () => {
  console.log('server start at 3001');
});

現在我們已經實現了中間件的錯誤異常捕獲,但是我們還缺少框架層發生錯誤的捕獲機制。我們可以讓Application繼承原生的Emitter,從而實現error監聽

kao/application.js

const Emitter = require('events');

// 繼承Emitter
class Application extends Emitter {
  constructor() {
    // 調用super
    super();
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }
}

kao/context.js

const proto = module.exports = {
  onerror(err) {
    const { res } = this;

    if ('ENOENT' == err.code) {
      err.status = 404;
    } else {
      err.status = 500;
    }

    this.status = err.status;

    // 觸發error事件
    this.app.emit('error', err, this);

    res.end(err.message || 'Internal error');
  }
}
const Kao = require('./application');
const app = new Kao();

app.use(async (ctx) => {
  // 報錯可以捕獲
  a.b.c = 1;
  ctx.body = 'hello tc';
});

app.listen(3001, () => {
  console.log('server start at 3001');
});

// 監聽error事件
app.on('error', (err) => {
  console.log(err.stack);
});

至此我們可以瞭解到Koa異常捕獲的兩種方式:

  • 中間件捕獲(Promise catch)
  • 框架捕獲(Emitter error)
// 捕獲全局異常的中間件
app.use(async (ctx, next) => {
  try {
    await next()
  } catch (error) {
    return ctx.body = 'error'
  }
}
)
// 事件監聽
app.on('error', err => {
  console.log('error happends: ', err.stack);
});

總結

Koa整個流程可以分成三步:

初始化階段:

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

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

app.listen(3000);

new初始化一個實例,use蒐集中間件到middleware數組,listen 合成中間件fnMiddleware,返回一個callback函數給http.createServer,開啓服務器,等待http請求。

請求階段:

每次請求,createContext生成一個新的ctx,傳給fnMiddleware,觸發中間件的整個流程

響應階段:

整個中間件完成後,調用respond方法,對請求做最後的處理,返回響應給客戶端。

參考下面的流程圖:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章