NodeJS で Async hooks を使うトランザクションの実用的な実装

前に書いた NodeJS で実験的な Async hooks を使って横断的なトランザクション をもう少し実用的な実装にしてみました。とは言っても NodeJS をプロダクション用に書くことが全くないのでホントに実用的になってるかどうかはわかりませんが。

// context.ts

import asyncHooks from 'async_hooks'

interface Pool<T extends Connection> {
    getConnection(): Promise<T>,
    end(): Promise<void>;
}

interface Connection {
    beginTransaction(): Promise<void>,
    commit(): Promise<void>,
    rollback(): Promise<void>,
    release(): void,
}

type PromiseValue<T> = T extends Promise<infer V> ? V : never;

type PoolConnection<TPool extends Pool<Connection>> = PromiseValue<ReturnType<TPool["getConnection"]>>;

export class Conext<
    TPool extends Pool<TConnection>,
    TConnection extends Connection = PoolConnection<TPool>
> {
    private readonly context: {[eid: string]: { conn: TConnection, trx: number }} = {};

    private asyncHooks: asyncHooks.AsyncHook;

    constructor(private pool: TPool) {
        this.asyncHooks = asyncHooks.createHook({
            init: (asyncId, type, triggerAsyncId, resource) => {
                if (this.context[triggerAsyncId]) {
                    this.context[asyncId] = this.context[triggerAsyncId];
                }
            },
            destroy: (asyncId) => {
                delete this.context[asyncId];
            }
        }).enable();
    }

    async connection<T>(callback: (conn: TConnection) => Promise<T>) {
        const eid = asyncHooks.executionAsyncId();
        if (this.context[eid]) {
            return await callback(this.context[eid].conn);
        }
        const conn = await this.pool.getConnection();
        try {
            const eid = asyncHooks.executionAsyncId();
            this.context[eid] = { conn: conn, trx: 0 };
            try {
                return await callback(conn);
            } finally {
                delete this.context[eid];
            }
        } finally {
            conn.release();
        }
    }

    async transaction<T>(callback: (conn: TConnection) => Promise<T>) {
        return await this.connection(async (conn) => {
            const eid = asyncHooks.executionAsyncId();
            if (this.context[eid].trx === 0) {
                this.context[eid].trx++;
                await conn.beginTransaction();
                try {
                    const ret = await callback(conn);
                    await conn.commit();
                    return ret;
                } catch (err) {
                    await conn.rollback();
                    throw err;
                } finally {
                    this.context[eid].trx--;
                }
            } else {
                this.context[eid].trx++;
                try {
                    return await callback(conn);
                } finally {
                    this.context[eid].trx--;
                }
            }
        });
    }

    async end() {
        try {
            return await this.pool.end();
        } finally {
            this.asyncHooks.disable();
        }
    }
}

ジェネリクスを用いて mysql や mariadb のパッケージに直接依存しないようにしました。

使い方

コネクションプールを作成し、それをコンストラクタに渡して Conext のインスタンスを作ります。 このインスタンスは例のようにパッケージスコープでエクスポートしてもいいですし、DIコンテナを使っているならDIコンテナに入れるなどしても良いと思います。

// db.ts

import mysql from 'mariadb'
import { Conext } from './conext'

const dbConfig: mysql.PoolConfig = {
    host: process.env.MYSQL_HOST,
    port: parseInt(process.env.MYSQL_PORT || ''),
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_PASSWORD,
    database: process.env.MYSQL_DATABASE,
}

const pool = mysql.createPool(dbConfig);

export default new Conext(pool);

実際に使ってみる例です。トランザクションが必要ないときは connection を、トランザクションが必要なときは transaction を使います。どちらの場合もそのスコープ、およびそのスコープから呼ばれた同期/非同期関数では同じ接続が使用されます。

// index.ts

import db from './db'

