Koa 原始碼淺析

是熊大啊發表於2018-09-16

本文圍繞koa服務從啟動,到處理請求再到回覆響應這個過程對原始碼進行簡單的解析

在koa中ctx是貫穿整個請求過程的,它是這次請求原資訊的承載體,可以從ctx上獲取到request、response、cookie等,方便我們進行後續的計算處理。 ctx在實現上原本就是一個空物件,在koa服務起來時,往上掛載了很多物件和方法。當然開發者也可以自定義掛載的方法。 在context.js檔案中對ctx初始化了一些內建的物件和屬性,包括錯誤處理,設定cookie。

  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.js

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');

複製程式碼

delegate方法的作用是將其他物件的方法或者屬性掛載在指定物件上,在這裡就是proto,也就是最初的ctx物件,屬性提供方就是第二個引數"response"。 method是代理方法,getter代理get,access代理set和get. 看delegate如何實現:


function Delegator(proto, target) {
  if (!(this instanceof Delegator)) return new Delegator(proto, target);
  this.proto = proto;
  this.target = target;
  this.methods = [];
  this.getters = [];
  this.setters = [];
  this.fluents = [];
}

Delegator.prototype.method = function(name){
  var proto = this.proto;
  var target = this.target;
  this.methods.push(name);

  proto[name] = function(){
    return this[target][name].apply(this[target], arguments);
  };

  return this;
};

複製程式碼

delegate中的method的實現是在呼叫原屬性上指定方法時,轉而呼叫提供方的方法。這裡可以發現提供方也被收在了this上,這裡的不直接傳入一個物件而是將該物件賦值在原物件上的原因,我想應該是存放一個副本在原物件上,這樣可以通過原物件直接訪問到提供屬性的物件。

./context.js中使用delegates為ctx賦值的過程並不完整,因為這裡的屬性提供方雖然是request和response, 但是是從./application.js createContext方法中傳入,這樣delegates才算完成了工作

到這裡我們就可以看下平時用koa時常走的流程。

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

// response
app.use(ctx => {
  ctx.body = 'Hello Koa';
});

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

基本上就是分為三步,例項化Koa,註冊中介軟體再監聽埠, 這裡正常能讓koa服務或者說一個http服務起的來的操作其實是在app.listen(...args)裡,是不是和想象中的有點差距, 看下原始碼實現。

// ./application.js

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

在listen方法裡使用了http模組的createServer方法來啟動http服務,這裡相當於是宣告瞭一個http.Server例項,該例項也繼承於EventEmitter,是一個事件型別的伺服器,並監聽了該例項的request事件,意為當客戶端有請求發過來的時候,這個事件將會觸發,等價於如下程式碼

var http = require("http");
var server = new http.Server();

server.on("request", function(req, res){
    // handle request
});

server.listen(3000);
複製程式碼

這個事件有兩個引數req和res,也就是這次事件的請求和響應資訊。有點扯遠了,回到koa原始碼, 處理req和res引數的任務就交給了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;
  }

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

callback返回一個函式由他來處理req和res,這個函式內部做了兩件事, 這兩件事分別在koa服務的初始化和響應時期完成,上述程式碼中compose中介軟體就是在服務初始化完成, 而當request事件觸發時,該事件會由callback返回的handleRequest方法處理,這個方法保持了對fn,也就是初始化過後中介軟體的應用, handleRequest先會初始化貫穿整個事件的ctx物件,這個時候就可以將ctx以此走入到各個中介軟體中處理了。

可以說koa到這裡主流程已經走一大半了,讓我們理一理經過簡單分析過的原始碼可以做到哪個地步(忽略錯誤處理)

  • 響應http請求 √
  • 生成ctx物件 √
  • 運用中介軟體 √
  • 返回請求 ×

如上我們已經可以做到將響應進入readly狀態,但還沒有返回響應的能力,後續會說道。在前三個過程中有兩個點需要注意,ctx和middleware,下面我們依次深入學習下這兩個關鍵點。

ctx是貫穿整個request事件的物件,它上面掛載瞭如req和res這種描述該次事件資訊的屬性,開發者也可以根據自己喜好,通過前置中介軟體掛載一些屬性上去。 ctx在koa例項createContext方法上建立並被完善,再由callback返回的handleRequest也就是響應request的處理函式消費。看下createContext原始碼

 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.state = {};
    return context;
  }

複製程式碼

