從零開始手寫Koa2框架

xpromise發表於2019-03-21

01、介紹

  • Koa -- 基於 Node.js 平臺的下一代 web 開發框架
  • Koa 是一個新的 web 框架,由 Express 幕後的原班人馬打造, 致力於成為 web 應用和 API 開發領域中的一個更小、更富有表現力、更健壯的基石。
  • 與其對應的 Express 來比,Koa 更加小巧、精壯,本文將帶大家從零開始實現 Koa 的原始碼,從根源上解決大家對 Koa 的困惑

本文 Koa 版本為 2.7.0, 版本不一樣原始碼可能會有變動
原始碼倉庫 已經開放,如果本文對你有幫助,歡迎 star ~~

02、原始碼目錄介紹

  • Koa 原始碼目錄截圖
    從零開始手寫Koa2框架
  • 通過原始碼目錄可以知道,Koa主要分為4個部分,分別是:
    • application: Koa 最主要的模組, 對應 app 應用物件
    • context: 對應 ctx 物件
    • request: 對應 Koa 中請求物件
    • response: 對應 Koa 中響應物件
  • 這4個檔案就是 Koa 的全部內容了,其中 application 又是其中最核心的檔案。我們將會從此檔案入手,一步步實現 Koa 框架

03、實現一個基本伺服器

  • 程式碼目錄

  • my-application

    const {createServer} = require('http');
    
    module.exports = class Application {
      constructor() {
        // 初始化中介軟體陣列, 所有中介軟體函式都會新增到當前陣列中
        this.middleware = [];
      }
      // 使用中介軟體方法
      use(fn) {
        // 將所有中介軟體函式新增到中介軟體陣列中
        this.middleware.push(fn);
      }
      // 監聽埠號方法
      listen(...args) {
        // 使用nodejs的http模組監聽埠號
        const server = createServer((req, res) => {
          /*
            處理請求的回撥函式,在這裡執行了所有中介軟體函式
            req 是 node 原生的 request 物件
            res 是 node 原生的 response 物件
          */
          this.middleware.forEach((fn) => fn(req, res));
        })
        server.listen(...args);
      }
    }
    複製程式碼
  • index.js

    // 引入自定義模組
    const MyKoa = require('./js/my-application');
    // 建立例項物件
    const app = new MyKoa();
    // 使用中介軟體
    app.use((req, res) => {
      console.log('中介軟體函式執行了~~~111');
    })
    app.use((req, res) => {
      console.log('中介軟體函式執行了~~~222');
      res.end('hello myKoa');
    })
    // 監聽埠號
    app.listen(3000, err => {
      if (!err) console.log('伺服器啟動成功了');
      else console.log(err);
    })
    複製程式碼
  • 執行入口檔案 index.js 後,通過瀏覽器輸入網址訪問 http://localhost:3000/ , 就可以看到結果了~~

  • 神奇吧!一個最簡單的伺服器模型就搭建完了。當然我們這個極簡伺服器還存在很多問題,接下來讓我們一一解決

04、實現中介軟體函式的 next 方法

  • 提取createServer的回撥函式,封裝成一個callback方法(可複用)
    // 監聽埠號方法
    listen(...args) {
      // 使用nodejs的http模組監聽埠號
      const server = createServer(this.callback());
      server.listen(...args);
    }
    callback() {
      const handleRequest = (req, res) => {
        this.middleware.forEach((fn) => fn(req, res));
      }
      return handleRequest;
    }
    複製程式碼
  • 封裝compose函式實現next方法
    /**
     * 負責執行中介軟體函式的函式
     * @param middleware 中介軟體陣列
     * @return {function}
     */
    function compose(middleware) {
      // compose方法返回值是一個函式,這個函式返回值是一個promise物件
      // 當前函式就是排程
      return (req, res) => {
        // 預設呼叫一次,為了執行第一個中介軟體函式
        return dispatch(0);
        function dispatch(i) {
          // 提取中介軟體陣列的函式fn
          let fn = middleware[i];
          // 如果最後一箇中介軟體也呼叫了next方法,直接返回一個成功狀態的promise物件
          if (!fn) return Promise.resolve();
          /*
            dispatch.bind(null, i + 1)) 作為中介軟體函式呼叫的第三個引數,其實就是對應的next
              舉個栗子:如果 i = 0  那麼 dispatch.bind(null, 1))  
                --> 也就是如果呼叫了next方法 實際上就是執行 dispatch(1) 
                  --> 它利用遞迴重新進來取出下一個中介軟體函式接著執行
            fn(req, res, dispatch.bind(null, i + 1))
              --> 這也是為什麼中介軟體函式能有三個引數,在呼叫時我們傳進來了
          */
          return Promise.resolve(fn(req, res, dispatch.bind(null, i + 1)));
        }
      }
    }
    複製程式碼
  • 使用compose函式
    callback () {
      // 執行compose方法返回一個函式
      const fn = compose(this.middleware);
      
      const handleRequest = (req, res) => {
        // 呼叫該函式,返回值為promise物件
        // then方法觸發了, 說明所有中介軟體函式都被呼叫完成
        fn(req, res).then(() => {
          // 在這裡就是所有處理的函式的最後階段,可以允許返回響應了~
        });
      }
      
      return handleRequest;
    }
    複製程式碼
  • 修改入口檔案 index.js 程式碼
    // 引入自定義模組
    const MyKoa = require('./js/my-application');
    // 建立例項物件
    const app = new MyKoa();
    // 使用中介軟體
    app.use((req, res, next) => {
      console.log('中介軟體函式執行了~~~111');
      // 呼叫next方法,就是呼叫堆疊中下一個中介軟體函式
      next();
    })
    app.use((req, res, next) => {
      console.log('中介軟體函式執行了~~~222');
      res.end('hello myKoa');
      // 最後的next方法沒發呼叫下一個中介軟體函式,直接返回Promise.resolve()
      next();
    })
    // 監聽埠號
    app.listen(3000, err => {
      if (!err) console.log('伺服器啟動成功了');
      else console.log(err);
    })
    複製程式碼
  • 此時我們實現了next方法,最核心的就是compose函式,極簡的程式碼實現了功能,不可思議!

