引言
最近讀了一下Koa2的原始碼;在閱讀Koa2 (2.3.0) 的原始碼的過程中,我的感受是整個程式碼設計精巧,思路清晰,是一個小而精的 nodejs web服務框架。
設計理念
作為web服務框架,都是要圍繞核心服務而展開的。那什麼是核心服務呢?其實就是接收客戶端的一個http的請求,對於這個請求,除了接收以外,還有解析這個請求。所以說會有
HPPT:接收 -> 解析 -> 響應
在響應客戶端的時候,也有很多種方式,比如返回一個html頁面,或者json文字。在解析請求和響應請求的中間,會有一些第三方的中介軟體,比如 日誌、表單解析等等來增強 koa 的服務能力,所以 koa 至少要提供 "請求解析"、"響應資料"、"中介軟體處理" 這三種核心能力的封裝,同時還需要有一個串聯他們執行環境的上下文(context)
- HTTP
- 接收
- 解析
- 響應
- 中介軟體
- 執行上下文
上下文可以理解為是http的請求週期內的作用域環境來託管請求響應和中介軟體,方便他們之間互相訪問。
以上分析是站在單個http請求的角度來看一個web服務能力。那麼站在整個網站,站在整個後端服務的角度來看的話,能夠提供 "請求"、"響應"、"解析"、"中介軟體"、"http流程全鏈路" 這些服務能力的綜合體,可以看做是一個應用服務物件。如果把這些全放到 koa 裡的話,那麼對應的就是:
- Application
- Context
- Request
- Response
- Middlewares
- Session
- Cookie
Koa的組成結構
首先看下koa的目錄結構
- application.js:框架入口;負責管理中介軟體,以及處理請求
- context.js:context物件的原型,代理request與response物件上的方法和屬性
- request.js:request物件的原型,提供請求相關的方法和屬性
- response.js:response物件的原型,提供響應相關的方法和屬性
// application.js
const isGeneratorFunction = require('is-generator-function'); // 判斷當前傳入的function是否是標準的generator function
const debug = require('debug')('koa:application'); // js除錯工具
const onFinished = require('on-finished'); // 事件監聽,當http請求關閉,完成或者出錯的時候呼叫註冊好的回撥
const response = require('./response'); // 響應請求
const compose = require('koa-compose'); // 中介軟體的函式陣列
const isJSON = require('koa-is-json'); // 判斷是否為json資料
const context = require('./context'); // 執行服務上下文
const request = require('./request'); // 客戶端的請求
const statuses = require('statuses'); // 請求狀態碼
const Cookies = require('cookies');
const accepts = require('accepts'); // 約定可被服務端接收的資料,主要是協議和資源的控制
const Emitter = require('events'); // 事件迴圈
const assert = require('assert'); // 斷言
const Stream = require('stream');
const http = require('http');
const only = require('only'); // 白名單選擇
const convert = require('koa-convert'); // 相容舊版本koa中介軟體
const deprecate = require('depd')('koa'); // 判斷當前在執行koa的某些介面或者方法是否過期,如果過期,會給出一個升級的提示
複製程式碼
以上是koa入口檔案的依賴分析。接下來我們進行原始碼分析,首先我們利用刪減法來篩出程式碼的核心實現即可,不用上來就盯細節! 我們只保留constructor
// application.js
module.exports = class Application extends Emitter {
constructor() {
super();
this.proxy = false; // 是否信任 proxy header 引數,預設為 false
this.middleware = []; //儲存通過app.use(middleware)註冊的中介軟體
this.subdomainOffset = 2; // 子域預設偏移量,預設為 2
this.env = process.env.NODE_ENV || 'development'; // 環境引數,預設為 NODE_ENV 或 ‘development’
this.context = Object.create(context); //context模組,通過context.js建立
this.request = Object.create(request); //request模組,通過request.js建立
this.response = Object.create(response); //response模組,通過response.js建立
}
// ...
}
複製程式碼
我們可以看到,這段程式碼暴露出一個類,建構函式內預先宣告瞭一些屬性,該類繼承了Emitter,也就是說這個類可以直接為自定義事件註冊回撥函式和觸發事件,同時可以捕捉到其他地方觸發的事件。
除了這些基本屬性之外,還有一些公用的api,最重要的兩個一個是==listen==,一個是==use==。koa的每個例項上都會有這些屬性和方法。
// application.js
module.exports = class Application extends Emitter {
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);
}
listen() {
const server = http.createServer(this.callback());
return server.listen.apply(server, arguments);
}
use(fn) {
this.middleware.push(fn);
return this;
}
}
複製程式碼
listen 方法內部通過 http.createServer 建立了一個http服務的例項,通過這個例項去 listen 要監聽的埠號,http.createServer 的引數傳入了 this.callback 回撥
// application.js
module.exports = class Application extends Emitter {
...
callback() {
const fn = compose(this.middleware); // 把所有middleware進行了組合,使用了koa-compose
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn); // 返回了本身的回撥函式
};
return handleRequest;
}
}
複製程式碼
可以看到,handleRequest 返回了本身的回撥,接下來看 handleRequest 。
handleRequest 方法直接作為監聽成功的呼叫方法。已經拿到了 包含 req res 的 ctx 和可以執行所有中介軟體函式的 fn。 首先一進來預設設定狀態碼為==404== . 然後分別宣告瞭 成功函式執行完成以後的成功 失敗回撥方法。這兩個方法實際上就是再將 ctx 分化成 req res。 分別調這兩個物件去客戶端執行內容返回。 ==context.js request.js response.js== 分別是封裝了一些對 ctx req res 操作相關的屬性,我們以後再說。
// application.js
module.exports = class Application extends Emitter {
...
handleRequest(ctx, fnMiddleware) {
const res = ctx.res; // 拿到context.res
res.statusCode = 404; // 設定預設狀態嗎404
const onerror = err => ctx.onerror(err); // 設定onerror觸發事件
const handleResponse = () => respond(ctx); // 向客戶端返回資料
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
}
複製程式碼
失敗執行的回撥
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();
}
複製程式碼
成功執行的回撥
function respond(ctx) {
...
}
複製程式碼
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
我們拆分理解,首先 return fnMiddleware(ctx) 返回了一箇中介軟體陣列處理鏈路,then(handleResponse) 等到整個中介軟體陣列全部完成之後把返回結果通過 then 傳遞到 handleResponse。
// application.js
module.exports = class Application extends Emitter {
...
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;
}
}
複製程式碼
這裡我們不用去太深入去摳程式碼,理解原理就行。createContext 建立 context 的時候,還會將 req 和 res 分別掛載到context 物件上,並對req 上一些關鍵的屬性進行處理和簡化 掛載到該物件本身,簡化了對這些屬性的呼叫。我們通過一張圖來直觀地看到所有這些物件之間的關係。
- 最左邊一列表示每個檔案的匯出物件
- 中間一列表示每個Koa應用及其維護的屬性
- 右邊兩列表示對應每個請求所維護的一些列物件
- 黑色的線表示例項化
- 紅色的線表示原型鏈
- 藍色的線表示屬性
createContext 簡單理解就是掛載上面的物件,方便整個上下游http能及時訪問到進出請求及特定的行為。
// application.js
module.exports = class Application extends Emitter {
...
}
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; // 賦值服務狀態碼
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);
}
// 通過判斷body型別來呼叫,這裡的res.end就是最終向客戶端返回資料的動作
if (Buffer.isBuffer(body)) return res.end(body);
if ('string' == typeof body) return res.end(body);
if (body instanceof Stream) return body.pipe(res);
// 返回為json資料
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
res.end(body);
}
複製程式碼
respond 函式是 handleRequest 成功處理的回撥,內部做了合理性校驗,諸如狀態碼,內容的型別判斷,最後向客戶端返回資料。
結語
以上就是我們對application.js檔案的分析,通過上面的分析,我們已經可以大概得知Koa處理請求的過程:當請求到來的時候,會通過 req 和 res 來建立一個 context (ctx) ,然後執行中介軟體。