メインコンテンツへ移動

koa ミドルウェアと async

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

柔軟で爽やかなミドルウェアメカニズム

前言

express の保守的なのに対し、koa は比較的急進的です。現在 Node Stable はすでに v7.10.0 で、async&awaitv7.6 で豪華ランチに追加されました。このような素晴らしいものは必ず使う必要があります

現在の歴史から見ると、順序形式で非同期コードを記述することは自然な選択の結果です。Microsoft 製品の一連の言語、例えば 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 サポートを処理するなどです。リクエストが来るたびに、この全過程の各環節の処理は不可欠で、各環節はすべてミドルウェアです

ミドルウェアの作業方式は车间流水线に類似しており、1 枚の注文(原始リクエストデータ)が来て、ルーティングが対応する部門に配信し、Cookie フィールドを取り出し、解析完毕后結果を記入し、クエリ文字列を取り出し、各パラメータ対を解析し、記入し、リクエストボディを読み取り、解析して包装し、記入します……注文に補充された情報に基づき、车间が 1 つの製品を吐き出します……統一規格の簡単な包装(原始データを包装)を添え、ラベルを貼り(レスポンスヘッダー)、精装か平装かを考慮し(JSONP サポートを処理)、最後に出荷します

したがってミドルウェアは底層の詳細をカプセル化し、基礎機能を組織し、インフラと業務ロジックを分離するために使用されます

尾触发

最も一般的なミドルウェア組織方式は尾触发で、例えば:

// 一般中间件的结构:尾触发下一个中间件
var middleware = function(err, req, res, next) {
    // 把处理结果挂到请求对象上
    req.middlewareData = handle(req);
    // 通过 next 传递 err,捕获异步错误
    if (errorOccurs) {
        return next(error);
    }

    next();
};

すべてのミドルウェアを順序通りに繋ぎ、業務ロジック環節に至る時、必要なすべての入力項は事前に準備されリクエストオブジェクトに掛かっています(リクエスト関連のミドルウェアが完成)、業務ロジック実行完毕后レスポンスデータを取得し、直接後に投げ、レスポンス関連の一連のミドルウェアを通り、最終的にリクエスト側は予想に符合するレスポンスコンテンツを取得します。実際には私たちは業務ロジックのみに注目すればよく、前後のことは一連のミドルウェアが完成します

尾触发はすべてのミドルウェアを直列実行し、2 つの問題が存在します:

  • 並行最適化が欠如

  • エラーキャプチャメカニズムが煩雑

ミドルウェアを依存関係に基づきグループ化し、並行実行すれば、パフォーマンスを向上できます。1 層の抽象を追加すれば解決できます。エラーは手動で後に投げる必要があり、ミドルウェアチェーンに沿って手動で伝達し、比較的面倒で、解決しにくいです

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 があれば任意の位置で後続のミドルウェアを触发できます。例えば上記の 2 つのタイムスタンプ間の 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 はリクエスト配信ミドルウェアで、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 チェーン上の任意の 1 環の 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();
};

抓取成功后、datactx.state に掛け、resolve() で待機終了を通知し、next() で次のミドルウェアにレスポンスデータを包装させ、非常に爽やかです

プロジェクトアドレス:https://github.com/ayqy/RSSHelper/tree/master/node

参考資料

コメント

コメントはまだありません

コメントを書く