Koa 梳理分析【二:非同步中介軟體】
部落格原文地址 歡迎交流、star
上一章把koa
例項建立和處理請求的流程梳理了一遍,其中很多細節沒有分析,比如生成洋蔥中介軟體的compose
函式,context
、request
和responed
物件是怎麼構建的等。這一章就來梳理一下這些細節,學習koa
的思想和程式設計技巧。
洋蔥模型中介軟體
在原始碼中,是引入了koa-compose
工具函式來處理中介軟體的,最終合併成一個。
const fn = compose(this.middleware);
複製程式碼
先看一下compose
的簡單結構:
function compose (middleware) {
...
return function (context,next) {
...
}
}
複製程式碼
可以看到compose
函式是一個接受middleware
中間陣列並返回一個入參為context
和next
的函式。這裡在koa
原始碼中把這個返回的函式稱作為fnMiddleware
,它的外部呼叫形式為:
fnMiddleware(ctx).then(handleResponse).catch(onerror);
複製程式碼
在koa
中呼叫的時候傳入一個ctx
,next
並沒有傳入。可以看到這個函式的返回值是一個promise
。接下來來看看它的內部實現。
function compose (middleware) {
...
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)
}
}
}
}
複製程式碼
- 首先宣告一個變數
index
用來記錄當前準備執行的中介軟體。 - 宣告
dispatch
函式,接受一個下標,用來執行物件下標的中介軟體。 - 執行第一個中介軟體。
dispatch(0)
函式內部的核心在於dispatch
函式,這個函式的主要幾個步驟如下:
-
根據傳入的下標獲取對應的中介軟體函式,這裡會對下標的邊界做一些相容和查錯。
-
使用
Promise.resolve
決策一個promise
,這個promise
就是中介軟體執行後的返回值。例如我們有個中介軟體如下:
app.use(async function (ctx,next) {
return null
})
複製程式碼
一個async
函式已經會返回一個promise
,那麼在dispatch
函式內部為什麼還需要使用Promise.resolve
去包裹呢?因為我們傳入的中介軟體有可能就是普通的函式,所有這裡是做了一個相容。
-
重點放在
fn(context,dispatch.bind(null,i + 1))
,被執行的中介軟體,傳入了context
物件,這裡是直接傳入,這就是為什麼所有的中介軟體用到的ctx
是全域性唯一同一個引用。第二個引數就是next
函式,這裡把dispatch
綁定了下一個下標作為引數傳入。如果我們在我們的中介軟體中不執行next
函式,也就是沒有呼叫dispatch(i+1)
,下一個中介軟體也就不會被執行了。 -
使用
try/catch
來包裹中介軟體的執行,有錯誤就直接返回一個Promise.reject
。
相容生成器函式
在看koa
的原始碼的時候,可以看到在使用use
新增中介軟體的時候,會先對函式進行判斷,目前2.*
的版本下,會將生成器函式轉成async/await
函式。
// use
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);
}
複製程式碼
首先簡單的瞭解一下generator
函式。
特點:一是,function關鍵字與函式名之間有一個星號;二是,函式體內部使用yield表示式,定義不同的內部狀態(yield在英語裡的意思就是“產出”)。(引用自《ECMAScript 6 入門》) 當執行生產器的時候,會獲取一個遍歷物件,通過呼叫物件的
next()
方法就會返回一個有著value
和done兩個屬性的物件{ value: x,done: true/false }
。value
屬性表示當前的內部狀態的值,是yield
表示式後面那個表示式的值;done
屬性是一個布林值,表示是否遍歷結束。
簡單的介紹之後,可以看到generator
的執行,需要通過不停的呼叫next()
函式,但是async/await
是可以自動執行的。所以想要將generator
轉成類async/await
就需要有個輔助方法來自動執行generator
的next
函式。
這裡舉這樣的例子(來自可能是目前最全的koa原始碼解析指南)。
function* gen() {
yield new Promise(function (resolve,reject) {
// 做一些非同步操作
if (true) { // 成功
resolve()
} else {
reject()
}
})
yield new Promise(function (resolve,reject) {
// 做一些非同步操作
if (true) { // 成功
resolve()
} else {
reject()
}
})
}
let g = gen()
let ret = g.next() // 拿到第一個`promise`
複製程式碼
怎麼讓next()
繼續執行下去呢?看看如下程式碼。
let p = ret.value // 第一個 promise
p.then(() => { g.next() })
複製程式碼
如上,只需要使用一定的方式在每一個promise
的決策中再次呼叫next
方法,直到生成器被執行完。
如果想通過上面的方式去實現轉換,最終要的一步就是使每一個yield
後面返回都應該是一個promise
。
koa
裡面的convert
函式,最終是呼叫的是co
這庫來進行轉換的,所以來看看它是怎麼處理的。
function co(gen) {
var ctx = this;
var args = slice.call(arguments,1);
...
return new Promise(function(resolve,reject) {
if (typeof gen === 'function') gen = gen.apply(ctx,args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFulfilled();
...
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
return null;
}
...
function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}
...
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx,ret.value);
if (value && isPromise(value)) return value.then(onFulfilled,onRejected);
return onRejected(new TypeError('You may only yield a function,promise,generator,array,or object,'
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});
}
複製程式碼
- 整個函式返回一個
promise
物件,這與async/await
一致。 - 在返回的
promise
中,首先判斷是否為可執行的生成器函式,然後呼叫函式,獲取到遍歷物件。 - 然後第一次手動執行
onFulfilled
函式,這個函式就是來呼叫next()
方法的。 - 宣告
onRejected
函式,主要用來呼叫生成器的throw()
方法來結束執行和報錯的。 - 在3、4步驟中,只要呼叫了
g.next()
方法,最終都會呼叫co
自己宣告的next
函式,這個函式的主要工作就是將給promise
的value
轉成promise
,然後再在promise
的下一個非同步去呼叫onFulfilled
和onRejected
函式,以此往復。
以上就是如何把generator函式轉為類async的邏輯了。
每個請求的獨立context
在閱讀原始碼的過程中,在處理請求的回撥函式中, 都會呼叫createContext
函式來建立一個獨立且整個處理過程中唯一的context
物件。
const handleRequest = (req,res) => {
const ctx = this.createContext(req,res);
return this.handleRequest(ctx,fn);
}
複製程式碼
在中介軟體處理請求的時候,上一章說過,他們是共享這個context
物件的,在處理完之後統一交給response
物件去將結果響應給請求方。
// 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;
}
複製程式碼
createContext
函式通過Objectet.create
方法建立了一個原型為從context.js
檔案匯出的物件的一個新物件,然後在將request
和response
這兩個koa
自己擴充套件的物件和http
原生的req
和res
掛載上去了。所以我們在中介軟體處理響應的時候就可以直接通過ctx
訪問到原生的req
和res
物件了,並且能訪問到request
和response
上的擴充套件方法了。
委託模式
在處理請求的的時候,可以直接通過訪問ctx.header
、ctx.url
和ctx.method
來獲取一些請求頭或者去設定響應頭等。能夠這樣操作,主要是因為ctx.request
和ctx.response
的一些屬性和方法被委託到了ctx
這個物件上。如果對vue
比較熟悉的人,也會感受到vue
裡面的一些data
和method
可以直接在vue
的物件中訪問到,這些也是委託模式,將其他物件的屬性委託在了最上層的物件屬性上。
看一下,context.js
裡面委託的實現,這裡擷取其中幾行程式碼
delegate(proto,'request')
.method('acceptsLanguages')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
...
複製程式碼
這裡通過delegate
函式,將proto
物件裡面的request
屬性下面的方法委託到proto
物件上。這裡的delegate
函式是引入的一個庫const delegate = require('delegates');
。原始碼
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;
};
...
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;
};
複製程式碼
通過建構函式初始化委託的物件和被委託的目標物件,這裡的method
和getter
方法都是很簡單的,當訪問委託物件上的屬性時,就是去呼叫了target
上的屬性,只是需要注意this
的執行。然後getter
的呼叫也就是直接訪問了相應的值。當然這個庫還有其他一些方法,有興趣的可以去了解。
小結
通過分析,對koa
原始碼裡面的一些細節更加的清晰了,中介軟體的合併和co
函式的實現思想有了足夠的認識。分析原始碼還是收益不少,委託模式的使用可以應用在一些通用的庫上面,讓使用者能夠最直接的訪問到物件的屬性值,適當的減少了使用難度。
參考文章: