がるの健忘録

エンジニアでゲーマーで講師で占い師なおいちゃんのブログです。

複数ドメインでのセッション管理のポイント

ワードとしては「複数ドメイン間でのセッションの共有」とか「サブドメイン間でセッションの共有」とか「別ドメインでログイン(認可)情報を共有」とかそんな感じになりますかね。
ケースとしては比較的レアかと思うのですが。

今回の場合
・「複数のゲーム」をひとまとめにしたサイトを作りたい
・各ゲームは個々に実装。ディレクトリじゃなくてドメインで切り分ける想定
・ログインだけ「1カ所に集約」して、ユーザ登録は1回で片付けられるようにしたい
って感じです(プチ宣伝込み)。

あんまり「よくあるニーズ」ではなさそうなんですが。
まぁその分「必要な人には必要だろう」って建前と、ぶっちゃけ「書いておかないと自分が忘れるがな」っていうこのblog本来の目的のために、書いておきます(笑

まず事前準備。

# とりあえず基底のディレクトリ作る
mkdir session_test; cd $_

# 2つのドメイン用のディレクトリをそれぞれ作る
mkdir hoge
mkdir foo

# DNS設定(略
# nginx設定(略

nginxの設定の一部だけ書いておくとこんな感じです。

server {
    listen       80;
    server_name  hoge.example.com;
    root /home/example/session_test/hoge/;
    (略)
}
server {
    listen       80;
    server_name  foo.example.com;
    root /home/example/session_test/foo/;
    (略)
}

この辺はいわゆるインフラ系のお仕事。

ドメイン
hoge.example.com
foo.example.com
の2つを用意しました。

実際には
www.example.com
ゲーム1.example.com
ゲーム2.example.com
って感じのを用意して、wwwのほうに「ユーザ登録、ログイン機能」「ゲームの一覧」あたりを実装、各ゲームはゲーム1とかゲーム2とかのドメインに実装予定です。
(その時用の共通化についてもいろいろ考えているので、ある程度実装したら今度、記事にする予定です)。

用意したコードは以下の通り。
まず session_test に、共通の「セッション用のコード」を用意しています。

<?php  // common.php

declare(strict_types=1);

//
ob_start();

// ざっくりセッション設定
session_set_cookie_params([
    'lifetime' => 0,
    'samesite' => 'Strict',
    // 'secure' => true, // テスト環境なのでhttpsになってないからコメントアウト
    'httponly' => true,
    'domain' => 'example.com', // ここは、テストによって有効にしたりコメントアウトしたり
]);
//
ini_set('session.use_strict_mode', 1);

// セッション開始
session_start();

次に、hogeとfooに以下を用意しました。

hoge/index.php
foo/index.php

<?php  // hoge/index.php, foo/index.php
// 設定確認用
echo 'ok';

hoge/read.php
foo/read.php

<?php  // hoge/read.php, foo/read.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッション情報の表示
var_dump($_SESSION);
var_dump(session_id());

hoge/regenerate.php
foo/regenerate.php

<?php  // hoge/regenerate.php, foo/regenerate.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションID差し替え
session_regenerate_id(true);

echo 'ok';


hoge/write.php

<?php  // hoge/write.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションへの書き込み
$_SESSION['hoge'] = 'hoge value';

//
echo 'ok';

foo/write.php

<?php  // foo/write.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションへの書き込み
$_SESSION['foo'] = 'foo value';

//
echo 'ok';

準備完了。
まずは

    'domain' => 'gjmj.net', // ここは、テストによって有効にしたりコメントアウトしたり

コメントアウトした状態で、「だよね~」の確認。

http://hoge.example.com/read.php
array(0) { }
string(32) "19c0589b071e4d2d5e995028cb9081aa"
http://foo.example.com/read.php
array(0) { }
string(32) "0187f6525a7d24e03b662e564471747f"


http://hoge.example.com/write.php
http://foo.example.com/write.php


http://hoge.example.com/read.php
array(1) { ["hoge"]=> string(10) "hoge value" }
string(32) "19c0589b071e4d2d5e995028cb9081aa"
http://foo.example.com/read.php
array(1) { ["foo"]=> string(9) "foo value" }
string(32) "0187f6525a7d24e03b662e564471747f"

当然ではありますが「違うドメイン」なので、違うセッションIDだし違う情報を書いてるし読んでるから、交わることなし。


次。今回の眼目。domainの所を有効にして実験。

http://hoge.example.com/read.php
array(0) { }
string(32) "8b5f581950014979cb679dee1c07d449"
http://foo.example.com/read.php
array(0) { }
string(32) "8b5f581950014979cb679dee1c07d449"


http://hoge.example.com/write.php
http://foo.example.com/write.php


http://hoge.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "8b5f581950014979cb679dee1c07d449"
http://foo.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "8b5f581950014979cb679dee1c07d449"


http://hoge.example.com/regenerate.php
http://foo.example.com/regenerate.php


http://hoge.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "f4a57c3d4dc2e0fe4f4fb4b167d4dcd2"
http://foo.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "f4a57c3d4dc2e0fe4f4fb4b167d4dcd2"

予想通り。
session_regenerate_id() も含めて、ちゃんと意図的に動いてくれる感じ。
まぁこれだと「ゲーム本体の情報」を下手にセッションにぶち込んだ時に割と死ねるんだけど。
その辺は最悪「(PHPのセッションは)認可と共通情報専用」にして「別途、個別ゲーム情報用のセッション」は組み直してもいいしなぁ……あとは「セッション名のスイッチング(この辺も、面白そうだったら今度blogに書きます)」とか。

とりあえず「できるといいなぁできないと気合いで自力実装だなぁ」と思っているところが割とさくっと解決したので、めでたいの思いを込めて一筆。

「そうだパスワードの持ち方、変えよう」

色々あっていろいろあったのに触発されました(笑
いや昔実際に実務で実装したこともあったんで、その辺を踏まえて。

大前提として
・ログインロジックに介入できる
・ユーザの「最終ログイン時間」が捕捉できる(ケースによる)
が必要になりますんでご注意を。
最終ログイン時間については「今からそのロジック追加」で間に合います。

次。
用語整理軽く。

Salt(ソルト)
hash時の味付け。詳しくは「salt hash」あたりでググってください。
この記事では勝手に「ユーザ個別のもの」をsaltって言っておきます(いちいち書くのが面倒なので)。
「共通のsalt」は、「共通salt」と明記しておきます。

Pepper(SecretSalt)(ペッパー、シークレットソルト)
これも「ペッパー hash」でググっていただくのが早いかと。

もういっちょ。
ログインのロジックの、以下に介入する前提です。だいたいイメージで察してください。
ベースになるコードイメージです。
端的には「idに対応するレコードがなければfalse、入力パスワードと保存パスワード比較して不一致ならfalse。idがokでパスワードもokならtrue」って感じ。
「そっくりそのまんま」とまでは言わんにしても、まぁ近似値な処理がきっとある、はず!!(笑

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (hash値の比較($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

ほんでもって次。
パスワードを「どのように保存しているか」は、たぶん、こんなグラデーションなんではないかなぁ、と。
(ペッパーの有無はいったん省きます。リストが2倍になるの面倒なんで)

  • 平文
  • md5
  • 共通salt付きmd5(ストレッチあったりなかったり
  • 個別salt付き適当なhash(ストレッチあったりなかったり
  • Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)
  • Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)

さて一通り出そろいましたので、入れ替えを見ていきましょう。

MCFなんだけどアルゴリズム古し

今が「Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)」なら別段なにもせんでえぇので。
1ランク下がりました「Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)」である場合を考えてみましょう。

古いんで新しいのに差し替えたいです。
おおざっぱには

  • (古いのだったら)新しいアルゴリズムのに差し替える
  • 適当に時間がたったら「パスワードhashのアルゴリズム」確認して、差し替え前アルゴリズムなら、一度パスワードのレコードを空文字とかにする

ってな感じになります。

実装。
ちょいと丁寧にすると、だいたいまぁこんな感じか、と。
password_hash()使ってる前提です。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    /* OKだったぽいので */  
    // hashのアルゴリズムが古かったら、新しいのに差し替える  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
    }  

    // 認証通る  
    return true;  
}  

こんな感じ。
これで「ログイン通過してhashが古い」人は、新しいhashに書き換わります。

……そこから幾星霜が経過しました(長い)。

そろそろ「古いhash、本格的に撲滅しねぇ?」って感じの頃合いでございます。それだけ時間がたったんだってば。
こんなbatchを組んでみましょう。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報($uにあるはず);  
        なんかログに残したり表示したり  
    }  
}  

これで「古いhashは撲滅」できます。
後は、ログのuser_idでもいいし、改めてDBからselectしてもいいし、のターゲットに対して
「しばらくログインがなかったからパスワード無効にしたのでパスワードリマインダで設定してから再ログインしてね」
とかって連絡をすれば、一段落。

MCFじゃなかったんだけどまぁそこまでクリティカルにマズい持ち方してない

次。
「個別salt付き適当なhash(ストレッチあったりなかったり」を想定してみましょう。
他よりはましなのですが、MCFではないので色々と面倒が付きまといます。
サクっと本体実装を見ていきましょう。
大まかには

  • MCFで認証通ったらそのまま
  • 古いhash方式で認証通ったら「MCFに置き換えつつ」認証OK
  • そうじゃなきゃNG

って感じですね。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列と個別のsalt」を取得。ストレッチ回数は「ここに入れてるけど多分どこかglobalな領域にあるんじゃね?」を想定  
    $hashed[hadhed_password, 個別のsalt, ストレッチ回数] = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    /* MCFでのhashの突合せ */  
    if (password_verify($raw_password, $hashed['hadhed_password']) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordをhash化する  
    $in_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, $hashed[個別のsalt], $hashed[ストレッチ回数]);  
    // hashされた値の比較  
    if (hash_equals($in_hashed_password, $hashed['hadhed_password']) === true) {  
        // hashのアルゴリズムが古かったら、新しいのに差し替える  
        $password_info = password_get_info($hashed_pw);  
        if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
            // 別に個別saltは空にしなくてもいいんだけど、一応  
            update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション), 個別salt='' WHERE userを特定する情報;  
        }  
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これでまたしばらくしたら「古い方式を無効にする」ので。
「幾星霜」よりもうちょっと短めのほうがいいかも、ではあるけど(早めにロジック掃除したいじゃん?)まぁ任意。
同じバッチを組めばOK。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報;  
        なんかログに残したり表示したり  
    }  
}  

$password_info['algoName']、たしか「unknown」だかなんだか、になったはず(うろ覚え)。
あと、このタイミングで「古いhashでの認証処理ロジックを削除」「個別saltのカラムを削除」「ストレッチ回数の情報の破棄」とかやってもよいですね。

MCFじゃなかったしクリティカルにマズい持ち方してる

さて、残るは

・平文
md5
・共通salt付きmd5(ストレッチあったりなかったり

というゴツ目なあたり。
いや身もふたもない「即時に片付く」方法が一応ありまして。

・ログインのロジック(とか周辺あちこち)を「MCFのパスワード」に置き換える(ステージングくらいまでで確認)
・メンテナンスに入る
・プログラムをデプロイ
・DBのパスワードカラムを全部「空文字」でupdateする
・メンテナンス終わり
・全ユーザに「ごめんパスワードリマインダからパスワード設定しなおして」の連絡をする

と、あら不思議「脆弱なパスワードがなくなります」(笑
いやマジなところ、上3つならコレやったほうがいいような気がするんですけどねぇ。
一度、マジで提案してみましょう。通ったらお互いに「少しだけ」幸せになれるwww

でも「ど~しても難しい」場合、以下の手順を踏むとまぁ「ギリ」かなぁ、と。
「明日、クラックされた」を前提にする分、ちょいと内部的には手間でございます。

「以下のコードに修正する」と「DBに細工をする」を同じタイミングで流す(メンテナンスのin-outで対応しましょう)

DB小細工。これによって「とりあえずパスワードを「MCFフォーマット(なので最悪漏れても時間稼ぎが出来る状態)」に置き換える

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    update ユーザテーブル set password = password_hash($u->password, DEFAULTなりお好きなアルゴリズムなり, オプションもお好みで) WHERE $u->user_id;  
}  

次、認証側のコード。
入っているパスワードが「平文」の場合、ある意味気楽で。
以下のコードでいけます。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

md5だったり「共通salt付きmd5」だったりするときは、こんなふうにしましょう。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // MCFでのhashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordを(以前のやり方で)hash化する(共通saltとストレッチ回数があるパターン。ない時は引数調整して)  
    $old_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, 共通salt, ストレッチ回数);  

    // 「古いやり方でhash化した」パスワードを「生パスワード」と仮定して、値の比較  
    if (password_verify($old_hashed_password, $hashed_pw) === true) {  
        // 本来の正しいのに置き換える  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
          
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これで多分そのうちなんとか。
最悪漏れてもこれなら「普通にちゃんとセキュリティ担保したパスワードとして有効な保持の仕方」なので、どうにかなるでしょ。

で……この方式のみ「切り替わってるんだか切り替わってないんだかわからない」ので。
もし「古いロジック残しておいても面倒だから適当なタイミングで"更新していないユーザのパスワード"リセットしたい」のであれば、最終ログイン時間を使って「上にあるバッチ」で「最終ログイン時間が一定以上過去の人のパスワードを空文字にする」でやればOK。

なんていう方法がありますよ、というおおざっぱな提案でした。
……事故が減るといいなぁ。
& 変な所とか怪しいところとか危ない所があったら、突っ込んでもらえれば速攻で直します!!

phinxを使ってみた(マイグレーション変)

とりあえずinstall

composer require robmorgan/phinx

initで初期化をするらしい。

vendor/bin/phinx init

phinx.php が生えた。

<?php

return
[
    'paths' => [
        'migrations' => '%%PHINX_CONFIG_DIR%%/db/migrations',
        'seeds' => '%%PHINX_CONFIG_DIR%%/db/seeds'
    ],
    'environments' => [
        'default_migration_table' => 'phinxlog',
        'default_environment' => 'development',
        'production' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'production_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ],
        'development' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'development_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ],
        'testing' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'testing_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ]
    ],
    'version_order' => 'creation'
];

なるほど。
このフォーマットなら「他のFW(おいちゃんの想定はSlim)使ってて、そっちで(も)DBの接続設定がある」なんてケースでもちゃんとDRYに出来るねぇ!!
1点気になってたのが一瞬で氷塊したので、まぁ普通に「使う」前提でよいのではないかしらん???

見るに
・いったんのデフォはdevelopment
っぽいので、そのままテスト用のDBの設定をdevelopmentに書いて、追加で実験してみませう。

お次。
必要なディレクトリを作るぽ。

mkdir -p db/migrations db/seeds

これはまぁ実際には「このディレクトリもgitに登録しておく」とかで良さそうざます。
&
configの設定の「%%PHINX_CONFIG_DIR%%」がちょい気になるが、まぁなんかあったら後で出てくるでせう。
次。

眼目になるマイグレーションファイル作成。
本家サイトとか、アッパーキャメルなのがちょいと気になるなぁ……いったんヘビにしてみよう。

php vendor/bin/phinx create table_name

The migration class name "table_name" is invalid. Please use CamelCase format.

わをwww
キャメル固定なのかwww

では改めて。

php vendor/bin/phinx create TableName

Phinx by CakePHP - https://phinx.org.

using config file phinx.php
using config parser php
using migration paths
- /home/furu/PhinxTest/db/migrations
using seed paths
- /home/furu/PhinxTest/db/seeds
using migration base class Phinx\Migration\AbstractMigration
using default template
created db/migrations/20230819124347_table_name.php

ほむ……seedとmigrationsにファイルが出来てる? 確認。

seedには……ない。
なんじゃろ???
(後で分かった。ここ。seederを作る所なんだ)

migrationsにはファイルが出来てる。

<?php
declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

final class TableName extends AbstractMigration
{
    /**
     * Change Method.
     *
     * Write your reversible migrations using this method.
     *
     * More information on writing migrations is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     *
     * Remember to call "create()" or "update()" and NOT "save()" when working
     * with the Table class.
     */
    public function change(): void
    {

    }
}

簡素だなをいw
んで、指定したのは純粋に「クラス名」なのか。それ以外の処理、なんもないw

(RDB準備中...RDB準備中...)

軽く書いていこう。
書く場所は
・change() に書くと、(ある程度までは)マイグレーションを巻き戻す時、勝手に良い感じに巻き戻してくれる
・でなきゃ、おとなしく up() と donw() を書け
らしい。

で……ちょっと気になる記述も見かけたので、その辺の確認込みで、いくつか書いてみる。

    public function change(): void
    {
        $table = $this->table('test');
        $table->addColumn('name', 'string')
            ->addColumn('str', 'varchar', ['limit' => 128])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'int', ['comment' => 'コメント'])
            ->addColumn('num2', 'bigint')
            ->addColumn('created_at', 'datetime')
            ->create();

    }

ドライランがあるとの事で、早速やってみる。
あと、 -e での環境指定を「あえて」省略してみる(configにデフォ値あったし)。

php vendor/bin/phinx migrate --dry-run

InvalidArgumentException: An invalid column type "varchar" was specified for column "str".

あそ。
他いくつか修正。

    public function change(): void
    {
        $table = $this->table('test');
        $table->addColumn('name', 'string')
            ->addColumn('str', 'string', ['limit' => 128])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'integer', ['comment' => 'コメント'])
            ->addColumn('num2', 'biginteger')
            ->addColumn('created_at', 'datetime')
            ->create();

    }

CREATE TABLE `test` (
`id` INT(11) unsigned NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NULL,
`str` VARCHAR(128) NULL,
`str2` VARBINARY(128) NULL,
`num` INT(11) NULL COMMENT 'コメント',
`num2` BIGINT(20) NULL,
`created_at` DATETIME NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

あら?
うん前情報通り「勝手にID入れてくる」のは気になるが、VARBINARYに対応しているのはおいちゃん的に大分と高ポイントざますよ?
ほむ……ちょいと本気を出してみよう(実験用なんでテーブルレイアウトはきにすんな)。

    public function change(): void
    {
        $table = $this->table('test', [
            'id' => false,
            'primary_key' => 'test_id',
            'comment' => 'テーブルコメント',
        ]);
        $table
            ->addColumn('test_id', 'biginteger', ['null' => false, 'signed' => false, 'identity' => true,])
            ->addColumn('name', 'string')
            ->addColumn('str', 'string', ['limit' => 128, 'null' => false])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'integer', ['comment' => 'コメント'])
            ->addColumn('num2', 'biginteger')
            ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
            ->create();

        //
        $table = $this->table('test_2', [
            'id' => false,
            'primary_key' => ['test_id', 'str'],
            'comment' => 'テーブルコメント',
        ]);
        $table
            ->addColumn('test_id', 'biginteger', ['null' => false, 'signed' => false, 'identity' => true,])
            ->addColumn('str', 'string', ['limit' => 128, 'null' => false])
            ->create();
    }

CREATE TABLE `test` (
`test_id` BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NULL,
`str` VARCHAR(128) NOT NULL,
`str2` VARBINARY(128) NULL,
`num` INT(11) NULL COMMENT 'コメント',
`num2` BIGINT(20) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`test_id`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='テーブルコメント';

CREATE TABLE `test_2` (
`test_id` BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
`str` VARCHAR(128) NOT NULL,
PRIMARY KEY (`test_id`,`str`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='テーブルコメント';

よし、よし。
ちょっと調べるのに時間がかかったけど、大体いけるっぽいなぁ。
よしちゃんと実行してみやう。

php vendor/bin/phinx migrate
mysql> show tables;
+----------------------+
| Tables_in_phinx_test |
+----------------------+
| phinxlog             |
| test                 |
| test_2               |
+----------------------+
3 rows in set (0.00 sec)

mysql> show create table test \G
*************************** 1. row ***************************
       Table: test
Create Table: CREATE TABLE `test` (
  `test_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `str` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  `str2` varbinary(128) DEFAULT NULL,
  `num` int DEFAULT NULL COMMENT 'コメント',
  `num2` bigint DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`test_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='テーブルコメント'
1 row in set (0.00 sec)

mysql> show create table test_2 \G
*************************** 1. row ***************************
       Table: test_2
Create Table: CREATE TABLE `test_2` (
  `test_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `str` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`test_id`,`str`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='テーブルコメント'
1 row in set (0.00 sec)

んむんむ、よきよき。
一応、rollback

php vendor/bin/phinx rollback
mysql> show tables;
+----------------------+
| Tables_in_phinx_test |
+----------------------+
| phinxlog             |
+----------------------+
1 row in set (0.01 sec)

よきよき。

一応メモ程度に残しておくと。

php vendor/bin/phinx migrate -e 環境名
php vendor/bin/phinx rollback -e 環境名

ってのが本来っぽい感じではあるので、一応。
ただまぁこれだと

・ステージングまでは普通に「コードupしてからマイグレーションする」
・本番は「先にマイグレーションしてからコードupする」

とかもできそうだなぁ……ちょいと運用も考えてみやう。

大雑把に
・「テーブル作る」とか「カラム追加する」くらいなら、change() に実装すればよさげ
・一応「SQLダイレクト発行」もある、みたいだけど、現状だとあんまりいらなさそうな雰囲気がある
・まぁ必要になったら「up()とdown()で実装しつつSQL書く」でよいんじゃなかろうか
って感じなので。
「便利な機能はあるけど簡単にoffれるから足かせにならないので気楽」って感じだなぁ。
うん、ちょいと使い込んでみてもよいかも。ちょっと癖があるから色々調べなきゃいかんけどw

次回はseederを確認してみませう。

「オリエンタル霊異譚 幽冥鬼使」の確率計算

数学苦手なんで(……とも言ってられないんだが)、馬力で計算してみたw

厳密には「3以上の時は技能値(等)が足される」ので、その辺で少し加減されるんだけど。
いったん「ダイス目だけ」で、陰徳値無考慮で純粋に「ダイスの確率」で確認。
プログラムでざっくり書いてるんで端数誤差あるんだけど、その辺は気にせずに。

0 27.777778%
3 4.320988%
4 4.783951%
5 8.641975%
6 9.567901%
7 12.962963%
8 10.030864%
9 8.641975%
10 6.17284%
11 4.320988%
12 2.314815%
20 0.462963%

「n値以上」だと、こんな感じ。

0以上 100%
3以上 72.222223%
4以上 67.901235%
5以上 63.117284%
6以上 54.475309%
7以上 44.907408%
8以上 31.944445%
9以上 21.913581%
10以上 13.271606%
11以上 7.098766%
12以上 2.777778%
20以上 0.462963%

ここの判定と比較的近似と思われる、2d6の分布は大体こんな感じ。

2 2.78%
3 5.56%
4 8.33%
5 11.11%
6 13.89%
7 16.67%
8 13.89%
9 11.11%
10 8.33%
11 5.56%
12 2.78%

2 100.00%
3 97.22%
4 91.67%
5 83.33%
6 72.22%
7 58.33%
8 41.67%
9 27.78%
10 16.67%
11 8.33%
12 2.78%

比較っぽい表にすると、こんな感じ。

0 - 100%
2 100.00% -
3 97.22% 72.222223%
4 91.67% 67.901235%
5 83.33% 63.117284%
6 72.22% 54.475309%
7 58.33% 44.907408%
8 41.67% 31.944445%
9 27.78% 21.913581%
10 16.67% 13.271606%
11 8.33% 7.098766%
12 2.78% 2.777778%
20 - 0.462963%

一概には言いにくいんだけど、中程度の難易度を想定するときは「平目で、2d6の時より1くらい値を下げておく」をベースにすると、割と、近似するんじゃなかろうか? と。

なお、コード(デバッグ出力があちこちあんのは気にすんな)。

<?php

// 元ネタ
$base = [];
for($i = 1; $i <= 6; ++$i) {
    for($j = 1; $j <= 6; ++$j) {
        for($k = 1; $k <= 6; ++$k) {
            for($l = 1; $l <= 6; ++$l) {
                $base["{$i},{$j},{$k},{$l}"] = [$i, $j, $k, $l];
            }
        }
    }
}
// var_dump($base);

// 確認
$data = [];
foreach($base as $k => $v) {
    // いったん、サイコロの目をsort
    sort($v);
    
    // 先にクリティカル確認
    if ( ($v[0] === $v[1])&&($v[1] === $v[2])&&($v[2] === $v[3]) ) {
        echo "クリット: {$k}\n";
        $data[20] ??= 0;
        $data[20] ++;
        continue;
    }
    // 失敗を算出
    if ( ($v[0] != $v[1])&&($v[1] !== $v[2])&&($v[2] !== $v[3]) ) {
        echo "ファンブル: {$k}\n";
        $data[0] ??= 0;
        $data[0] ++;
        continue;
    }
    // 通常成功を算出(面倒だから馬力)
    if ( ($v[0] === $v[1]) ) {
        $key = $v[2] + $v[3];
        echo "{$k} => {$v[2]} + {$v[3]} = {$key} \n";
    } elseif (($v[1] === $v[2])) {
        $key = $v[0] + $v[3];
        echo "{$k} => {$v[0]} + {$v[3]} = {$key} \n";
    } elseif (($v[2] === $v[3])) {
        $key = $v[0] + $v[1];
        echo "{$k} => {$v[0]} + {$v[1]} = {$key} \n";
    }
    //
    $data[$key] ??= 0;
    $data[$key] ++;
}
//var_dump($data);

// sortして
ksort($data);
// 合計値出して
$total = array_sum($data);
// 各値の確率を出力
foreach($data as $k => $v) {
    printf("%d: %f%% \n", $k, $v/$total*100);
}

「validationをValue Objectで行う」を考えてみた

なんか「ふと思いついた」程度のお話ではあるのですが。

まず前提として「Value Objectとは、一意性がなく交換可能なもの」としておきます。おいちゃんの好み的に「イミュータブル」であって欲しいと思ってますが、その辺はまぁ余談。
あと、ちょろっと出てくる「Entity」については「一意性がある、データの塊」くらいに把握しておいてもらえればとりあえず会話は成り立つかなぁ、と思ってます。

超大雑把に、ですが。
例えば「User」っていうentityがあって、このUser entityの中には「user_idというValue Objectと姓名というValue ObjectとemailというValue Objectと誕生日というValue Object」があって、みたいな感じをイメージしていただけるとよろしかろうか、と思います。

このとき。https://gallu.hatenadiary.jp/entry/2019/05/16/225759 で書きましたが、少なくとも「Controller的な所で、入ってくる時にvalidateする」のをおいちゃんはあまり好まないので。
上述の場合はentityにvalidateを実装していた、のですが。
ふと「あれ? 各Value Objectでvalidateしてもいいんぢゃね?」とか思ったんですね。

emailは、それが「userの中にあろうとも」「ほかのナニカの中にあろうとも」基本的には「email用のvalidate」をしたいだろうし。
誕生日も同様だろうし、姓名も同様だと思うんですね。
だとすると「各validate処理自体は各Value Objectでやって、entityはそれをとりまとめて依頼して結果を拾い集めるだけ」ってやると、なんか割とうまくいくんぢゃね? とか思ったですます。

超大雑把にコードっぽいものを書いてみると。
以前は、entityに

$validate = [
    'user_id' => 'required|int', // 文字列で指定する書き方
    'email' => ['required', 'email'], // 配列で指定する書き方
    'birthday' => ['date'],
];

とかって書いてたイメージなのですが。
これを、まずentityのほうでは

$type_cast = [
    'user_id' => ValueObject\UserId::class,
    'email' => ValueObject\Email::class,
    'birthday' => ValueObject\Birthday::class,
];

って感じで「データが入ってくる時にこの型(クラス)にキャストするよ」って宣言しておいて、あとは各Value Object側で適宜validateしておくれやす、的な。
Value Objectのvaludateは多分「コンストラクタで値を取り込んだ時にvalidate、駄目なら例外投げるのをentityが個々にキャッチ」って感じじゃないかなぁ???

まぁ「完全にValue Objectのみに実装」だと、例えば「BBSとかのentityで"投稿されたメッセージ"までValue Object作るん? 面倒くね?」とか「こっちのAというValue Objectがこの値の時はBというValue Objectがこうなってて欲しい、的に相互が関連するもの」もあるので、その辺はまぁ「entityへの実装も許容する」ほうが現実問題として楽だろう、と思うのですが。

そすると、Enity的には、超雑ですがこんなイメージかなぁ、と(カラムとか適当に追加してます)。

// 各カラムのキャスト型の指定
$type_cast = [
    'user_id' => ValueObject\UserId::class,
    'email' => ValueObject\Email::class,
    'birthday' => ValueObject\Birthday::class,
    'memo' => 'string', // これは単純に「文字列型にする」くらいのイメージの指定(スカラ型はこんな感じでいいかなぁ、と思ってる)
];
// (ValueObjectでは行われない追加の)valudate指定
$validate = [
    'hoge' => ValidateRule\HogeRule::class, // validateルールをクラスで指定しておく(ちょっと複雑な子もこれで安心)
    'memo' => ['required', 'min_length:10', 'max_length:100'], // 従来のvalidateもここでちゃんと書ける
];
// 「1カラムでは片付かない」valudateの追加実装
protected function validate(): bool
{
    // 元々のvalidateの実装をcall
    $r = parent::validate();

    // 追加のvalidate
    $r2 = {AがXXの時はBがYYであること、的な実装が書いてあるとおもいねぇ};

    // 親のvalidateと追加のvalidateをがっちゃんこしてreturn
    return $r && $r2;
}

(おいちゃんが作ってるSlimLittleToolsだと、validation、厳密には「insertのみで適用する用」と「updateのみで適用する用」も書けるので、もうちょっとだけ複雑にはなりますが、大枠はこんなもんかなぁ)

そうすると、 https://twitter.com/mpyw/status/1619951659047858181 に書かれているような感じの「validateのルールをクラス名で渡す」も一緒にあわせて実装すると、まぁ少し「方法が多様になる」にしても、気をつければ割ときれいだったり楽だったりする実装になるんじゃないかなぁ? と。
(多分、ValidateRuleとかって名前とValueObjectValidationとかって名前のインタフェースとか切るんだろうなぁ、と、もわもわ妄想中)

なんかちょっと突飛だけどどうかなぁ?
……と思ったら、思ったよりあちこちで考察されてましたよ。ですよね~w

https://qiita.com/kotauchisunsun/items/e319e4c4b093d6add74b#valueobject%E5%9E%8B
https://qiita.com/j5ik2o/items/bd77f2da6d445ee4268a

なんか「ふと思いついた」だけなので、どこかで検証コードというかアプリケーションざっくり実装してみたいなぁ。
「検証用に使うための汎用の仕様」とか作って、なんか色々な方法で実装してみようかしらん? とか、思ってみたりもしたりはする。

いったん今回は「脳内妄想垂れ流し」なんで、コード書いたらまた追記します ノ

queue:work の --memory 引数が……ちょっと……

php artisan queue:work には、色々な引数があるようです。
バージョンにもよるんだろうなぁ、と思うのですが、とりあえず手元の 8.83.27 のバージョンでお話を進めます。

とりあえず引数の一覧は、vendor/laravel/framework/src/Illuminate/Queue/Console/WorkCommand.php によれば

    protected $signature = 'queue:work
                            {connection? : The name of the queue connection to work}
                            {--name=default : The name of the worker}
                            {--queue= : The names of the queues to work}
                            {--daemon : Run the worker in daemon mode (Deprecated)}
                            {--once : Only process the next job on the queue}
                            {--stop-when-empty : Stop when the queue is empty}
                            {--delay=0 : The number of seconds to delay failed jobs (Deprecated)}
                            {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception}
                            {--max-jobs=0 : The number of jobs to process before stopping}
                            {--max-time=0 : The maximum number of seconds the worker should run}
                            {--force : Force the worker to run even in maintenance mode}
                            {--memory=128 : The memory limit in megabytes}
                            {--sleep=3 : Number of seconds to sleep when no job is available}
                            {--rest=0 : Number of seconds to rest between jobs}
                            {--timeout=60 : The number of seconds a child process can run}
                            {--tries=1 : Number of times to attempt a job before logging it failed}';

こんな感じのようです。
うんまぁ例えばsleepとかtimeoutとか、triesとかもまぁバッチ的には「あるし必要だしわかるし」って感じなのですが。

memoryが、ぶっちゃけまぁ「直感的ではないんじゃないかねぇ?」とか思うわけでございます。
「The memory limit in megabytes(メガバイト単位のメモリ制限)」とかあるわけ、なのですが、ちょっとこれ語弊があるんじゃないかねぇ? とか思ったりします。

先に答えをゲロると
・「ここで指定したメモリ以上のメモリを食うとバッチを止める」実装はある
PHPのmemory_limitを引き上げるような動作はしない
・--onceが指定されたら、メモリチェックはしない
となるようです。

1番目はまぁそりゃそうなのですが、多分引っかかりそうなのが2番目の「PHPのmemory_limitを引き上げるような動作はしない」。3番目は……そもこの引数「使われてるのかね?」とか思うんですが、使ってるとすると、もしかしたらちょろっと気になるかも。
なんか普通にこの引数を考えると「この引数まではメモリを確保してくれる」ような動作を期待したいような気がするのですが、実際には「やってないと思われます」。

コードを雑に追いかけている限り

vendor/laravel/framework/src/Illuminate/Queue/Console/WorkCommand.php

    public function handle()
    {
        if ($this->downForMaintenance() && $this->option('once')) {
            return $this->worker->sleep($this->option('sleep'));
        }

        // We'll listen to the processed and failed events so we can write information
        // to the console as jobs are processed, which will let the developer watch
        // which jobs are coming through a queue and be informed on its progress.
        $this->listenForEvents();

        $connection = $this->argument('connection')
                        ?: $this->laravel['config']['queue.default'];

        // We need to get the right queue for the connection which is set in the queue
        // configuration file for the application. We will pull it based on the set
        // connection being run for the queue operation currently being executed.
        $queue = $this->getQueue($connection);

        return $this->runWorker(
            $connection, $queue
        );
    }

から

    protected function runWorker($connection, $queue)
    {
        return $this->worker->setName($this->option('name'))
                     ->setCache($this->cache)
                     ->{$this->option('once') ? 'runNextJob' : 'daemon'}(
            $connection, $queue, $this->gatherWorkerOptions()
        );
    }

から
vendor/laravel/framework/src/Illuminate/Queue/Worker.php

    public function daemon($connectionName, $queue, WorkerOptions $options)
    {
        if ($supportsAsyncSignals = $this->supportsAsyncSignals()) {
            $this->listenForSignals();
        }
(中略)
            // Finally, we will check to see if we have exceeded our memory limits or if
            // the queue should restart based on other indications. If so, we'll stop
            // this worker and let whatever is "monitoring" it restart the process.
            $status = $this->stopIfNecessary(
                $options, $lastRestart, $startTime, $jobsProcessed, $job
            );

            if (! is_null($status)) {
                return $this->stop($status);
            }
        }
    }

から

    protected function stopIfNecessary(WorkerOptions $options, $lastRestart, $startTime = 0, $jobsProcessed = 0, $job = null)
    {
        if ($this->shouldQuit) {
            return static::EXIT_SUCCESS;
        } elseif ($this->memoryExceeded($options->memory)) {
            return static::EXIT_MEMORY_LIMIT;
        } elseif ($this->queueShouldRestart($lastRestart)) {
            return static::EXIT_SUCCESS;
        } elseif ($options->stopWhenEmpty && is_null($job)) {
            return static::EXIT_SUCCESS;
        } elseif ($options->maxTime && hrtime(true) / 1e9 - $startTime >= $options->maxTime) {
            return static::EXIT_SUCCESS;
        } elseif ($options->maxJobs && $jobsProcessed >= $options->maxJobs) {
            return static::EXIT_SUCCESS;
        }
    }

から

    public function memoryExceeded($memoryLimit)
    {
        return (memory_get_usage(true) / 1024 / 1024) >= $memoryLimit;
    }

ってな感じぽいです。
ちな、コードほじくってて気づいたのですが、
vendor/laravel/framework/src/Illuminate/Queue/Listener.php

    public function runProcess(Process $process, $memory)
    {
        $process->run(function ($type, $line) {
            $this->handleWorkerOutput($type, $line);
        });

        // Once we have run the job we'll go check if the memory limit has been exceeded
        // for the script. If it has, we will kill this script so the process manager
        // will restart this with a clean slate of memory automatically on exiting.
        if ($this->memoryExceeded($memory)) {
            $this->stop();
        }
    }
(略)

    public function memoryExceeded($memoryLimit)
    {
        return (memory_get_usage(true) / 1024 / 1024) >= $memoryLimit;
    }

ってコードもあるんですよねぇ……まぁおいといて。

なので。
多分なんとなく「PHPががっつり落としてくる前に手前でコントロールできている間にバッチを止める」ってのを意図している一方で「メモリの上限は特に上げてくれない」っぽいんですよねぇ。
あと、細かい所で「明示的に --once の時は多分、上述のメモリチェックもしない」感じですねぇおそらく。

おそらくなんですが、意図としては「バッチがdaemonのようにグルグル動き続ける時に、PHPメモリリークとか含めて"メモリ大量消費"されるのを防ぎたい」って感じ、なんじゃないんですかねぇおそらく???
ただまぁ、だとしたらもうちょっと命名を変えたほうがいいような気がせんでもないのですが……まぁ、まぁ。

軽くググってみた限りだと、意外に記事がなかったぽいので、ちょっくら一筆。

名前付き引数とcall_user_func_array

別件で調査をしていて、ふと「あれ? call_user_func_array() にhash配列渡したら、名前付き引数的にいい感じに処理してくれるんぢゃね?」って思ったので、早速実験。

名前付き引数(PHP8.0から)
https://www.php.net/manual/ja/functions.arguments.php#functions.named-arguments

まず前提になる「今までの」コード。

<?php
declare(strict_types=1);
error_reporting(-1);

//
function test($a, $b, $c) {
    var_dump($a, $b, $c);
}

//
call_user_func_array('test', ['aaa', 'bbb', 'ccc']);

string(3) "aaa"
string(3) "bbb"
string(3) "ccc"

うん。
では、まず「すなおな」hash配列。

call_user_func_array('test', ['a' => 'aaa', 'b' => 'bbb', 'c' => 'ccc']);

string(3) "aaa"
string(3) "bbb"
string(3) "ccc"

まぁここまでは。
ちなみにPHP7.4系で動かしても結果は同じでした。

次に「順番を入れ替え」。

call_user_func_array('test', ['b' => 'bbb', 'c' => 'ccc', 'a' => 'aaa']);

string(3) "aaa"
string(3) "bbb"
string(3) "ccc"

あぁ、予想通り。
ちなみにPHP7.4系だと以下の通り。

string(3) "bbb"
string(3) "ccc"
string(3) "aaa"

ではここから少し意地悪系。
まずは「追加でNGがある」ケース。

call_user_func_array('test', ['b' => 'bbb', 'c' => 'ccc', 'a' => 'aaa', 'd' => 'ddd']);

Fatal error: Uncaught Error: Unknown named parameter $d in ...

うんまぁそうだろうなぁ的な。
ちなPHP7.4系だと「エラーは出ないで動く」。まぁパラメタが多い時に「(自作関数だと)何も言ってくれない」からなぁ……。
したら一応、足りない系も。

call_user_func_array('test', ['b' => 'bbb', 'c' => 'ccc']);

Fatal error: Uncaught ArgumentCountError: test(): Argument #1 ($a) not passed in ...

で~す~よ~ね~。
予想通りの動きでなにより。

うっすらと懸念がないわけではない、にしても、基本的には「hashのほうがわかりやすい」ケースが増えてくるんじゃないかしらん???
興味深かったので、メモかねて。

追伸
なお、元々調査していて期待していた「phpunitのdataProvider」では使えませんでした orz
array_values() とかやってる感じなんかなぁ?(なんかそんな雰囲気の挙動)