博客 / 詳情

返回

koa源碼

前提: 你需要對node的http模塊比較熟悉,同時瞭解相關的http知識,這很重要

目錄結構

Application

application.js主要是對 App 做的一些操作,包括創建服務、在 ctx 對象上掛載 request、response 對象,以及處理異常等操作。接下來將對這些實現進行詳細闡述。

Koa 創建服務的原理

  • Node 原生創建服務
const http = require("http");

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

server.listen(4000, () => {
  console.log("server start at 4000");
});
module.exports = class Application extends Emitter {
  /**
   * 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);
  }

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

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

    if (!this.listenerCount("error")) this.on("error", this.onerror);

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

    return handleRequest;
  }

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

中間件實現原理

中間件使用例子

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

app.use(async (ctx, next) => {
  console.log("---1--->");
  await next();
  console.log("===6===>");
});

app.use(async (ctx, next) => {
  console.log("---2--->");
  await next();
  console.log("===5===>");
});

app.use(async (ctx, next) => {
  console.log("---3--->");
  await next();
  console.log("===4===>");
});

app.listen(4000, () => {
  console.log("server is running, port is 4000");
});

註冊中間件

Koa 註冊中間件是用app.use()方法實現的

module.exports = class Application extends Emitter {
  constructor(options) {
    this.middleware = [];
  }

  /**
   * 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!");
    debug("use %s", fn._name || fn.name || "-");
    this.middleware.push(fn);
    return this;
  }
};

Application 類的構造函數中聲明瞭一個名為 middleware 的數組,當執行 use()方法時,會一直往 middleware 中的 push()方法傳入函數。其實,這就是 Koa 註冊中間件的原理,middleware 就是一個隊列,註冊一箇中間件,就進行入隊操作。

koa-compose

中間件註冊後,當請求進來的時候,開始執行中間件裏面的邏輯,由於有 next 的分割,一箇中間件會分為兩部分執行。

midddleware 隊列是如何執行?

const compose = require("koa-compose");

module.exports = class Application extends Emitter {
  callback() {
    // 核心代碼:處理隊列中的中間件
    const fn = compose(this.middleware);

    if (!this.listenerCount("error")) this.on("error", this.onerror);

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

    return handleRequest;
  }
};

探究下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
   */
  // 返回閉包,由此可知koa this.callback的函數後續一定會使用這個閉包傳入過濾的上下文
  return function (context, next) {
    // last called middleware #
    // 初始化中間件函數數組執行下標值
    let index = -1;
    // 返回遞歸執行的Promise.resolve去執行整個中間件數組
    // 從第一個開始
    return dispatch(0);
    function dispatch(i) {
      // 檢驗上次執行的下標索引不能大於本次執行的下標索引i,如果大於,可能是下箇中間件多次執行導致的
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      // 當前執行的中間件函數
      let fn = middleware[i];
      // 如果當前執行下標等於中間件數組長度,放回Promise.resolve()即可
      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);
      }
    }
  };
}

如何封裝 ctx

module.exports = class Application extends Emitter {
  // 3個屬性,Object.create分別繼承
  constructor(options) {
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

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

    if (!this.listenerCount("error")) this.on("error", this.onerror);

    const handleRequest = (req, res) => {
      // 創建context對象
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

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

中間件中的ctx對象經過createContext()方法進行了封裝,其實ctx是通過Object.create()方法繼承了this.context,而this.context又繼承了lib/context.js中導出的對象。最終將http.IncomingMessage類和http.ServerResponse類都掛載到了context.reqcontext.res屬性上,這樣是為了方便用户從ctx對象上獲取需要的信息。

單一上下文原則: 是指創建一個 context 對象並共享給所有的全局中間件使用。也就是説,每個請求中的 context 對象都是唯一的,並且所有關於請求和響應的信息都放在 context 對象裏面。
function respond(ctx) {
  // allow bypassing koa
  if (ctx.respond === false) return;

  if (!ctx.writable) return;

  const res = ctx.res;
  let body = ctx.body;
  const code = ctx.status;

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

  if (ctx.method === "HEAD") {
    if (!res.headersSent && !ctx.response.has("Content-Length")) {
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }

  // status body
  if (body == null) {
    if (ctx.response._explicitNullBody) {
      ctx.response.remove("Content-Type");
      ctx.response.remove("Transfer-Encoding");
      ctx.length = 0;
      return res.end();
    }
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code);
    } else {
      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 (typeof body === "string") 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);
}

錯誤處理

onerror (err) {
  // When dealing with cross-globals a normal `instanceof` check doesn't work properly.
  // See https://github.com/koajs/koa/issues/1466
  // We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
  const isNativeError =
    Object.prototype.toString.call(err) === '[object Error]' ||
    err instanceof Error
  if (!isNativeError) throw new TypeError(util.format('non-error thrown: %j', err))

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

  const msg = err.stack || err.toString()
  console.error(`\n${msg.replace(/^/gm, '  ')}\n`)
}

Context 核心實現(TODO)

Context 可以理解為上下文,其實就是我們常用的 ctx 對象。

Koa 要把 ctx.request 和 ctx.response 中的屬性掛載到 ctx 上的原因,即更方便獲取相關屬性。

委託機制

context.js 中的委託機制使用了一個包 delegates。可以幫我們方便快捷地使用設計模式當中的委託模式(Delegation Pattern),即外層暴露的對象將請求委託給內部的其他對象進行處理。

  • getter:外部對象可以直接訪問內部對象的值
  • setter:外部對象可以直接修改內部對象的值
  • access:包含 getter 與 setter 的功能
  • method:外部對象可以直接調用內部對象的函數

具體的用法請查看文檔,這裏不過多介紹。

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 = [];
}

如果 this 不是 Delegator 的實例的話,則調用 new Delegator(proto, target)。通過這種方式,可以避免在調用初始化函數時忘記寫 new 造成的問題,因為此時下面兩種寫法是等價的:

  • let x = new Delegator(petShop, 'dog')
  • let x = Delegator(petShop, 'dog')
Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);
​
proto.__defineGetter__(name, function(){
  return this[target][name];
});
​
return this;
};