(async () => {

    const func1 = async () => {
        // このスコープの中では同じコネクションが使用される
        await db.connection(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func1`);

            await conn.query('select sleep(1)');
            await func2();
        });
    };

    const func2 = async () => {
        // ネストされたトランザクションは一番外側でのみ begin/commit される
        await db.transaction(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func2`);

            await conn.query("insert into t (name) values ('aaa')");
            await conn.query('select sleep(1)');
            await func3();
        });
    };

    const func3 = async () => {
        // このトランザクションは func2 の内側なので begin/commit されない
        await db.transaction(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func3`);

            await conn.query("insert into t (name) values ('zzz')");
        });
    };

    try {
        // 5並行で func1 -> func2 -> func3 を実行
        // 5接続しか使用されず func1/func2/func3 では同じ接続が使用される
        await Promise.all(Array.from(Array(5)).map(async () => func1()));

        // 30並行でクエリを実行
        // プールの接続数のデフォが10なので10接続しか使用されない
        // 1クエリで1秒かかるので合計で3秒ぐらいかかる
        const start = new Date().getTime();
        const ids = await Promise.all(Array.from(Array(30)).map(async () => {
            return await db.connection(async (conn) => {
                await conn.query('select sleep(1)');
                const [{cid}] = await conn.query('select connection_id() as cid');
                return cid;
            });
        }));
        console.log(`connection_ids:`, new Set(ids))
        console.log(`duration: ${new Date().getTime() - start} ms`);

    } catch (err) {
        console.error(err);
    } finally {
        await db.end();
    }
})()

さいごに

NodeJS のサーバサイドだとどうしてもコンテキスト的なものを引き回す必要があるのだと思っていたのですが、Async hooks なんて便利なものが(Experimental だけど)出来ていたんですね。

Async hooks は 3 年ぐらい前に出てきたもののようです。NodeJS のトレンドは全然追えていませんが、NodeJS のフレームワークでは Async hooks がもっと活用されてくるようになってきたりするのでしょうか。

NodeJS で実験的な Async hooks を使って横断的なトランザクション

NodeJS で非奨励の Domain を使ってコンテキスト的なものを持ち回すことなく横断的なトランザクション というのをやってみましたが、よくよく見てみれば Async hooks でも同じことができそうでした。

Domain は Deprecated でしたが Async hooks は Experimental なのでこっちのほうが良いです。しかも下記のコードを見るに Domain も実は Async hooks で実装されているんですね。

例えば次のように非同期コールバックの中で外側のコンテキスト的なものを取り出すことができます。

import asyncHooks from 'async_hooks'

const contexts: {[eid: string]: string} = {};

const asyncCall = async () => {
    return new Promise((r) => {
        setTimeout(() => {
            // 現在の ID を元にコンテキストを取り出す
            const eid = asyncHooks.executionAsyncId();
            const context = contexts[eid];
            console.log(`ここは ${context} の中です`);
            r();
        }, 1)
    });
};

(async () => {
    asyncHooks.createHook({
        init(asyncId, type, triggerAsyncId, resource) {
            // 新しい非同期リソースが作られたときに作成元のコンテキストをコピーする
            if (contexts[triggerAsyncId]) {
                contexts[asyncId] = contexts[triggerAsyncId];
            }
        },
        destroy(asyncId) {
            delete contexts[asyncId];
        }
    }).enable();

    const arr = [
        (async () => {
            // 現在の ID を元にコンテキストを設定
            const eid = asyncHooks.executionAsyncId();
            contexts[eid] = 'AAA';
            await asyncCall();
        })(),
        (async () => {
            // 現在の ID を元にコンテキストを設定
            const eid = asyncHooks.executionAsyncId();
            contexts[eid] = 'BBB';
            await asyncCall();
        })(),
    ];

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

例えるなら executionAsyncId() が現在のスレッド ID で、init フックはスレッドが新しく作成されたときに実行され、asyncId が新しいスレッドの ID、triggerAsyncId がスレッドを作成した元のスレッドの ID、みたいに理解するとわかりやすいでしょうか。スレッドではないけど(1つの非同期リソースから複数回コールバックが実行されることもあるのでたとえ話にしてもこの説明は正しくない)。

前回と同じようなトランザクションは次のように実装できます。

import mysql from 'mariadb'
import asyncHooks from 'async_hooks'

const connections: {[eid: string]: mysql.PoolConnection} = {};

const transaction = async (
    pool: mysql.Pool,
    callback: (conn: mysql.PoolConnection) => Promise<void>
) => {
    const conn = await pool.getConnection();
    try {
        const eid = asyncHooks.executionAsyncId();
        connections[eid] = conn;
        try {
            await conn.beginTransaction();
            try {
                await callback(conn);
                await conn.commit();
            } catch (err) {
                await conn.rollback();
                throw err;
            }
        } finally {
            delete connections[eid];
        }
    } finally {
        conn.release();
    }
}

const getCurrentConnection = () => {
    const eid = asyncHooks.executionAsyncId();
    return connections[eid];
}

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

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

(async () => {
    asyncHooks.createHook({
        init(asyncId, type, triggerAsyncId, resource) {
            if (connections[triggerAsyncId]) {
                connections[asyncId] = connections[triggerAsyncId];
            }
        },
        destroy(asyncId) {
            delete connections[asyncId];
        }
    }).enable();

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

さいごに

というかググると Async hooks の例がたくさん出てくるので割とありふれたもののようです。Domain なんてのを知ってはしゃいでた自分が周回遅れなだけでした・・NodeJS、完全にすら理解できていなかった・・

以下の記事での内容から察するに、かつては Domain は setTimeout を書き換えるような方法で実装されていたのでしょうか。

Async hooks が出てきてからは Domain の実装も Async hooks に置き換わった、ということのようです、たぶん。

AWS の Billing Alert(請求アラート)を日々の増加分で設定する

AWS の請求アラートは昔は CloudWatch Alarm を使う必要がありましたが、最近なら AWS Budgets で簡単に設定できます。便利です。

がしかし、AWS Budgets はもっとも短いスパンでも月次の請求額なので、次のようなケースにはフィットしません。

  • 検証用の AWS アカウントなので普段はインスタンスなどを立ち上げておらずまったく費用はかからない
  • たまに検証のためにインスタンスなどを立ち上げたとしてもその日のうちに削除して立ち上げっぱなしにはしない

このケースでアラートしてほしいのは「インスタンスなどを削除し忘れていて課金されっぱなしになっている」ということなので、月次の請求額のアラートではあまり意味がありません。

(もっとも検証用のアカウントでも、だいたい月にこれくらいまで、という予算はあると思うのでそれを超えないようにするための月次のアラートはそれはそれで有用だと思いますが)

ので、CloudWatch Alarm で請求額の日々の増加分に対してアラートを仕込んでみました。

Terraform のテンプレートは次のような感じ。

provider "aws" {
  alias  = "us-east-1"
  region = "us-east-1"
}

variable "alarm_name" {}

resource "aws_sns_topic" "billing" {
  provider = aws.us-east-1
  name     = "BillingAlarm"
}

resource "aws_cloudwatch_metric_alarm" "billing_rate" {
  provider            = aws.us-east-1
  alarm_name          = var.alarm_name
  alarm_description   = "Billing rate over 0.1 USD in 6 hours"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  datapoints_to_alarm = 1
  threshold           = 0.1
  alarm_actions       = [aws_sns_topic.billing.arn]

  metric_query {
    id = "m1"

    metric {
      namespace   = "AWS/Billing"
      metric_name = "EstimatedCharges"
      period      = 6 * 60 * 60
      stat        = "Maximum"
      dimensions = {
        Currency = "USD"
      }
    }
  }

  metric_query {
    id         = "e1"
    expression = "RATE(m1) * 60 * 60 * 6"
  }

  metric_query {
    id          = "e2"
    expression  = "IF(e1>0, e1, 0)"
    label       = "USD/24H"
    return_data = true
  }
}

RATE 関数でメトリクスの直前値との差分を1秒あたりに換算した変化分が得られます。これに 60 * 60 * 6 して 6 時間分の増分にしています。日々の、と言っておきながら実際には 6 時間分のものになっていますが、請求額のメトリクスがだいたい 6 時間ごとの値なのでそれに合わせています。

月替りでメトリクスが 0 にリセットされるためその際に RATE がマイナス値になります。アラートしきい値としてはそのままでも問題ありませんが、グラフで見たときに変なので IF(e1>0, e1, 0) で 0 以下のメトリクスは 0 としています。

さいごに

結構前から仕込んでいていい感じにアラートしてくれていたのですが、

AWS Budgets で日次予算でアラートが仕込めるようになったようです。よってこの記事の内容はもう用済みです。

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 の方を使うべきなのでしょう。

VSCode で WSL 上のシンボリックリンクを含むパスで NodeJS(TypeScript) をデバッグ

  • Fedora 31 on WSL1
  • NodeJS 14.4.0
  • VSCode 1.48.2

Remote WSL で VSCode を WSL 上で簡単に実行できるので、次のような Launch Configuration だけでリモートデバッグ出来ます。また、runtimeExecutable やら runtimeArgs やらで ts-node を使えば ts が直接実行できます。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "restart": true,
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/src/app.ts",
            "runtimeExecutable": "ts-node",
            "runtimeArgs": ["-T"],
        }
    ]
}

ホスト機が Windows であることなどまったく気にする必要ありません。完。

シンボリックリンク内のワークスペースの問題

と思っていたのですが、実際にやってみたらダメでした。ワークスペースのパスにシンボリックリンクが含まれていてその中で VSCode を開いていると、ブレークポイントを設定しても Unbound breakpoint とか言われて止められません。

例えば、次のようなシンボリックリンクがあり、

ln -sfn /c/Users/oreore/devel /home/oreore/devel

ワークスペースのパスが /home/oreore/devel/path/to/project だとすると、それをそのまま開いてもダメでした。/c/Users/oreore/devel/path/to/project を開く必要があります。

諸事情で普段いつもパスにシンボリックリンクが含まれている場所で作業しているので、これだとかなり都合が悪いです。

下記の Issue によると runtimeArgs--preserve-symlinks--preserve-symlinks-main を指定すれば良い、とのことですが、ダメでした。

VSCode の設定で debug.javascript.usePreview: false を指定すれば大丈夫でした。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "restart": true,
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/dist/app.js",
            "runtimeArgs": [
                "--preserve-symlinks",
                "--preserve-symlinks-main"
            ]
        }
    ]
}

ただ node コマンドのオプションを指定する必要があるため ts-node は使えません。runtimeArgs-r ts-node/register/transpile-only を付けるようにしてもダメでした。

outFiles でビルドされた js ファイルを指定すれば実行できました。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "restart": true,
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/src/app.ts",
            "outFiles": ["${workspaceFolder}/dist/**/*.js"],
            "runtimeArgs": [
                "--preserve-symlinks",
                "--preserve-symlinks-main"
            ]
        }
    ]
}

ただこれだとあくまでもビルドされた js が実行されるだけであって ts ファイルが直接実行されているわけではありません。

次のようにコマンドを叩けば node コマンドからでも直接 ts ファイルが実行できているんですけど・・なんででしょうね?

node --preserve-symlinks --preserve-symlinks-main -r ts-node/register/transpile-only src/app.ts

VSCode の NodeJS デバッガー拡張

複数あるもよう。

ms-vscode.js-debug が最も新しいデバッガーで debug.javascript.usePreview: true のときに使用されます(デフォルト true なので明示的に無効にしない限りこれが使用される)。

ms-vscode.node-debugms-vscode.node-debug2debug.javascript.usePreview: false のときに使用されます。どちらが使用されるかは NodeJS のバージョンで自動的に決定されます。Launch Configuration で指定することもできます。

また、Launch Configuration の typepwa-node を指定すると debug.javascript.usePreview には依らず ms-vscode.js-debug 使用されるようです。

なお、この pwa は プログレッシブウェブアプリケーション のことではないようです。

node のコマンドの開始方法も異なります。ms-vscode.js-debug の場合は環境変数 NODE_OPTIONS にデバッガに必要なオプションが指定されます。なので ts-node などを runtimeExecutable に指定することができます。一方で ms-vscode.node-debugms-vscode.node-debug2 ではコマンドラインオプションに --debug-brk--inspect-brk が直接追加されます。

前述のシンボリックリンク問題が --preserve-symlinks--preserve-symlinks-main で解決するというのは、リンクされている PR が ms-vscode.node-debug2 のものなので、ms-vscode.js-debug だと効果ないようです。

また、ms-vscode.node-debug2 だと program で指定されたファイルが js かどうかを判定して、js ではない場合はソースマップから js ファイルを特定してそちらを実行するようになっています。

拡張子が .js .es6 .jsx .mjs のいずれかだと js だと判定されるようです。

Launch Configuration で __debuggablePatterns*.ts を指定すれば ts ファイルを直接実行させられる?

と思ったんですがダメでした。

remoteRoot で実際のパスを指定

debug.javascript.usePreview: true のままでも、remoteRoot でシンボリックリンクを解決した実際のパスを指定すれば大丈夫でした。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "restart": true,
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/src/app.ts",
            "runtimeExecutable": "ts-node",
            "runtimeArgs": ["-T"],
            "localRoot": "${workspaceRoot}",
            "remoteRoot": "/c/Users/oreore/devel/path/to/project",
        }
    ]
}

remoteRoot にパスをベタ書きする必要があるのがちょっとイケていないですね。

VSCode を Windows で実行して WSL の node プロセスにアタッチ

VSCode は Windows 上で実行しつつ、WSL 内で node --inspect dist/app.js のように実行し、下記のような Launch Configuration でインスペクタのポートにアタッチしても良さそうです。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "restart": true,
            "name": "Attach to Remote",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "address": "127.0.0.1",
            "port": 9229,
            "localRoot": "${workspaceFolder}",
            "remoteRoot": "/c/Users/oreore/devel/path/to/project",
        },
    ]
}

ただこの場合は node コマンドを使う必要があるので ts ファイルではなく js ファイルを実行する必要があります。 あるいは次のように -r ts-node/register/transpile-only を指定すれば node コマンドを使いつつ ts ファイルが直接実行できます。ただ、この場合はプロジェクトの node_modulests-nodetypescript が必要です。

node --inspect -r ts-node/register/transpile-only src/app.ts

あるいは NODE_OPTIONS 環境変数で --inspect オプションを指定しても OK です。この方法なら ts-nodetypescript はグローバルにあれば十分です。

env NODE_OPTIONS=--inspect ts-node -T src/app.ts

さいごに

シンボリックリンクを含むパスの中で作業するという変なことをやっていなければハマることもなくもっと簡単だったのかも。。。

自分用 PHP の Docker イメージを Docker Hub で自動ビルド

ngyuki/php-dev - Docker Hub に自分用の PHP の Docker イメージを置いています。これを新しい PHP のバージョンがリリースされたときに自動ビルドするために試行錯誤したメモです。

Docker Hub の Repository Links で自動ビルド → ダメ

最近になってから構成をガラッと変えているのですが、以前は次のような構成でした。

├── hooks/
│   ├── build
│   └── post_push
├── check.php
├── Dockerfile
├── Makefile
└── README.md

このリポジトリでは下記の PHP マイナーバージョンの最新版をビルドしています。

  • 7.1
  • 7.2
  • 7.3
  • 7.4

ですが Dockerfile はひとつだけ です。次のように ARG でビルドのベースになるイメージを指定できるようにしています。

ARG BASE_IMAGE
FROM ${BASE_IMAGE}

docker build のコマンドラインオプションでベースイメージが指定できます。

docker build . --build-arg BASE_IMAGE=php:7.4-alpine -t ngyuki/php-dev:7.4

Docker Hub でのビルドの実行時、リポジトリの中に hooks/build が存在すればこれがデフォルトのビルドコマンドの代わりに使用されます(Advanced options for Autobuild and Autotest | Docker Documentation)。

これを使用して hooks/build で次のように --build-arg を指定します。

docker build . --pull --build-arg BASE_IMAGE=php:$DOCKER_TAG-alpine -f $DOCKERFILE_PATH -t $IMAGE_NAME

Docker Hub のビルドルールでは Docker Tag だけ異なる複数の設定を作っておきます。

f:id:ngyuki:20200911092328p:plain

これで master へプッシュすれば自動的に複数のバージョンがビルドされます。さらに REPOSITORY LINKS を有効にして、ベースイメージが更新されたときに自動でビルドが実行されるようにします。

f:id:ngyuki:20200911092349p:plain

と思っていたのですが吹き出しの部分をよく見ると、アンオフィシャルイメージでしか効果ない、と書いてるじゃないですか・・・なぜか長いこと気づいていませんでした。

そもそものところ、ベースイメージを ARG 指定するような方法だと静的に Dockerfile が解釈できないため、たとえアンオフィシャルイメージを使っていたとしても REPOSITORY LINKS は効かないのでは・・?(未確認)

Dependabot を使う → ダメ

Docker Hub の REPOSITORY LINKS で自動的にビルドするのは無理そうなので、新しいバージョンがリリースされたときに自動的に Pull Request を出してくれる系のサービスを使うことにします。

ざっとググったところ Dependabot というのが有名なようです。これ GitHub に買収 されていて、GitHub にネイティブに統合 されているので、Github の Insights > Dependency graph > Dependabot で状態が閲覧できたりするようです。

それなら Dependabot 一択かな・・と思ったのですが、GitHub ネイティブ統合版だと Pull Request を作成するまではやってくれるものの自動マージはできないようです。automerge のような設定がありません

Dependabot Preview と呼ばれる GitHub ネイティブ統合前のものなら 自動マージをサポート しているのでこちらを使おうとしたのですが、7.3.21 -> 7.3.22 のようにパッチバージョンだけを更新させる方法がわかりませんでした。

設定の automerged_updates には semver:patch なる条件があるのですが allowed_updates で同じものは指定できません。

ので 7.3.21 -> 7.4.9 のようにマイナーバージョンが更新されてしまいます。複数のマイナーバージョンをビルドしたいのでこれでは都合が悪いです。

Renovate を使う

Dependabot だとダメそうなので Renovate を使うことにしました。リポジトリの構成は次のようになりました。

├── 7.1/
│   ├── Dockerfile
│   └── hooks -> ../hooks/
├── 7.2/
│   ├── Dockerfile
│   └── hooks -> ../hooks/
├── 7.3/
│   ├── Dockerfile
│   └── hooks -> ../hooks/
├── 7.4/
│   ├── Dockerfile
│   └── hooks -> ../hooks/
├── hooks/
│   ├── build
│   ├── post_push
│   └── test
├── Dockerfile.in
├── Makefile
├── README.md
├── renovate.json
├── check.php
└── latest -> 7.4

Dockerfile に具体的なバージョン番号を書く必要があるので Dockerfile はバージョンごとに作成します。中身はほとんど同じなので Dockerfile.in をテンプレートとして作成しています。

Docker Hub のビルドルールは次の通り。バージョンごとにビルドコンテキストを指定しています。

f:id:ngyuki:20200911092401p:plain

Renovate で自動マージするためには CI で OK になる必要があります。ので Docker Hub の Autotest を有効にしています。

f:id:ngyuki:20200911092422p:plain

Autotest は docker-compose.test.ymlsut というサービスを定義しておくと実行されます。

# docker-compose.test.yml
sut:
  build: .
  command: run_tests.sh

がしかし docker-compose.test.yml をビルドコンテキストごと、つまりバージョンごとに配置するのも煩雑な気がしたし、既に hooks/ はすべてのビルドコンテキストでシンボリックリンクで共有するようにしているので、それならばと思ってテストもカスタムフックスクリプト hooks/test で実行することにしました。

#!/bin/bash

echo Running custom test

set -eux

docker run --rm -i "$IMAGE_NAME" -d zend_extension=xdebug.so -d opcache.enable_cli=1 < ../check.php

また、最新版に latest タグが付くように hooks/post_push フックを使っています。リポジトリの latest シンボリックリンクが最新版のディレクトリを指すようにしているので、スクリプトでビルドコンテキスト(カレントディレクトリ)と latest シンボリックリンクの位置が同じかチェックして、同じなら latest タグもプッシュします。

#!/bin/bash

echo Running post push

set -eux

if [ "$(readlink -f .)" == "$(readlink -f ../latest)" ]; then
  docker tag "$IMAGE_NAME" "$DOCKER_REPO:latest"
  docker push "$DOCKER_REPO:latest"
fi

renovate.json は Renovate の設定です。separateMinorPatch: true にすればマイナーバージョンとパッチバージョンで別々に PR が出ます(7.3.21 -> 7.3.227.3.21 -> 7.4.9 が別になる、という意味)。さらに packageRules でメジャーバージョンとマイナーバージョンの更新を無効、パッチバージョンの更新だけ有効にします。

// renovate.json
{
    "extends": [
        "config:base"
    ],
    "timezone": "Asia/Tokyo",
    "labels": ["renovate"],
    "enabledManagers": ["dockerfile"],
    "rebaseWhen": "behind-base-branch",
    "prHourlyLimit": 10,
    "prConcurrentLimit": 10,
    // マイナーバージョンとパッチバージョンの更新を分ける
    "separateMinorPatch": true,
    "packageRules": [
        {
            // メジャーバージョンとマイナーバージョンの更新は無効
            "datasources": ["docker"],
            "updateTypes": ["major", "minor"],
            "enabled": false
        },
        {
            // パッチバージョンの更新を有効にする
            "datasources": ["docker"],
            "updateTypes": ["patch"],
            "enabled": true,
            // 1つのグループ(PR)にまとめる
            "groupName": "php",
            // 自動マージを有効にする
            "automerge": true
        }
    ]
}

これで次のような PR が自動で作成&テストが通れば自動でマージされて Docker Hub のイメージが更新されます。

これは便利!

さいごに

よく考えたらもっとシンプルに次のような構成でも良かったかも・・

├── hooks/
│   ├── build
│   ├── post_push
│   └── test
├── Dockerfile.7.1
├── Dockerfile.7.2
├── Dockerfile.7.3
├── Dockerfile.7.4
├── Dockerfile.in
├── Makefile
├── README.md
├── renovate.json
├── check.php
└── latest -> Dockerfile.7.4

renovate.jsonfilematch で Dockerfile のパターンは指定出来るし。うーん、なにか理由があってビルドコンテキストを分けたような気がするのだけど・・なんだったかな?

また、今なら Docker Hub でカスタムフックスクリプトを使っていろいろやるぐらいなら Github Actions でビルド&プッシュする方が簡単かもしれません。Docker Hub だとなかなか確認に時間がかかります。

Slim4 を使ってみたメモ

とある社内用のツールで Silex を使っていたのですが、随分前に DEPRECATED になっている ので、Slim4 にリプレースしました。

Silex からの移行なら Symfony Flex では? という気もしますが特に深い理由はありません。もともと極小さいアプリでフレームワークなんて何でも良い(無くても良い)ぐらいのものなので、たまたま Slim4 を使ってみよー、と思っただけの理由です。

環境

  • PHP 7.4.10
  • slim/slim 4.5.0
  • slim/slim-skeleton 4.1.0

インストール

composer でスケルトンからプロジェクトを作ると手っ取り早いです。

composer create-project slim/slim-skeleton my-slim-app
cd my-slim-app
php -S localhost:8080 -t public public/index.php

スケルトンだと主に次のようなパッケージ構成になります。これらはカスタマイズして差し替えることも可能です。

PSR-7 や PSR-15 や PSR-17 は Slim のパッケージで実装されています。

PSR-7/PSR-17 は本体の slim/slim とは別の slim/psr7 で実装されています。これも別の実装、例えば nyholm/psr7 とか guzzlehttp/psr7 とか laminas/laminas-diactoros に差し替えることも可能です。

error logging

スケルトンの素のままだとエラーでもログがでないので index.php の下記の箇所を変更します。

<?php
// false を true に変更
$errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, true, true);
$errorMiddleware->setDefaultErrorHandler($errorHandler);

また、ログが PHP の error_log 関数に行ってしまうので、Monolog に来るように下記の箇所も変更します。

<?php
// LoggerInterface のインスタンスを HttpErrorHandler の第3引数に渡す
$logger = $container->get(LoggerInterface::class);
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory, $logger);

Error Handling Middleware - Slim Framework によると $app->addErrorMiddleware() の4番目の引数に $logger を渡しても良さそうですが、そこで渡した $loggerErrorMiddleware がデフォルトのエラーハンドラを作成するときに渡されるだけのものです。

スケルトンではエラーハンドラは index.php で作られたものが ErrorMiddleware に渡されているので、そっちに $logger を渡す必要があります。ここわかりにくかったです。

slim/twig-view

素のままだとテンプレートエンジンがありません。GitHub の Slim Framework の Organization を見ると PHP-ViewTwig-View とが見つかりました。

PHP-View はいわゆる php をテンプレートとして使うやつです。最低限のレイアウト機能があるだけのものすごいシンプルなものです。

Twig-View はいわゆる Twig です。こっちを使います。

composer require slim/twig-view

アプリケーションのミドルウェアスタックに TwigMiddleware を追加します。

<?php
// app/middleware.php
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;

return function (App $app) {
    $app->add(SessionMiddleware::class);
    $app->add(TwigMiddleware::createFromContainer($app, Twig::class));
};

Slim\Views\Twig の設定とインスタンスをコンテナで定義します。

<?php
// app/settings.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        'settings' => [
            'twig' => [
                'debug' => true,
                'strict_variables' => true,
                'cache' => __DIR__ . '/../var/cache/twig',
            ],
        ],
    ]);
};
<?php
// app/dependencies.php
use Slim\Views\Twig;

return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        Twig::class => function (ContainerInterface $container) {
            $settings = $container->get('settings');
            return Twig::create(__DIR__ . '/../templates', $settings['twig']);
        },
    ]);
};

コンテナから Twig のインスタンスを取り出してテンプレートをレンダリングできます。

<?php
// app/routes.php
return function (App $app) {
    $app->get('/home', function (Request $request, Response $response) use ($app) {
        $twig = $app->getContainer()->get(Twig::class);
        return $twig->render($response, 'home.twig', [
            'name' => 'ore',
        ]);
    });
};

実際にはアクションをクラス化してコンストラクタインジェクションのほうが良いですね。

<?php
class HomeAction
{
    private Twig $twig;

    public function __construct(Twig $twig)
    {
        $this->twig = $twig;
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        return $this->twig->render($response, 'home.twig', [
            'name' => 'ore',
        ]);
    }
}

いくつかの Slim 特有のテンプレート関数が使用可能です。url_for() などのルート名から URL を返したりなどの、よくあるやつです。

ところで、テンプレートエンジンを使うためになぜミドルウェアを登録する必要があるの? と思いました。いくつかのテンプレート関数で現在のリクエストの URL が必要なため(full_url_for() とか)、ミドルウェアのタイミングでテンプレート関数が Twig に登録されるようになっているためでした。

なので TwigMiddleware をミドルウェアスタックに登録しなければ Slim 特有のテンプレート関数は使えません。

アクションの中でルート名を元に URL を得る

Twig のテンプレートの中なら url_for() テンプレート関数でルート名を元に URL が得られますが、アクションの中でやる場合はリクエストオブジェクトから RouteContext を取り出して使います。

<?php
use Slim\Routing\RouteContext;

class HomeAction
{
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $location = RouteContext::fromRequest($request)->getRouteParser()->fullUrlFor($request->getUri(), 'home');
        return $response->withStatus(303)->withHeader('location', $location);
    }
}

これはなかなかにめんどくさいですね。次のようなメソッドを持ったトレイトとか作っておくと便利でしょうか。

<?php
private function fullUrlFor(
    ServerRequestInterface $request,
    string $routeName,
    array $data = [],
    array $queryParams = []
): string {
    return RouteContext::fromRequest($request)->getRouteParser()
        ->fullUrlFor($request->getUri(), $routeName, $data, $queryParams);
}

private function redirect(ResponseInterface $response, string $location): ResponseInterface
{
    return $response->withStatus(303)->withHeader('location', $location);
}

次のように使います。

<?php
return $this->redirect($response, $this->fullUrlFor($request, 'home'));

slim/http

素の slim/psr7 だと(というか PSR-7 だと)必要最低限のメソッドしか持っておらず、例えばリクエストメソッドが POST かどうかを確認するためだけにこれだけのコードが必要です。

<?php
$isPost = strtoupper($request->getMethod()) === 'POST';

slim/http は PSR-7 のインタフェースをラップし、いくつかの便利なメソッドを追加します。

スケルトンのコードなら composer で入れておくだけで自動で検出して使用されます。

composer require slim/http

アクションで slim/http のリクエスト・レスポンスが利用できます。

<?php
use Slim\Http\Response;
use Slim\Http\ServerRequest;

return function (App $app) {
    $app->map(['GET', 'POST'], '/', function (ServerRequest $request, Response $response) {
        if ($request->isPost()) {
            return $response->withRedirect('http://example.com/', 303);
        }
        return $response->withJson(['hello' => 'world!']);
    });
};

ただ、レスポンスはともかく(アクションがディスパッチされる直前に生成されるため)、リクエストの方はミドルウェアで別のクラスに置き換えられているとダメですね。

<?php
return function (App $app) {
    $app->map(['GET', 'POST'], '/', function (ServerRequest $request, Response $response) {
        if ($request->isPost()) {
            return $response->withRedirect('http://example.com/', 303);
        }
        return $response->withJson(['hello' => 'world!']);
    })->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) {
        // laminas-diactoros のリクエストオブジェクトに置き換える
        $request = new \Laminas\Diactoros\ServerRequest(
            $request->getServerParams(),
            $request->getUploadedFiles(),
            $request->getUri(),
            $request->getMethod(),
            $request->getBody(),
            $request->getHeaders(),
            $request->getCookieParams(),
            $request->getQueryParams(),
            $request->getParsedBody(),
            $request->getProtocolVersion()
        );
        return $handler->handle($request);
    });
};

普通は $request->withXXX() で自身を clone したインスタンスを使うので問題ないはずですが、↑のようなコードは PSR でダメということになっていましたっけ? であれば良いんですけど。

slim/csrf

いわゆる CSRF 対策です。

composer require slim/csrf

ミドルウェアスタックに追加します。

<?php
// app/middleware.php
use Slim\Csrf\Guard;

return function (App $app) {
    $app->add(Guard::class);
    $app->add(SessionMiddleware::class);
    $app->add(TwigMiddleware::createFromContainer($app, Twig::class));
};

Guard クラスが ResponseFactoryInterface に依存しており、スケルトンのコードだと解決できないため DI の定義も必要です。

<?php
// app/dependencies.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        Guard::class => function (ContainerInterface $container) {
            return new Guard(new ResponseFactory());
        },
    ]);
};

これでも一応動きますが、本当なら ResponseFactory をこんなところで作るべきではなく、App インスタンスが持っている ResponseFactory を使うべきです。がしかし、App インスタンスはコンテナに入っていないのでここからでは参照できません。

App インスタンスもコンテナに入れれば良いですね(ResponseFactoryInterface をコンテナに入れるのでも良いと思います)。

<?php
// app/dependencies.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        App::class => function (ContainerInterface $container) {
            return AppFactory::createFromContainer($container);
        },
        Guard::class => function (App $app) {
            return new Guard($app->getResponseFactory());
        },
    ]);
};
<?php
// public/index.php
$app = $container->get(App::class);

フォームへの埋め込みは次のようになります。

<?php
// アクション
return $this->twig->render($response, 'home.twig', [
    'csrf'   => [
        'keys' => [
            'name'  => $this->csrf->getTokenNameKey(),
            'value' => $this->csrf->getTokenValueKey(),
        ],
        'name'  => $request->getAttribute($this->csrf->getTokenNameKey()),
        'value' => $request->getAttribute($this->csrf->getTokenValueKey()),
    ]
]);

// テンプレート
<form method="post" action="{{ url_for('home.post') }}">
    <button type="submit">Submit</button>
    <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
    <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
</form>

めちゃめちゃ記述量が多いですね・・・Twig のテンプレート関数とかにすれば良いと思います。

なお、素のままだとリクエストの都度新しいトークンが作られ、直近の200個までセッションに保持されます。 次のように Guard のインスタンスで設定すれば1つだけ作られたトークンが使われ続けるようになります。

<?php
// app/dependencies.php
Guard::class => function (App $app) {
    return (new Guard($app->getResponseFactory()))->setPersistentTokenMode(true);
},

Route strategies

アクションをディスパッチする方法がカスタマイズできます。

例えば次のようなコードを使えば、コンテナからアクションのメソッドにインジェクションしたり、ルートパラメータを名前でインジェクションしたりできます。

<?php
// MyInvocationStrategy.php
class MyInvocationStrategy implements \Slim\Interfaces\InvocationStrategyInterface
{
    private \Invoker\Invoker $invoker;

    public function __construct(ContainerInterface $container)
    {
        $resolver = new \Invoker\ParameterResolver\ResolverChain([
            new \Invoker\ParameterResolver\Container\TypeHintContainerResolver($container),
            new \Invoker\ParameterResolver\TypeHintResolver(),
            new \Invoker\ParameterResolver\AssociativeArrayResolver(),
        ]);
        $this->invoker = new \Invoker\Invoker($resolver);
    }

    public function __invoke(
        callable $callable,
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $routeArguments
    ): ResponseInterface {
        return $this->invoker->call($callable, [
            ServerRequestInterface::class => $request,
            ResponseInterface::class => $response,
            get_class($request) => $request,
            get_class($response) => $response,
        ] + $routeArguments);
    }
}
<?php
// public/index.php
$app->getRouteCollector()->setDefaultInvocationStrategy($container->get(MyInvocationStrategy::class));
<?php
// ViewUserAction.php
class ViewUserAction
{
    public function __invoke(ServerRequest $request, Response $response, UserRepository $repository, string $id): Response
    {
        $user = $repository->findUserOfId((int)$id);
        // ...
        return $response;
    }
}

まあそこまでしてメソッドインジェクションしなくていいと思います。1クラスに1アクションになっているならコンストラクタインジェクションでも極端な量の依存をコンストラクタ引数に書いたりすることも無いでしょう。

環境変数でモード切り替え

スケルトンだと production とか development とかのモード切替の概念がないため、自前で実装する必要があります。

次のように適当な環境変数をコンテナの定義で使うとよいでしょう。

<?php
use function DI\get;

return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        'debug' => (bool)getenv('APP_DEBUG'),
        'settings' => [
            'displayErrorDetails' => get('debug'),
            'twig' => [
                'debug' => get('debug'),
                'strict_variables' => true,
                'cache' => __DIR__ . '/../var/cache/twig',
            ],
        ],
    ]);
};

コンテナとルートの定義をキャッシュ

プロダクションではコンテナとルートの定義をキャッシュすることでパフォーマンスの向上が期待できます。

<?php
// public/index.php

// コンテナ定義のキャッシュ
$containerBuilder = new ContainerBuilder();
$containerBuilder->enableCompilation(__DIR__ . '/../var/cache');

// ルート定義のキャッシュ
$app->getRouteCollector()->setCacheFile(__DIR__ . '/../var/cache/routes.php');

ただスケルトンでは有効・無効を切り替えられるような仕組みがないため、環境変数で分岐するなどの対応が必要です。

もしくは、キャッシュファイルを作成するだけのスクリプトを作成し、手動でキャッシュファイルを作成していればそれを使うし、なければキャッシュ無効にする、という振り分けも良さそうです。

まず public/index.php からコンテナと App インスタンスを作成してルート定義を読み込むところまでを別ファイルに切り出します。このファイルはクロージャーを返し、引数によってキャッシュファイルを作成するかどうかを制御できます。

<?php
// app/app.php

// $compile = true で実行するとキャッシュファイルを作り直す
return function (bool $compile = false) {

    $containerBuilder = new ContainerBuilder();

    $cacheDir = __DIR__ . '/../var/cache';
    $containerCacheFile = "$cacheDir/CompiledContainer.php";

    if ($compile) {
        // 既存のキャッシュファイルを削除して再作成
        if (file_exists($containerCacheFile)) {
            unlink($containerCacheFile);
        }
        $containerBuilder->enableCompilation($cacheDir);
    } else {
        // キャッシュファイルがあれば有効にする
        if (file_exists($containerCacheFile)) {
            $containerBuilder->enableCompilation($cacheDir);
        }
    }

    // ... コンテナ定義、ルート定義、ミドルウェア定義、などの読み込み ...

    $routeCacheFile = "$cacheDir/routes.php";

    if ($compile) {
        // 既存のキャッシュファイルを削除して再作成
        if (file_exists($routeCacheFile)) {
            unlink($routeCacheFile);
        }
        $app->getRouteCollector()->setCacheFile($routeCacheFile);

        // キャッシュファイルを作成するためにルート解決を実行
        $app->getRouteResolver()->computeRoutingResults('/', 'GET');
    } else {
        // キャッシュファイルがあれば有効にする
        if (file_exists($routeCacheFile)) {
            $app->getRouteCollector()->setCacheFile($routeCacheFile);
        }
    }

    return $app;
};

public/index.php ではそのファイルから App インスタンスを取得し、残りの処理を実行します。

<?php
// public/index.php
$app = (require __DIR__ . '/../app/app.php')();
assert($app instanceof App);

// ... ShutdownHandler や ErrorMiddleware を登録 ...

$response = $app->handle($request);
$responseEmitter = new ResponseEmitter();
$responseEmitter->emit($response);

キャッシュファイルを作成するスクリプトでは引数を true にして App インスタンスを取得します。

<?php
// scripts/optimize.php
require __DIR__ . '/../vendor/autoload.php';
(require __DIR__ . '/../app/app.php')(true);

php scripts/optimize.php でキャッシュファイルが作成されればキャッシュ有効モードに、キャッシュファイルを削除すればキャッシュ無効モードになります。

さいごに

スケルトンをそのままでは扱いにくいところがあり、ソースを見つつ手を加えていく必要がありました。ただ、ソース全部読んだとしてもたいした分量ではないし、素直に PSR-7/PSR-15/PSR-17 に乗っかっているのでわかりやすいと思いました。

フルスタックフレームワークは自由がなさすぎる、かといってオレオレフレームワークは秩序がなさすぎる、というときには Slim4 もアリなんじゃないでしょうか。いろいろ肉付けしていくとオレオレ染みてくる気もするけど。

もっとも初手の速さは Laravel のようなフルスタックの方が速いので、とりあえず動くなにかを作るのには向かないかもです。自分用に Slim4 ベースのボイラーテンプレートを作っておけばまた違うかもしれないですけど(それってもうオレオレのようなものなのではという気もする)。