跳到主要內容
黯羽輕揚每天積累一點點

koa 中間件與 async

免費2017-05-06#Node#koa async#koa中间件#koa异步中间件#koa异常处理#koa错误捕获

靈活清爽的中間件機制

寫在前面

相比 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,把請求按 methodurl 分發給對應的 route。路由與一般中間件的區別是路由通常與主要業務邏輯緊密相關,可以把請求處理過程分成 3 段:

請求預處理 -> 主要業務邏輯 -> 響應包裝處理

對應到中間件類型:

請求相關的中間件 -> 路由 -> 響應相關的中間件

雖然功能不同,但從結構上看,路由和一般的中間件沒有任何區別。router 是請求分發中間件,用來維護 urlroute 的關係,把請求交給對應 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 here100ms 後拋出未捕獲的異常

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

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論