__defineGetter__ ,它可以在已存在的對象上添加可讀屬性,其中第一個參數為屬性名,第二個參數為函數,返回值為對應的屬性值:

const obj = {};
obj.__defineGetter__('name', () => 'elvin');
​
console.log(obj.name);
// => 'elvin'
​
obj.name = '旺財';
console.log(obj.name);
// => 'elvin'

需要注意的是儘管 defineGetter 曾被廣泛使用,但是已不被推薦,建議通過 Object.defineProperty 實現同樣功能,或者通過 get 操作符實現類似功能:


const obj = {};
Object.defineProperty(obj, 'name', {
   value: 'elvin',
});
​
Object.defineProperty(obj, 'sex', {
 get() {
   return 'male';
 }
});
​
const dog = {
 get name() {
   return '旺財';
 }
};

不過我看 github 上別人提的 PR 到現在都沒合併

setter


Delegator.prototype.setter = function(name){
 var proto = this.proto;
 var target = this.target;
 this.setters.push(name);
​
 proto.__defineSetter__(name, function(val){
   return this[target][name] = val;
 });
​
 return this;
};

__defineSetter__,它可以在已存在的對象上添加可讀屬性,其中第一個參數為屬性名,第二個參數為函數,參數為傳入的值:

const obj = {};
obj.__defineSetter__('name', function(value) {
   this._name = value;
});
​
obj.name = 'elvin';
console.log(obj.name, obj._name);
// undefined 'elvin'

同樣地,雖然 defineSetter 曾被廣泛使用,但是已不被推薦,建議通過 Object.defineProperty 實現同樣功能,或者通過 set 操作符實現類似功能:

const obj = {};
Object.defineProperty(obj, 'name', {
 set(value) {
   this._name = value;
 }
});
​
const dog = {
 set(value) {
   this._name = value;
 }
};

method


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

method 的實現也十分簡單,只需要注意這裏 apply 函數的第一個參數是內部對象 this[target],從而確保了在執行函數 thistarget 時,函數體內的 this 是指向對應的內部對象。

Cookie 的操作

Koa 的服務一般都是 BFF 服務,涉及前端服務時通常會遇到用户登錄的場景。Cookie 是用來記錄用户登錄狀態的,Koa 本身也提供了修改 Cookie 的功能。

request 具體實現

request.js 的實現比較簡單,就是通過 set()和 get()方法對一些屬性進行封裝,方便開發者調用一些常用屬性。

  1. 獲取並設置 headers 對象
get header() {
  return this.req.headers
}

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

get headers() {
  return this.req.headers
}

set headers(val) {
  this.req.headers = val
}

headerheaders屬性一樣,兼容寫法。

  1. 獲取設置 res 對象的 URL
get url () {
  return this.req.url
},

set url (val) {
  this.req.url = val
},
  1. 獲取 URL 的來源,包括 protocol 和 host。
get origin () {
  return `${this.protocol}://${this.host}`
},
  1. 獲取完整的請求 URL