05、處理返回響應

  • 定義返回響應函式respond
    function respond(req, res) {
      // 獲取設定的body資料
      let body = res.body;
      
      if (typeof body === 'object') {
        // 如果是物件,轉化成json資料返回
        body = JSON.stringify(body);
        res.end(body);
      } else {
        // 預設其他資料直接返回
        res.end(body);
      }
    }
    複製程式碼
  • callback中呼叫
    callback() {
      const fn = compose(this.middleware);
      
      const handleRequest = (req, res) => {
        // 當中介軟體函式全部執行完畢時,會觸發then方法,從而執行respond方法返回響應
        const handleResponse = () => respond(req, res);
        fn(req, res).then(handleResponse);
      }
      
      return handleRequest;
    }
    複製程式碼
  • 修改入口檔案 index.js 程式碼
    // 引入自定義模組
    const MyKoa = require('./js/my-application');
    // 建立例項物件
    const app = new MyKoa();
    // 使用中介軟體
    app.use((req, res, next) => {
      console.log('中介軟體函式執行了~~~111');
      next();
    })
    app.use((req, res, next) => {
      console.log('中介軟體函式執行了~~~222');
      // 設定響應內容,由框架負責返回響應~
      res.body = 'hello myKoa';
    })
    // 監聽埠號
    app.listen(3000, err => {
      if (!err) console.log('伺服器啟動成功了');
      else console.log(err);
    })
    複製程式碼
  • 此時我們就能根據不同響應內容做出處理了~當然還是比較簡單的,可以接著去擴充套件~

06、定義 Request 模組

// 此模組需要npm下載
const parse = require('parseurl');
const qs = require('querystring');

module.exports = {
  /**
   * 獲取請求頭資訊
   */
  get headers() {
    return this.req.headers;
  },
  /**
   * 設定請求頭資訊
   */
  set headers(val) {
    this.req.headers = val;
  },
  /**
   * 獲取查詢字串
   */
  get query() {
    // 解析查詢字串引數 --> key1=value1&key2=value2
    const querystring = parse(this.req).query;
    // 將其解析為物件返回 --> {key1: value1, key2: value2}
    return qs.parse(querystring);
  }
}
複製程式碼

07、定義 Response 模組

module.exports = {
  /**
   * 設定響應頭的資訊
   */
  set(key, value) {
    this.res.setHeader(key, value);
  },
  /**
   * 獲取響應狀態碼
   */
  get status() {
    return this.res.statusCode;
  },
  /**
   * 設定響應狀態碼
   */
  set status(code) {
    this.res.statusCode = code;
  },
  /**
   * 獲取響應體資訊
   */
  get body() {
    return this._body;
  },
  /**
   * 設定響應體資訊
   */
  set body(val) {
    // 設定響應體內容
    this._body = val;
    // 設定響應狀態碼
    this.status = 200;
    // json
    if (typeof val === 'object') {
      this.set('Content-Type', 'application/json');
    }
  },
}
複製程式碼

08、定義 Context 模組

// 此模組需要npm下載
const delegate = require('delegates');

const proto = module.exports = {};

// 將response物件上的屬性/方法克隆到proto上
delegate(proto, 'response')
  .method('set')    // 克隆普通方法
  .access('status') // 克隆帶有get和set描述符的方法
  .access('body')  

// 將request物件上的屬性/方法克隆到proto上
delegate(proto, 'request')
  .access('query')
  .getter('headers')  // 克隆帶有get描述符的方法

複製程式碼

09、揭祕 delegates 模組

module.exports = Delegator;