前三行依次宣告瞭context、request和response,分別繼承於koa例項的三個靜態屬性,這三個靜態屬性由koa自己定義,在上面有一些快捷操作方法,比如在Request靜態類上可以獲取通過query獲取查詢引數,通過URL解析url等,可以理解為request的工具庫,Response和Context同理。res和rep是node的原生物件,還記得嗎,這兩個引數是由http.Server()例項觸發request事件帶來的入參。 res是http.incomingMessage的例項而rep繼承於http.ServerResponse, 貼一張圖。

al

箭頭指向說明了從屬關係,有五個箭頭指向ctx表面ctx上有五個這樣的的屬性,可以很清楚看到ctx上各個屬性之間的關係。

接下來我們再來看看koa中的中介軟體,在koa中使用use方法可以註冊中介軟體.

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

兩件事,統一中介軟體格式,再將中介軟體推入中介軟體陣列中。 在koa2.0以後middleware都是使用async/await語法,使用generator function也是可以的,2.0以後版本內建了koa-convert,它可以根據 fn.constructor.name == 'GeneratorFunction' check here.來判斷是legacyMiddleware還是modernMiddleware,並根據結果來做相應的轉換。 koa-convert的核心使用是co這個庫,它提供了一個自動的執行器,並且返回的是promise,generator function有了這兩個特性也就可以直接和async函式一起使用了。

回到koa原始碼來,callback中是這樣處理中介軟體陣列的

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

這裡的compose也就是koa-compose模組,它負責將所有的中介軟體串聯起來,並保證執行順序。經典的洋蔥圈圖:

koa mw

koa-compose模組的介紹只有簡單的一句話

Compose the given middleware and return middleware.

言簡意賅,就是組合中介軟體。貼上原始碼

function compose (middleware) {
  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
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
複製程式碼

compose先存入中介軟體陣列,從第一個開始執行依次resolve到最後一個,中介軟體函式簽名為(ctx,next)=>{},在內部呼叫next就會間接喚起下一個中介軟體,也就是執行dispatch.bind(null, i + 1),中介軟體執行順序如下(網上扒下來的圖)。

mw

圖上是遇到yield,和執行next同理。 也不是所有的中介軟體都需要next,在最後一個middleware執行完畢後可以不呼叫next,因為這個時候已經走完了所有中介軟體的前置邏輯。當然這裡呼叫next也是可以的,是為了在所有前置邏輯執行完後有一個回撥。我們單獨使用koa-compose:

const compose = require("koa-compose");
let _ctx = {
    name: "ctx"
};

const mw_a = async function (ctx, next) {
    console.log("this is step 1");
    ctx.body = "lewis";
    await next();
    console.log("this is step 4");
}

const mw_b = async function (ctx, next) {
    console.log("this is step 2");
    await next();
    console.log("this is step 3");
}

const fn = compose([mw_a, mw_b]);

fn(_ctx, async function (ctx) {
    console.log("Done", ctx)
});

// => 
// 
// this is 1
// this is 2
// Done {name: "ctx", body: "lewis"}
// this is 3
// this is 4
複製程式碼

compose返回的函式接受的引數不光是ctx,還可以接受一個函式作為走完所有中介軟體前置邏輯後的回撥。有特殊需求的開發者可以關注一下。 當然整個中介軟體執行完後會返回一個resolve狀態的promise,在這個回撥中koa用來告訴客戶端“響應已經處理完畢,請查收”,這個時候客戶端才結束等待狀態,這個過程的原始碼:

// handleRequest 的返回值
// 當中介軟體已經處理完畢後,交由handleResponse也就是respond方法來最後處理ctx
const handleResponse = () => respond(ctx);
fnMiddleware(ctx).then(handleResponse).catch(onerror);

/** 
 * 交付response
 */
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);
}

複製程式碼

以上程式碼對各種款式的status和ctx.body做了相應的處理,最關鍵的還是這一句res.end(body),它呼叫了node原生response的end方法,來告訴伺服器本次的請求以回傳body來結束,也就是告訴伺服器此響應的所有報文頭及報文體已經發出,伺服器在此呼叫後認為這條資訊已經傳送完畢,並且這個方法必須對每個響應呼叫一次。

總結

至此,koa整個流程已經走通,可以看到koa的關鍵點集中在ctx物件和中介軟體的運用上。 通過delegate將原生res和req的方法屬性代理至ctx上,再掛載koa內建的Request和Reponse,提供koa風格操作底層res和req的實現途徑和獲取請求資訊的工具方法。 中介軟體則是使用koa-compose庫將中介軟體串聯起來執行,並具有可以逆回執行的能力。

相關文章