NodeJS で非奨励の Domain を使ってコンテキスト的なものを持ち回すことなく横断的なトランザクション

NodeJS(TypeScript) でデータベースに繋いでみます。NodeJS だと MongoDB とかのが多い気もするのですが MySQL 脳なので MySQL です。

接続に使用するパッケージは mysql2 が Promise 対応していて良さそう、と思ったのですが型定義が @types になくて types/mysql2 の typings のものしか無いようなので今日日の TypeScript で使いにくいです。

mysql パッケージなら @types/mysql もあります。ただ Promise 非対応なので async/await しようとすると util.promisify() とかでてきて結構めんどいです。

import mysql from 'mysql'
import util from 'util'

(async () => {
    const pool = mysql.createPool(dbConfig);

    try {
        // promisify
        const getConnection = util.promisify(pool.getConnection).bind(pool);

        const conn = await getConnection();

        try {
            // promisify
            const beginTransaction = util.promisify(conn.beginTransaction).bind(conn);
            const commit = util.promisify(conn.commit).bind(conn);
            const rollback = util.promisify(conn.rollback).bind(conn);
            const query = util.promisify(conn.query).bind(conn);

            await beginTransaction();
            try {
                await query("insert into t (name) values ('xxx')");
                await commit();
            } catch (err) {
                await rollback();
                throw err;
            }
        } finally {
            conn.release();
        }
    } finally {
        pool.end();
    }
})()

promise-mysql は mysql パッケージを Promise 化したもので async/await でもシュッとかけます。

import mysql from 'promise-mysql'

(async () => {
    const pool = await mysql.createPool(dbConfig);
    try {
        const conn = await pool.getConnection();
        try {
            await conn.beginTransaction();
            try {
                await conn.query("insert into t values (null, 'xxx')");
                await conn.commit();
            } catch (err) {
                await conn.rollback();
                throw err;
            }
        } finally {
            conn.release();
        }
    } finally {
        await pool.end();
    }
})()

あるいは mariadb パッケージのほうが良さそうでしょうか。インポートするパッケージ名だけ変えればほぼほぼ promise-mysql と同じように使えます。微妙に同じメソッドでも Promise を返すかどうかの違いがあるようです(createPool とか)。

import mysql from 'mariadb'

ところで・・PHP だとグローバルなコンテキストがリクエストごとに作り直されるので、グローバルスコープ=リクエストスコープ、みたいな感じです。なので実質グローバルであるところの DI コンテナに DB 接続のオブジェクトを突っ込んで、どこでも DB 接続使えるっす、みたいな実装になってることが多いと思います。

その恩恵、かどうかはともかく、トランザクションをコントローラーやユースケースで制御しつつ、DB の処理はそれとは別のリポジトリクラスで処理してたりしました。

<?php
// HogeController.php
$conn->transactional(function () use ($aaa, $bbb) {
    // aaaRepo や bbbRepo へは DI コンテナから **このリクエストの** DB 接続がインジェクションされているので、
    // トランザクションを開始した DB 接続をリポジトリに渡す必要が無い
    $this->aaaRepo->save($aaa);
    $this->bbbRepo->save($bbb);
});

NodeJS(に限らずグローバル=リクエストではないプラットフォーム全般)だと、同じようなことやろうとすると DB 接続のインスタンスを引数でも持ち回す必要があると思うのだけどどうなの?(マルチスレッドなプラットフォームなら TLS=スレッドローカルストレージ が使えるのかもしれない)

と思ってググってたら次のようなものを見つけました。

Domain なるものを使って実現されているらしいですが・・記事の内容だけだとさっぱりわからない。

例えば次のように setTimeout() の中で投げた例外を拾うことができます。

import domain from 'domain'

const d = domain.create()

process.on('uncaughtException', (err) => {
    console.log(err.message);      // bbb
});

d.on('error', (err) => {
    console.log(err.message);      // aaa
    console.log(err.domain === d); // true
    console.log(err.domainThrown); // true
});

d.run(() => {
    setTimeout(() => {
        // この例外はドメインの中なのでドメインの error に行く
        throw new Error('aaa');
    }, 1);
});

setTimeout(() => {
    // この例外はドメインの外なので process の uncaughtException に行く
    throw new Error('bbb');
}, 1);

単に非同期例外が拾えるだけでなく、process.domaindomain.active で実行中の現在のドメインが得られます。なので次のようなことが可能です。

import domain from 'domain'

const Context = Symbol.for('Context');

const asyncCall = async () => {
    return new Promise((r) => {
        setTimeout(() => {
            // 現在のドメインを取り出す
            const context = (process.domain as any)[Context];
            console.log(`ここは ${context} の中です`);
            r();
        }, 1)
    });
};

(async () => {
    const arr = [
        (async () => {
            // ドメインを作成してその中で asyncCall を実行
            const d = domain.create();
            (d as any)[Context] = 'AAA';
            await d.run(asyncCall);
        })(),
        (async ()=>{
            // ドメインを作成してその中で asyncCall を実行
            const d = domain.create();
            (d as any)[Context] = 'BBB';
            await d.run(asyncCall);
        })(),
    ];

    await Promise.all(arr);
    // => ここは AAA の中です
    // => ここは BBB の中です
})()

これをうまく利用すれば次のようなトランザクションが実現できます。

import mysql from 'mariadb'
import domain from 'domain'

const Connection = Symbol.for('Connection');

const transaction = async (
    pool: mysql.Pool,
    callback: (conn: mysql.PoolConnection) => Promise<void>
) => {
    const conn = await pool.getConnection();
    try {
        const d = domain.create();
        (d as any)[Connection] = conn;
        await d.run(async () => {
            await conn.beginTransaction();
            try {
                await callback(conn);
                await conn.commit();
            } catch (err) {
                await conn.rollback();
                throw err;
            }
        });
    } finally {
        conn.release();
    }
}

const getCurrentConnection = (): mysql.PoolConnection => {
    return (process.domain as any)[Connection];
}

const insert = async () => {
    const conn = getCurrentConnection();
    await conn.query("insert into t values (null, 'xxx')");

    throw new Error('oops!!!');
};

(async () => {
    const pool = mysql.createPool(dbConfig);
    try {
        await transaction(pool, async () => {
            // コネクションとかトランザクションとかコンテキストとかのオブジェクトを渡す必要無し
            await insert();
        });
    } catch (err) {
        console.error(err);
    } finally {
        pool.end();
    }
})();

さいごに

ただし Domain は Deprecated です。

また、前述の記事でも結局 Domain をコードから削除してコンテキストのようなものを引数で渡すようにしたとのことです。

The lesson we will take away from this is to avoid the use of overly exotic constructs and to work with Node.js’ single-threaded grain, rather than against it.

ところで Async hooks でも似たようなことは実現できる? むしろ Domain 自体が Async hooks で実装 されている?

Async hooks は Experimental ですが Deprecated よりはマシなので、やるなら Async hooks の方を使うべきなのでしょう。