寫在前面
相比 express 的保守,koa 則相對激進,目前 Node Stable 已經是 v7.10.0 了,async&await 是在 v7.6 加入豪華午餐的,這麼好的東西必須用起來
從目前歷史來看,以順序形式編寫異步代碼是自然選擇的結果。微軟出品的一系列語言,比如 F# 2.0(2010 年)就支持了該特性,C# 5.0(2012 年)也添加了該特性,而 JS 在 ES2016 才考慮支持 async&await,期間生態出現了一些過渡產品,比如 EventProxy、Step、Wind 等異步控制庫,ES2015 推出的 Promise、yield,以及在此基礎上實現的 co 模塊,都是為了讓異步流程控制更簡單
async&await 是最自然的方式(順序形式,與同步代碼形式上沒區別),也是目前最優的方案
P.S. 關於 JS 異步編程的更多信息,請查看:
-
[模擬 EventProxy_Node 異步流程控制 1](/articles/模擬 eventproxy-node 異步流程控制 1/)
-
[Step 源碼解讀_Node 異步流程控制 2](/articles/step 源碼解讀-node 異步流程控制 2/)
-
[模擬 Promise_Node 異步流程控制 3](/articles/模擬 promise-node 異步流程控制 3/)
-
[向 WindJS 致敬_Node 異步流程控制 4](/articles/向 windjs 致敬-node 異步流程控制 4/)
一。中間件
不像 PHP 內置了查詢字符串解析、請求體接收、Cookie 解析注入等基本的細節處理支持
Node 提供的是赤果果的 HTTP 連接,沒有內置這些細節處理環節,需要手動實現,比如先來個路由分發請求,再解析 Cookie、查詢字符串、請求體,對應路由處理完畢後,響應請求時要先包裝原始數據,設置響應頭,處理 JSONP 支持等等。每過來一個請求,這整個過程中的各個環節處理都必不可少,每個環節都是中間件
中間件的工作方式類似於車間流水線,過來一張訂單(原始請求數據),路由分發給對應部門,取出 Cookie 字段,解析完畢把結果填上去,取出查詢字符串,解析出各參數對,填上去,讀取請求體,解析包裝一下,填上去……根據訂單上補充的信息,車間吐出一個產品……添上統一規格的簡單包裝(包裝原始數據),貼上標籤(響應頭),考慮精裝還是平裝(處理 JSONP 支持),最後發貨
所以中間件用來封裝底層細節,組織基礎功能,分離基礎設施和業務邏輯
尾觸發
最常見的中間件組織方式是尾觸發,例如:
// 一般中間件的結構:尾觸發下一個中間件
var middleware = function(err, req, res, next) {
// 把處理結果掛到請求對象上
req.middlewareData = handle(req);
// 通過 next 傳遞 err,捕獲異步錯誤
if (errorOccurs) {
return next(error);
}
next();
};
把所有中間件按順序串起來,走到業務邏輯環節時,需要的所有輸入項都預先準備好並掛在請求對象上了(由請求相關的中間件完成),業務邏輯執行完畢得到響應數據,直接往後拋,走響應相關的一系列中間件,最終請求方得到了符合預期的響應內容,而實際上我們只需要關注業務邏輯,前後的事情都是由一串中間件完成的
尾觸發串行執行所有中間件,存在 2 個問題:
-
缺少並行優化
-
錯誤捕獲機制繁瑣
對中間件按依賴關係分組,並行執行,能夠提高性能,加一層抽象就能解決。錯誤需要手動往後拋,沿中間件鏈手動傳遞,比較麻煩,不容易解決
koa2.0 中間件
看起來很漂亮:
app.use(async (ctx, next) => {
const start = new Date();
await next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
一個簡單的響應耗時記錄中間件,如果放到中間件隊首,就能得到所有中間件執行的總耗時
與上面介紹的尾觸發不同,有了 await 就可以在任意位置觸發後續中間件了,例如上面兩個時間戳之間的 next(),這樣就不需要按照非常嚴格的順序來組織中間件了,靈活很多
之前之所以用尾觸發,就是因為異步中間件會立即返回,只能通過回調函數控制,所以約定尾觸發順序執行各中間件
而 async&await 能夠等待異步操作結束(這裡的等待是真正意義上的等待,機制類似於 yield),不用再特別關照異步中間件,尾觸發就不那麼必要了
二。路由
路由也是一種中間件,負責分發請求,例如:
router
.get('/', function (ctx, next) {
ctx.body = 'Hello World!';
})
.post('/users', function (ctx, next) {
// ...
})
.put('/users/:id', function (ctx, next) {
// ...
})
.del('/users/:id', function (ctx, next) {
// ...
})
.all('/users/:id', function (ctx, next) {
// ...
});
常見的 RESTful API,把請求按 method 和 url 分發給對應的 route。路由與一般中間件的區別是路由通常與主要業務邏輯緊密相關,可以把請求處理過程分成 3 段:
請求預處理 -> 主要業務邏輯 -> 響應包裝處理
對應到中間件類型:
請求相關的中間件 -> 路由 -> 響應相關的中間件
雖然功能不同,但從結構上看,路由和一般的中間件沒有任何區別。router 是請求分發中間件,用來維護 url 到 route 的關係,把請求交給對應 route
三。錯誤捕獲
await myPromise 方式中 reject 的錯誤能夠被外層 try...catch 捕獲,例如:
(async () => {
try {
await new Promise((resolve, reject) => {
setTimeout(() => {
let err = new Error('err');
reject(err);
}, 100);
});
} catch (ex) {
console.log('caught ' + ex);
}
})();
console.log('first log here');
注意,try...catch 錯誤捕獲僅限於 reject(err),直接 throw 的或者運行時異常無法捕獲。此外,只有在異步函數創建的那層作用域的 try...catch 才能捕獲到異常,外層的不行,例如:
try {
(async () => {
await new Promise((resolve, reject) => {
setTimeout(() => {
let err = new Error('err');
reject(err);
}, 100);
});
})();
console.log('first log here');
} catch (ex) {
console.log('caught ' + ex);
}
因為異步函數自身執行後立即返回,外層 try...catch 無法捕獲這樣的異步異常,會先看到 first log here,100ms 後拋出未捕獲的異常
而 Promise 有一個特殊機制:
特殊的:如果 resolve 的參數是 Promise 對象,則該對象最終的 [[PromiseValue]] 會傳遞給外層 Promise 對象後續的 then 的 onFulfilled/onRejected
(摘自 [完全理解 Promise](/articles/完全理解 promise/))
也就是說通過 resolve(nextPromise) 建立的 Promise 鏈上任意一環的 reject 錯誤都會沿著 Promise 鏈往外拋,例如:
(async () => {
try {
await new Promise((resolve, reject) => {
resolve(new Promise((rs, rj) => {
rs(new Promise((s, j) => {
setTimeout(() => {
j(new Error('err'));
}, 100);
}))
}))
});
} catch (ex) {
console.log('caught ' + ex)
}
})();
仍然能夠捕獲到最內層的錯誤
捕獲中間件錯誤
利用這個特性,可以實現用來捕獲中間件錯誤的中間件,如下:
// middleware/onerror.js
// global error handling for middlewares
module.exports = async (ctx, next) => {
try {
await next();
} catch (err) {
err.status = err.statusCode || err.status || 500;
let errBody = JSON.stringify({
code: -1,
data: err.message
});
ctx.body = errBody;
}
};
把這個中間件放在最前面,就能捕獲到後續所有中間件 reject 的錯誤以及同步錯誤
全域錯誤捕獲
上面捕獲了 reject 的錯誤和同步執行過程中產生的錯誤,但異步 throw 的錯誤(包括異步運行時錯誤)還是捕獲不到
而輕輕一個 Uncaught Error 就能讓 Node 服務整個掛掉,所以有必要添上全域錯誤處理作為最後一道保障:
// global catch
process.on('uncaughtException', (error) => {
console.error('uncaughtException ' + error);
});
這個自然要盡量放在所有代碼之前執行,而且要保證自身沒有錯誤
粗暴的全域錯誤捕獲不是萬能的,比如無法在錯誤發生後響應一個 500,這部分是錯誤捕獲中間件的職責
四。示例 Demo
一個簡單的 RSS 服務,中間件組織如下:
middleware/
header.js # 設置響應頭
json.js # 響應數據轉規格統一的 JSON
onerror.js # 捕獲中間件錯誤
route/
html.js # /index 對應的路由
index.js # /html/:url 對應的路由
pipe.js # /pipe 對應的路由
rss.js # /rss/:url 對應的路由
按順序應用各中間件:
// global catch for middles error
app.use(onerror);
// router
router
.get('/', function (ctx, next) {
ctx.body = 'RSSHelper';
})
.get('/index', require('./route/index.js'))
.get('/rss/:url', require('./route/rss.js'))
.get('/html/:url', require('./route/html.js'))
.get('/pipe', require('./route/pipe.js'))
app
.use(router.routes())
.use(router.allowedMethods())
// custom middlewares
app
.use(header)
.use(json)
請求預處理和響應數據包裝都由前後的中間件完成,路由只負責產生輸出(原始響應數據),例如:
// route /html
const fetch = require('../fetch/fetch.js');
module.exports = async (ctx, next) => {
await new Promise((resolve, reject) => {
const url = ctx.params.url;
let onsuccess = (data) => {
data = data || {};
ctx.state.data = data;
resolve();
}
let onerror = reject;
fetch('html', url)
.on('success', onsuccess)
.on('error', onerror)
});
next();
};
抓取成功後,把 data 掛到 ctx.state 上,resolve() 通知等待結束,next() 交由下一個中間件包裝響應數據,非常清爽
項目地址:https://github.com/ayqy/RSSHelper/tree/master/node
暫無評論,快來發表你的看法吧