/**
 * 初始化一個 delegator.
 */
function Delegator(proto, target) {
  // this必須指向Delegator的例項物件
  if (!(this instanceof Delegator)) return new Delegator(proto, target);
  // 需要克隆的物件
  this.proto = proto;
  // 被克隆的目標物件
  this.target = target;
  // 所有普通方法的陣列
  this.methods = [];
  // 所有帶有get描述符的方法陣列
  this.getters = [];
  // 所有帶有set描述符的方法陣列
  this.setters = [];
}

/**
 * 克隆普通方法
 */
Delegator.prototype.method = function(name){
  // 需要克隆的物件
  var proto = this.proto;
  // 被克隆的目標物件
  var target = this.target;
  // 方法新增到method陣列中
  this.methods.push(name);
  // 給proto新增克隆的屬性
  proto[name] = function(){
    /*
      this指向proto, 也就是ctx
        舉個栗子:ctx.response.set.apply(ctx.response, arguments)
        arguments對應實參列表,剛好與apply方法傳參一致
        執行ctx.set('key', 'value') 實際上相當於執行 response.set('key', 'value')
    */
    return this[target][name].apply(this[target], arguments);
  };
  // 方便鏈式呼叫
  return this;
};

/**
 * 克隆帶有get和set描述符的方法.
 */
Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};

/**
 * 克隆帶有get描述符的方法.
 */
Delegator.prototype.getter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.getters.push(name);
  // 方法可以為一個已經存在的物件設定get描述符屬性
  proto.__defineGetter__(name, function(){
    return this[target][name];
  });

  return this;
};

/**
 * 克隆帶有set描述符的方法.
 */
Delegator.prototype.setter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.setters.push(name);
  // 方法可以為一個已經存在的物件設定set描述符屬性
  proto.__defineSetter__(name, function(val){
    return this[target][name] = val;
  });

  return this;
};
複製程式碼

10、使用 ctx 取代 req 和 res

  • 修改 my-application
    const {createServer} = require('http');
    const context = require('./my-context');
    const request = require('./my-request');
    const response = require('./my-response');
    
    module.exports = class Application {
      constructor() {
        this.middleware = [];
        // Object.create(target) 以target物件為原型, 建立新物件, 新物件原型有target物件的屬性和方法
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
      }
      
      use(fn) {
        this.middleware.push(fn);
      }
        
      listen(...args) {
        // 使用nodejs的http模組監聽埠號
        const server = createServer(this.callback());
        server.listen(...args);
      }
      
      callback() {
        const fn = compose(this.middleware);
        
        const handleRequest = (req, res) => {
          // 建立context
          const ctx = this.createContext(req, res);
          const handleResponse = () => respond(ctx);
          fn(ctx).then(handleResponse);
        }
        
        return handleRequest;
      }
      
      /**
       * 建立context 上下文物件的方法
       * @param req node原生req物件
       * @param res node原生res物件
       */
      createContext(req, res) {
        /*
          凡是req/res,就是node原生物件
          凡是request/response,就是自定義物件
          這是實現互相掛載引用,從而在任意物件上都能獲取其他物件的方法
         */
        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;
        
        return context;
      }
    }
    // 將原來使用req,res的地方改用ctx
    function compose(middleware) {
      return (ctx) => {
        return dispatch(0);
        function dispatch(i) {
          let fn = middleware[i];
          if (!fn) return Promise.resolve();
          return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
        }
      }
    }
    
    function respond(ctx) {
      let body = ctx.body;
      const res = ctx.res;
      if (typeof body === 'object') {
        body = JSON.stringify(body);
        res.end(body);
      } else {
        res.end(body);
      }
    }
    複製程式碼
  • 修改入口檔案 index.js 程式碼
    // 引入自定義模組
    const MyKoa = require('./js/my-application');
    // 建立例項物件
    const app = new MyKoa();
    // 使用中介軟體
    app.use((ctx, next) => {
      console.log('中介軟體函式執行了~~~111');
      next();
    })
    app.use((ctx, next) => {
      console.log('中介軟體函式執行了~~~222');
      // 獲取請求頭引數
      console.log(ctx.headers);
      // 獲取查詢字串引數
      console.log(ctx.query);
      // 設定響應頭資訊
      ctx.set('content-type', 'text/html;charset=utf-8');
      // 設定響應內容,由框架負責返回響應~
      ctx.body = '<h1>hello myKoa</h1>';
    })
    // 監聽埠號
    app.listen(3000, err => {
      if (!err) console.log('伺服器啟動成功了');
      else console.log(err);
    })
    複製程式碼

到這裡已經寫完了 Koa 主要程式碼,有一句古話 - 看萬遍程式碼不如寫上一遍。 還等什麼,趕緊寫上一遍吧~ 當你能夠寫出來,再去閱讀原始碼,你會發現原始碼如此簡單~

相關文章