2018年2月11日日曜日

【Node.js】リソースマネージャーといふもの

これまで、Node.jsでリクエストが切断されたときの処理レスポンス完了後の処理について説明してきました。

そこから導き出されたのは、PerlとかPHPと違って、リソースは勝手に解放されないからちゃんと解放しろよというもの。
正確には言語の違いというよりアーキテクチャの違いですけどね。

そして前回、Node.jsのリクエスト内でリソースを保持する方法と解放の仕方を説明しました。

今回はこれまでのまとめとして、前回の最後に書いた「リクエストが飛ぶと問答無用でリソースを確保するんじゃなくて、必要なときにだけ確保するには?」の実装例(間違いと正しい作法)を解説します。


まちがい

こんな感じのミドルウェアでの実装を考えた人がいるかもしれません。 openResource() / closeResource() は別途用意されているものとします。
function middleware(req, res, next) {
    let resource = null;
    req.getResource = () => {
        if (resource === null) {
            // 初回コール時にリソースを確保
            resource = openResource();
        }
        return resource;
    };

    // 終了時に解放
    onFinish(res, () => {
        if (resource !== null) {
            closeResource(resource);
        }
    });

    next();
}

function handler(req, res, next) {
    // 使うときはこうする
    const resource = req.getResource();
}
一見問題なさそうに見えます。
実際、リクエストを普通に処理して普通に終了する場合は問題ありません。 ちょっと問題点を考えてみましょう。

問題が発生するとき

前回同様、ここでも問題になるのはF5連打した場合です。
実際にやってみるとわかりますが、F5を連打するとリソースリークが発生してしまいます。

なぜか。

onFinish()のコールバックが呼ばれていないというわけではありません。
前回説明しましたが、onFinish()は「正常終了したとき」「途中で切断されたとき」「すでに切断されていたとき」の全てに対応できます。

前回との違い。それは、リソース確保と解放の順序です。

前回は、リソースを使おうが使うまいが必ず最初に確保していました。つまり、必ず「確保」→「解放」という順序が保証されていました。

今回はその順序が保証されていません。
なぜなら、req.getResource()がコールされる前にリクエストがクライアントとの接続が切れている可能性があるためです。

そして以前検証したとおり、シングルプロセスの非同期コールバックというアーキテクチャを採用しているNode.jsでは、クライアントからの接続が切れてもそれ以降のコードはまだ生きています

つまりどういうことだってばよ。

ちょっと話がややこしくなりましたね。↓つまりこういうこと↓
  1. クライアントからリクエストを受ける
  2. クライアントが切断
    • ここでonFinish()のコールバックが呼び出される
    • この時点で確保しているリソースはないので特に何もしない
  3. そのまま処理続行
    • 以前検証したとおり、クライアントからの接続が切れても処理は続行される
    • req.getResource()がコールされ、リソースが確保される
  4. 処理終了
    • もうonFinish()のコールバック関数は呼ばれない
    • つまり、確保されたリソースは解放されない

せいかい 

原因がわかれば対処は難しくありません。

いくつか方法はあると思いますが、リソースを解放した後はreq.getResource()を受け付けないようにしてしまうのが手っ取り早いでしょう。
すでにクライアントからの接続は切れているので、エラーを返してもクライアントにエラーメッセージが表示されることはありません。

こんなふうに変えてみましょう。
function middleware(req, res, next) {
    let finished = false;
    let resource = null;
    req.getResource = () => {
        if (finished) {
            // すでに終了していたらエラー
            throw new Error("Already finished!");
        }
        if (resource === null) {
            // 初回コール時にリソースを確保
            resource = openResource();
        }
        return resource;
    };

    // 終了時に解放
    onFinish(res, () => {
        if (resource !== null) {
            closeResource(resource);
        }
        finished = true;
    });

    next();
}
こうすれば、すでに接続が切れていた場合はリソースを確保しないので、リソースがリークすることはありません。

リソースマネージャーといふもの 

このコードは理論的には正しいのですが、ちょっとややこしいですよね。
しかも扱うリソースが1つだけならいいですが、MySQLにmemcachedにファイルハンドルに…と増えていくと、同じようなコードを書いていかなければいけません。

いい感じにライブラリで共通化できないかな…というわけで作ったパッケージがこれ。

@shimataro/resource-manager

上で解説したロジックに加えて、
  • 任意のリソース確保・解放に対応
  • 通常のリソース確保とSingletonパターンでの確保の両方をサポート
  • リソース確保時にオプション指定可
  • 配列・マップ・集合の3つは組み込みリソースとして定義済み
という、実運用を想定した機能がついています。

使い方

まずはnpmでインストール。
$ npm install -S @shimataro/resource-manager

ファクトリメソッドでリソースマネージャーを作成した後、リソース名と確保・解放の方法を任意に定義します。
そして、onFinish()のコールバック内でclose()メソッドをコールします。これにより、確保したリソースが全て解放されます。
import ResourceManager from "@shimataro/resource-manager";

function middleware(req, res, next) {
    req.resourceManager = ResourceManager.factory()
        .register(
            // リソース名(任意に設定可能)
            "resource1",
            (options) => {
                // リソース確保
                return openResource(options);
            },
            (resource) => {
                // リソース解放
                closeResource(resource);
            })
        .register(...); // 複数のリソースを登録する場合はメソッドチェーンで。

    // 終了時に、今まで確保したリソースをすべて解放
    onFinish(res, () => {
        req.resourceManager.close();
    });

    next();
}

リソースの確保

リソースの確保は、リソース名とオプションを指定することで行われます。
コールごとに別のリソースを作成することも、同じリソースを使いまわすこともできます。
function handler(req, res, next) {
    // リソース名とパラメータが同じでも呼ばれるたびに別のリソースを返す(resource1 !== resource2)
    const resource1 = req.resourceManager.open("resource1", {host: "localhost"});
    const resource2 = req.resourceManager.open("resource1", {host: "localhost"});

    // リソース名とオプションが同じ場合は以前確保したリソースを使いまわす(resourceSingle1 === resourceSingle2)
    const resourceSingle1 = req.resourceManager.openSingleton("resource1", {host: "192.168.0.1"});
    const resourceSingle2 = req.resourceManager.openSingleton("resource1", {host: "192.168.0.1"});
}

オプションの同一性判定 

オプションが同じかどうかは、JSON表現で同じかどうかで判定されます。
つまり、
  • 違うオブジェクト変数でも中身が同じなら同一
  • 同じオブジェクト変数でもプロパティの値が変わっていれば別物
  • {a:1,b:2}{b:2,a:1}は別物
と判定されます。

そして、close()をコールした後にopen()/openSingleton()をコールした場合は例外が投げられます。この仕組みによって先程のリソースリークを防いでいます。

設計思想として、close()処理の最後に一度だけコールされることを想定しています

組み込みリソース 

配列(array)・マップ(map)・集合(set)に関しては、あらかじめリソースマネージャーの中に組み込みリソースとして用意されていますので新しく作る必要はありません。
function handler(req, res, next) {
    /** @type {Map} */
    const map = req.resourceManager.openSingleton("map", "cache");
}
これはリクエスト内で有効なキャッシュとして使えます。
他のリソースと同様にclose()で解放されるので、WeakMapやWeakSetを使う必要がありません

なお、openSingleton("map", options)ではなくopen("map", options)を使うとコールするたびに別のリソースが確保されるので、キャッシュの意味がなくなってしまいます。ご注意ください。

リソースマネージャーをうまく活用して快適なNodeライフを。

0 件のコメント:

コメントを投稿