get href () {
  // support: `GET http://example.com/foo`
  if (/^https?:\/\//i.test(this.originalUrl)) return this.originalUrl
  return this.origin + this.originalUrl
},
  1. 獲取請求 method()方法
get method () {
  return this.req.method
},
  1. 獲取請求中的 path
get path() {
  return parse(this.req).pathname
}
  1. 獲取請求中的 query 對象
get query () {
  const str = this.querystring
  const c = this._querycache = this._querycache || {}
  return c[str] || (c[str] = qs.parse(str))
},

獲取 query 使用了querystring模塊const qs = require('querystring'),最終得到的是一個 Object。

  1. 獲取請求中的 query 字符串。
get querystring () {
  if (!this.req) return ''
  return parse(this.req).query || ''
},

ctx.request.querystring 輸出結果如下。

page=10
  1. 獲取帶問號的 querystring,與上面 get querystring()方法的區別是這裏多了個問號。

get search () {
  if (!this.querystring) return ''
  return `?${this.querystring}`
},
  1. 獲取主機(hostname:port),當 app.proxy 為 true 時,支持 X-Forwarded-Host,否則使用 Host。
get host () {
  const proxy = this.app.proxy
  let host = proxy && this.get('X-Forwarded-Host')
  if (!host) {
    if (this.req.httpVersionMajor >= 2) host = this.get(':authority')
    if (!host) host = this.get('Host')
  }
  if (!host) return ''
  return host.split(/\s*,\s*/, 1)[0]
},
  1. 存在時獲取主機名
get hostname () {
  const host = this.host
  if (!host) return ''
  // 如果主機是IPV6,koa解析到WHATWG URL API,注意,這可能會影響性能
  if (host[0] === '[') return this.URL.hostname || '' // IPv6
  return host.split(':', 1)[0]
},
  1. 獲取完整 URL 對象屬性
get URL () {
  /* istanbul ignore else */
  if (!this.memoizedURL) {
    const originalUrl = this.originalUrl || '' // avoid undefined in template string
    try {
      this.memoizedURL = new URL(`${this.origin}${originalUrl}`)
    } catch (err) {
      this.memoizedURL = Object.create(null)
    }
  }
  return this.memoizedURL
},
  1. 使用請求和響應頭檢查響應的新鮮度,會通過 Last-Modified 或 Etag 判斷緩衝是否過期。
get fresh () {
  const method = this.method
  const s = this.ctx.status

  // GET or HEAD for weak freshness validation only
  if (method !== 'GET' && method !== 'HEAD') return false

  // 2xx or 304 as per rfc2616 14.26
  if ((s >= 200 && s < 300) || s === 304) {
    return fresh(this.header, this.response.header)
  }

  return false
},
  1. 使用請求和響應頭檢查響應的陳舊度(和 fresh 相反)。
get stale () {
  return !this.fresh
},
  1. 檢測 this.method 是否是['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']中的方法。
get idempotent () {
  const methods = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']
  return !!~methods.indexOf(this.method)
},
  1. 獲取請求中的 socket 對象。

get socket () {
  return this.req.socket
},
  1. 獲取請求中的字符集。
get charset () {
  try {
    const { parameters } = contentType.parse(this.req)
    return parameters.charset || ''
  } catch (e) {
    return ''
  }
}
  1. 以 number 類型返回請求的 Content-Length。
get length () {
  const len = this.get('Content-Length')
  if (len === '') return
  return ~~len // 字符串轉數字
},
  1. 返回請求協議:“https”或“http”。當 app.proxy 是 true 時支持 X-Forwarded-Proto。
get protocol () {
  if (this.socket.encrypted) return 'https'
  if (!this.app.proxy) return 'http'
  const proto = this.get('X-Forwarded-Proto')
  return proto ? proto.split(/\s*,\s*/, 1)[0] : 'http'
},
  1. 通過 ctx.protocol == "https"來檢查請求是否通過 TLS 發出。
get secure () {
  return this.protocol === 'https'
},
  1. 當 app.proxy 為 true 時,解析 X-Forwarded-For 的 IP 地址列表
get ips () {
  const proxy = this.app.proxy
  const val = this.get(this.app.proxyIpHeader)
  let ips = proxy && val
    ? val.split(/\s*,\s*/)
    : []
  if (this.app.maxIpsCount > 0) {
    ips = ips.slice(-this.app.maxIpsCount)
  }
  return ips
},
  1. 獲取請求遠程地址。
get ip () {
  if (!this[IP]) {
    this[IP] = this.ips[0] || this.socket.remoteAddress || ''
  }
  return this[IP]
},
  1. 以數組形式返回子域。
get subdomains () {
  const offset = this.app.subdomainOffset
  const hostname = this.hostname
  if (net.isIP(hostname)) return []
  return hostname
    .split('.')
    .reverse()
    .slice(offset)
},
  1. 獲取請求Content-Type。
get type () {
  const type = this.get('Content-Type')
  if (!type) return ''
  return type.split(';')[0]
},

response 具體實現 (TODO)

response.js的整體實現思路和request.js大體一致,也是通過set()和get()方法封裝了一些常用屬性。

  1. 返回socket實例
get socket () {
  return this.res.socket
},
  1. 返回響應頭
get header () {
  const { res } = this
  return typeof res.getHeaders === 'function'
    ? res.getHeaders()
    : res._headers || {} // Node < 7.7
},

get headers () {
  return this.header
},
  1. 設置並獲取響應狀態碼。

http.ServerResponse對象的屬性:

  1. headerSent 當頭部已經有響應後,res.headerSent 為 true 否則為false 可以通過這個屬性來判斷是否已經響應.
  2. statusCode
  3. sendDate true/fasle 當為 false 時,將刪除頭部時間

get status () {
  return this.res.statusCode
},

set status (code) {
  if (this.headerSent) return

  assert(Number.isInteger(code), 'status code must be a number')
  assert(code >= 100 && code <= 999, `invalid status code: ${code}`)
  this._explicitStatus = true
  this.res.statusCode = code
  if (this.req.httpVersionMajor < 2) this.res.statusMessage = statuses[code]
  if (this.body && statuses.empty[code]) this.body = null
},
  1. 設置並獲取響應信息。
get message () {
  return this.res.statusMessage || statuses[this.status]
},
set message (msg) {
  this.res.statusMessage = msg
},
  1. 設置並獲取響應體body
get body () {
  return this._body
},

set body (val) {
  const original = this._body
  this._body = val

  // no content
  if (val == null) {
    if (!statuses.empty[this.status]) {
      if (this.type === 'application/json') {
        this._body = 'null'
        return
      }
      this.status = 204
    }
    if (val === null) this._explicitNullBody = true
    this.remove('Content-Type')
    this.remove('Content-Length')
    this.remove('Transfer-Encoding')
    return
  }

  // set the status
  if (!this._explicitStatus) this.status = 200

  // set the content-type only if not yet set
  const setType = !this.has('Content-Type')

  // string
  if (typeof val === 'string') {
    if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text'
    this.length = Buffer.byteLength(val)
    return
  }

  // buffer
  if (Buffer.isBuffer(val)) {
    if (setType) this.type = 'bin'
    this.length = val.length
    return
  }

  // stream
  if (val instanceof Stream) {
    onFinish(this.res, destroy.bind(null, val))
    if (original !== val) {
      val.once('error', err => this.ctx.onerror(err))
      // overwriting
      if (original != null) this.remove('Content-Length')
    }

    if (setType) this.type = 'bin'
    return
  }

  // json
  this.remove('Content-Length')
  this.type = 'json'
},
  1. 設置並獲取Content-Length。

set length (n) {
  if (!this.has('Transfer-Encoding')) {
    this.set('Content-Length', n)
  }
},

get length () {
  if (this.has('Content-Length')) {
    return parseInt(this.get('Content-Length'), 10) || 0
  }

  const { body } = this
  if (!body || body instanceof Stream) return undefined
  if (typeof body === 'string') return Buffer.byteLength(body)
  if (Buffer.isBuffer(body)) return body.length
  return Buffer.byteLength(JSON.stringify(body))
},
  1. 設置並獲取Content-Type。
set type (type) {
  type = getType(type)
  if (type) {
    this.set('Content-Type', type)
  } else {
    this.remove('Content-Type')
  }
},

get type () {
  const type = this.get('Content-Type')
  if (!type) return ''
  return type.split(';', 1)[0]
},
  1. 設置並獲取lastModified。
get lastModified () {
  const date = this.get('last-modified')
  if (date) return new Date(date)
},

set lastModified (val) {
  if (typeof val === 'string') val = new Date(val)
  this.set('Last-Modified', val.toUTCString())
},
  1. 設置並獲取Etag
set etag (val) {
  if (!/^(W\/)?"/.test(val)) val = `"${val}"`
  this.set('ETag', val)
},

get etag () {
  return this.get('ETag')
},

總結

koa本質上是對node的http模塊進行二次封裝,所以需要對使用者對http模塊有比較深的理解。同時也對洋葱模型的實現原理進行了説明,包括koa如何對requestresponse模塊對象的代理。

參考文章

  • Koa2 第三篇:koa-compose
  • koa 用到的 delegates NPM 包源碼閲讀
user avatar landejin 頭像 bell_lemon 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.