gallu’s blog

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

Twig チートシート

「あんちょこ」って大分と古くてあんまり使われてない単語なんだなぁ……というあさってな方向からの感想を述べつつ。

単置換

一番の基本だよねぇ。いわゆる「この変数を出力」ってやつ。

{{ variable }}

配列は.(ドット)でつなげる感じ。

{{ array.key }}

フィルタ

いろいろなフィルタがあって、割と便利。

{{ variable|filter }}

filterは、 https://twig.symfony.com/doc/2.x/ とかみるとわかるですが、なんか結構山盛り。
いくつかかいつまんで、使いそうかなぁ、って思えるものを中心にいくつか。

HTMLエスケープ。ただ、単置換でも普通にエスケープしてくれるからなぁ。
「js用のエスケープ」は、もしかしたら、便利、かも。

{{ variable|escape }}
{{ variable|e }}
{{ variable|escape('js') }}
{{ variable|e('js') }}

小文字になぁれ、大文字になぁれ

{{ variable|lower }}
{{ variable|upper }}

「改行をタグに」

{{ variable|nl2br }}

数値のフォーマット変更各種。PHPのnumber_format()関数と一緒

{{ variable|number_format }}

エスケープ無し。気を付けて使わないと危ないけど、とはいえ「必要な時は(稀に)あるよねぇ」というフィルタ。

{{ variable|raw }}

URLエスケープ

{{ variable|url_encode }}

コメント

コメントはこんな風に。

{# コメント #}

ある程度書いておいたほうが、後々、楽だよねぇ。

条件分岐

いわゆる if 文。

{% if variable == 'hoge' %}
    HTML
{% elseif variable == 'bar' %}
    HTML
{% else %}
    HTML
{% endif %}

演算子は、andとかorとかを使いましょう。

{% if 18 < variable and variable < 21 %}
    HTML
{% else %}
    HTML
{% endif %}

あと、変数が「あるかないか」とか配列が「あるかないか」とか配列の個数とか、その辺は「length」のフィルタ使ってチェックします。

{% if variable|length  %}

とか

{% if array|length  %}

とか。これは「戻り値が0なら、空文字(空配列)か存在しない変数のどちらかだからfalseになるよねぇ」って感じで使います。

反復

いわゆる「PHPのforeach」は、こんな風に。

{% for key, variable in array %}
  {{ key}}の値は{{ variable }}<br>
{% endfor %}

{% for variable in array %}
  値は{{ variable }}<br>
{% endfor %}

「for文で数値の反復」は、rangeを使います。

{% for i in range(0, 10) %}
  {{ i }}
{% endfor %}

また、ループでは「特別な変数」があって、それを使うと色々なことが出来ます。

loop.index  ループ数 1スタート(1 2 3 4 ...
loop.index0  ループ数 0からスタート(0 1 2 3 ...
loop.first 最初のアイテムならtrue
loop.last  最後のアイテムならtrue

変数の設定

あんまり使わん気もするが……どうしても「テンプレート内で変数を定義したい」場合は、こんな風に。

{% set variable = 'hogeramugera' %}

テンプレートの継承

{% extends 'layout.twig' %}

って書いておくと、継承してくれます。

あとは。継承元のファイルで

{% block XXXXXX %}{% endblock %}

って書いておいて、継承先のファイルで

{% block XXXXXX %}これを出力{% endblock %}

とかってやると変換してくれるので。
よくやるのが、headのtitleとかを

{% block title %}{% endblock %}

で宣言して継承先で

{% block title %}ほにゃららページ{% endblock %}

とかって使ったりします。

includeもあるんだけど、あんまり使わないなぁ。
使いたいときは

{% include "parts/include.twig" %}

とかって書式で。



大体、これくらいかなぁ。
過不足あったら、適宜修正していきまふ。

「空っぽのPHPバッチ」の処理コスト

ちょいと故があって、LaravelとplainなPHPとでの、バッチの処理コストを確認してみたんだけど………思ったより差異があって、幾分、びっくり(苦笑
なので、参考とmemo程度に、記録を残しておきます。

環境その他によると思うので、参考値程度に。
PHPのバージョンは、ちょいと古くて7.1系で試してます。

端的には

echo memory_get_peak_usage(true), "\n";

の処理だけ、のバッチを

<?php

$t = microtime(true);

for($i = 0; $i < 10; ++$i) {
    $s = `php artisan Test`;
    //$s = `php batch.php`;
}

$te = microtime(true);
var_dump($te - $t);
var_dump($s);

ってな感じで回していきます。

なので
・処理時間は「10回処理した時」の時間
・食ってるメモリは「1回のバッチで食う」メモリ
ってな感じ。

勿論、処理時間はコロコロぶれるのですが。
大まか
・Laraveで、処理時間1.48秒、メモリは14680064バイト
・palinで、処理時間0.34秒、メモリは2097152バイト
ってな感じ。

処理速度が4倍、メモリが7倍。
ふむ……普通の処理なら「そんなに気にするほどのものでもない」し、実際にもうちょっと「中身のあるバッチ」を組むと差異は恐らく縮んでいく可能性が想起されるし、plainなPHPで組むとそれはそれで色々と懸念とか課題とか山積みではあるんだけど、とはいえ「状況によっては無視しにくい」ような気もするなぁ。

Slim docsの解析; Cook book

これは……重量級かも。
URIが個々に違うので、頑張れるところまで。


Trailing / in route patterns
https://www.slimframework.com/docs/v3/cookbook/route-patterns.html
「スリムは、末尾にスラッシュが付いたURLパターンを、スラッシュなしのものとは異なるものとして扱います。つまり、/ userと/ user /は異なるため、異なるコールバックを添付することができます。(機械翻訳)」うん普通だ。ありがち。
「/で終わるすべてのURLを非トレーディング/同等のものにリダイレクト/リライトする場合は、次のミドルウェアを追加できます。」あら便利。これ、普通につけておいてもよくないかしらん?? いやまぁ「邪魔くさい重い」かもしれないんだけど。


Retrieving IP address
https://www.slimframework.com/docs/v3/cookbook/ip-address.html
あぁ、うんまぁこれは「時々欲しい」やつだ。
「クライアントの現在のIPアドレスを取得する最善の方法は、rka-ip-address-middlewareなどのコンポーネントを使用するミドルウェア経由です。(機械翻訳)」ふむぅ。
ミドルウェアはクライアントのIPアドレスをリクエスト属性に格納するため、$ request-> getAttribute( 'ip_address')を介してアクセスします。機械翻訳)」だよねぇ。だとしたら「これでよい」気もするんだが。
………あぁrka-ip-address-middlewareのコード読んでわかった。
X-Forwarded-Forとか、その辺のheaderに対応しているのか。ふむ、これはコードもシンプルだし、便利やもしれぬ。


Retrieving Current Route
https://www.slimframework.com/docs/v3/cookbook/retrieving-current-route.html
あぁ「現在のルーティングの情報を得る」か。
Middlewareにして「ログに吐き出す」とかやるような感じの時に、便利かも。……それ以外の用途はあんまり思い浮かばないんだけど(苦笑
……って思ったら、今作ってる「SlimLittleTools」で、使った(笑


Using Eloquent with Slim
https://www.slimframework.com/docs/v3/cookbook/database-eloquent.html
Eloquentはなんていうか「色々と懲りた」ので、いらんなぁ(苦笑
なので、略(笑


Using Doctrine with Slim
https://www.slimframework.com/docs/v3/cookbook/database-doctrine.html
こっちも割とパス(笑
まぁ「お好む人もいる」とは思うので、こーゆー情報があるのはよいと思う。


Setting up CORS
https://www.slimframework.com/docs/v3/cookbook/enable-cors.html
Cross origin resource sharing、ですか。
サンプルのコード、どっちかってぇと「CORSに限らず、追加ヘッダ入れたい時」用に使えるんだよねぇ。
これも今作成している「SlimLittleTools」で、近いコードが入ってます(笑


Access-Control-Allow-Methods
ん? なんでこのファイルにこれがあるんだろ???
………あぁそうか全体的に「response headerの追加」なんだな。


Getting and Mocking the Environment
https://www.slimframework.com/docs/v3/cookbook/environment.html
UnitTestでがっつりとお世話になるネタだねぇ、的な。
細かく書くと「先に $container->get('request') とかやった後に Mock Environmentsとか設定すると色々と齟齬る」感じではあるのですが、まぁそーゆー状況は「よっぽど狙って意図しないと」起きないと思われるので、気にせずに。


Uploading files using POST forms
https://www.slimframework.com/docs/v3/cookbook/uploading-files.html
あぁ、すっ飛ばしたネタだ(笑
大まかには

use Slim\Http\UploadedFile;

    $uploadedFiles = $request->getUploadedFiles();

があって。まぁ比較的多い「1ファイルアップ」なら

    // handle single input with single file upload
    $uploadedFile = $uploadedFiles['example1'];
    if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
        $uploadedFile->moveTo(移動先のファイル名);
        $response->write('uploaded ' . $filename . '<br/>');
    }

ってな感じで「ファイルの確認してファイル適当に移動させて」って処理すればOK。
あんまりない「複数ファイル」なら

    foreach ($uploadedFiles['example2'] as $uploadedFile) {
        if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
            $uploadedFile->moveTo(移動先のファイル名);
            $response->write('uploaded ' . $filename . '<br/>');
        }
    }

とあるので、ようは「$uploadedFiles['example2'] が配列になっているよ」程度。
むしろ

<input type="file" name="example3[]" multiple="multiple"/>

HTMLのこの書式、知らなかったよ(笑


Action-Domain-Responder with Slim
https://www.slimframework.com/docs/v3/cookbook/action-domain-responder.html
ふむぅ。
これはまずAction-Domain-Responderパターン(ADR)を把握しないと、そこから先に進めない感じだなぁ。
で………なんか、ピンとこない(苦笑
ので。ちょいと単語だけ心に留め置くとして、一旦、すっ飛ばしませう。


……あ。割とあっさりと終わった(笑

SQL識別子のエスケープ処理とか、どうすっぺか??

発端としては。
Slimを色々いじってるなかで「まぁちょっとしたツールくらい欲しいよねぇ」になり、その過程の一つとして「ほんのりしたModelクラス欲しいなぁ」がありまして。
でまぁ

$r = モデルクラス::insert(データのハッシュ配列);

とかって書式でいけると楽だよねぇ、が、発端。


ほんのりと処理を書いてすぐに気づいたのですが「やだぁSQL-Injectionやりやすい!!」。
いやまぁ値はプリペアドすればよいのですが、テーブル名とカラム名が、ねぇ。
テーブル名はともかく、カラム名は、書き方によっては「hashのkeyにちょいと大嘘つっこんだら、気を付けないとあっという間にクラック可能」でございます。
まぁPDO使ってるから何かエスケープ的メソッドあったよねぇ……ほいほいあったquote( http://php.net/manual/ja/pdo.quote.php )。
ってなわけでサクっと実装してUnitTest……こける。なんで?


確認してみたら、こいつ「シングルクォートを前後につける」だけでやんの orz*1
標準SQL(含むPostgreSQL)では、SQL識別子の前後はダブルクォーテーション。MySQLは基本的にはバックスラッシュ……なんだけど「SET sql_mode='ANSI_QUOTES';」ってのでモードが違うと、標準SQLよろしく「ダブルクォーテーション」になる。………めんどい orz
どっちにしても「quote」が使えないぽいので、escapeメソッドを探す………ない orz


このあたりから、茨の道がstartでございます。


まずネットをググってみますが………エスケープの文脈は大概「値の入れ方」のところで、つまりそれは「プリペアドステートメント使えば足りるぢゃん」って箇所ばかりなので、あんまり参考になりません orz
それでもいくつかヒットはしたのですが………


https://d.nekoruri.jp/entry/20131211/no_escape

3. RDBMSの管理ツールなどの開発をしている場合は頑張ってエスケープする
RDBMSの管理ツールなどをあなたが実装しているのであれば、ユーザがうっかり作ってしまった記号まみれのテーブル名に対してもアクセスできないといけないかもしれません。
おめでとうございます!
ここまできて初めてエスケープが必要となります。

おめでたくないです orz
んで

個人的には、RDBMSエスケープAPIや既存のライブラリを用いて「プレースホルダに値をエスケープして埋め込む」という関数をきっちり作り込み、そこに集約させるのが良いのでは無いかな、と思います。
決して、エスケープ処理自身を一から自分で書こうだなんて恐ろしいことを考えないでください。
その必要があるのは、あなたがRDBMS自身やそのライブラリの開発者の場合だけです。

その「提供された関数(メソッド)」が、現状、ないのでございます orz orz orz


https://blog.tokumaru.org/2013/12/sql_27.html

SQL識別子に関しては「もっと良い方法」がある
そもそも、SQL識別子をエスケープ処理しなければならない局面は以下であると考えられます。
・データベース管理ツールを作成していて、識別子はユーザ入力である(局面1)
・アプリケーション内でテーブル名や列名をジェネレートしており、これらを構成する文字として引用符が使われる可能性がある(局面2)
局面1の典型例は、phpMyAdminMySQL Workbenchを作成する場合ですが、これは識別子のエスケープ処理は必須ですね。しかし、この種のツールを作る人であれば、当然識別子のエスケープ処理くらいは知っているだろう…と思っていただけに、MySQL Workbenchの識別子のエスケープもれがあったことは驚きでした。しかし、前述のようのように、幸い重大な脆弱性とまでは言えません。データベース管理ツールを作る開発者はまれだと思われるので、この件は本稿ではこれ以上触れません。

「データベース管理ツールを作る開発者はまれだと思われるので、この件は本稿ではこれ以上触れません」触れてもらえなかった orz orz orz
いや厳密には「データベース管理ツール」ではないんだけど………方向性として「かなり似ている」ので orz


大体にしてから。
MySQL Workbenchの識別子のエスケープもれがあったことは驚きでした( https://blog.tokumaru.org/2013/12/sql_26.html )」を、ほんのりとはいえ、覚えているので。「しっかりした抜け漏れのないエスケープ処理」とか、さすがに全く自信がないのですよ orz
だから、他人様の知恵を、ググってお借りしたかった orz


……うん、一応おいちゃんもオリジナルのフレームワークは持っているのですが(Githubで公開もしてますし、そこそこ実務実績もあります)。
うちの、世間さまでModelとか呼ばれるものに相当するdata_clumpっていうクラスは「カラム名もテーブル名も(第一種)ホワイトリスト」なので、エスケープとか、あんまり深刻ではなかったのですだよ orz
まぁそこ嘆いても始まらんのですが。


さて……どうエスケープすっかなぁ??? なのですが。
PostgreSQLだと「pg_escape_identifier()」ってのがあるのですが……PDOで接続していると「それとは別にもう1connection」なので、ちょっと、なぁ。
MySQLだと「real_escape_string()」になりそう……なんだけど、そもそもこれ「値用」なので、識別子用じゃない orz
いやなんていうか本気で「公式関数での方向がふさがれている」のは、地味にツライのです orz


とりあえず「エスケープ自体の仕様」を、軽く確認。
https://blog.ohgaki.net/sql-identifier-escape

SQLリテラルのシングルクォートをシングルクォートでエスケープするように、SQL識別子ではダブルクォートをダブルクォートでエスケープします。

https://blog.ohgaki.net/mysql-postgresql-sqlite-identifier-escape#MySQL

通常モードの場合、識別子は`(バッククオート)2で囲みます。また、識別子に利用できる文字は次の通りです。
(中略)
ANSI QUOTESモードの場合はPostgreSQLと同様に”(ダブルクオート)で囲み”(ダブルクオート)でエスケープします。文字リテラルPostgreSQLと同様に’(シングルクオート)で囲み’(シングルクオート)でエスケープになります。

あとは識別子の長さもあるっぽいのですが(63バイトとか)、一旦ドン無視。


識別子自体の仕様は
https://hidekatsu-izuno.hatenablog.com/entry/2015/12/07/233618

MySQL
  英数で始まる 英数(A-Z0-9)、アンダースコア(_)、ドル($)からなる文字列
  64バイト以内
  大文字/小文字の区別なし(ただし、一部の環境では混在不可)
  バッククォート(`識別子`)にてエスケープ可能、後者は ANSI_QUOTES 有効時はダブルクォート("識別子") も可


PostgreSQL
  英字あるいはアンダースコアで始まる英数(A-Z0-9)、アンダースコア(_)、ドル($)からなる文字列
  64バイト以内(設定で変更可)
  大文字/小文字の区別なし(小文字に正規化)
  ダブルクォート("識別子")、ユニコードクォート(U&"識\5225+005B50")にてエスケープ可能

結構色々あるんだよなぁ………。
……あれ? MySQL、日本語も確かいけたはずだぞ??
調査し直し。


https://dev.mysql.com/doc/refman/5.6/ja/identifiers.html

識別子は内部で Unicode に変換されます。以下の文字を含めることができます。
  引用符で囲まれていない識別子で許可される文字。
    ASCII: [0-9,a-z,A-Z$_] (基本的なラテン文字、0-9 の数字、ドル、下線)
    拡張: U+0080 ..U+FFFF
  引用符で囲まれている識別子で許可される文字には、U+0000 を除き、完全な Unicode Basic Multilingual Plane (BMP) が含まれます。
    ASCII: U+0001 ..U+007F
    拡張: U+0080 ..U+FFFF
  ASCII NUL (U+0000) と補助文字 (U+10000 以上) は、引用符で囲まれた識別子または引用符で囲まれていない識別子では許可されません。
  識別子は数字で始めることができますが、引用符で囲まれていないかぎり、数字のみで構成することはできません。
  データベース名、テーブル名、およびカラム名は、空白文字で終えることはできません。

うわぁ思ったより面倒 orz
まぁ、やっぱり原典を当たるべきだね、うん。改めてしみじみ。


ついで、PostgreSQL
https://www.postgresql.jp/document/9.6/html/sql-syntax-lexical.html#sql-syntax-identifiers
「デフォルトではNAMEDATALENは64なので、識別子は最長で63バイトです。 この制限が問題になる場合は、src/include/pg_config_manual.h内のNAMEDATALEN定数の値を変更して増やすことができます。 」すげぇなおい。
おいといて。

SQL識別子とキーワードは、文字(a?zおよび発音区別符号付き文字と非Latin文字)、アンダースコア(_)で始まらなければいけません。 識別子またはキーワードの中で続く文字は、文字、アンダースコア、数字(0?9)あるいはドル記号($)を使用することができます。 標準SQLの記述に従うと、ドル記号は識別子内では使用できないことに注意してください。 ですから、これを使用するとアプリケーションの移植性は低くなる可能性があります。 標準SQLでは、数字を含む、あるいはアンダースコアで始まったり終わったりするキーワードは定義されていません。 したがって、この形式の識別子は標準の今後の拡張と競合する可能性がないという意味で安全と言えます。

ふむり。

引用符付き識別子は、コード0の文字以外であればどのような文字でも使えます (二重引用符を含めたい場合は、二重引用符を2つ入力します)。 これにより、空白やアンパサンド(&)を含むテーブル名や列名など、この方法がなければ作れないような名前のものを作ることが可能になります。 この場合においても長さの制限は適用されます。

こっちがおっかない。


……これ、DB毎にチェックする元気と気力と工数と体力、ないぞ??
ただ、幾分興味深いのが。
・だいたい、英数とアンダースコア
・ハイフンはNGっぽい(なんとなく、いけそうにおもってた)
・アンダースコア以外の記号はまちまち(なんだけど、厳しいところだと割とダメぽ)
あたりが多いんだよね。「PostgreSQLは小文字のみ」とかあるんだけど、その辺は一旦踏みつぶし倒し。


短絡的に「ダブルクォートとかバックスラッシュとかが文字列にあったら二重にした上で全体をダブルクォートとかバックスラッシュとかで囲む」って考えてもよいのですが、その程度で片付くんなら誰も困りゃしません、ってお話でございます。
で……そこから「抜け漏れ」とか考え出すと、なんていうか、割と、沼。


で……現実的に(とりあえず直近で使いたい)プロジェクトも併せて考えると。あんまり時間も大量にはないので。一端ですが
・テーブル名は「クラスによる選択」で、外部から一切入ってこないので、あんまり気にしない
を前提にして(まぁなんかエスケープはしたいが)。
カラム名を、割とがっつり「縛る」と、よさげかしらん? 的な。


ちと「想定している実装」を考えながら考察を重ねてみませう。


insert(とかupdateとか)の値って、ある程度まとめて「formからごそっと取ってきたい」んだよね。
サンプルコード的には

$data = $request->getSpecifiedParams(['col_1', 'col_2', 'col_3', ..... ]);
$r = モデルクラス::insert($data);

こんな感じ。
………うんまぁ多分なんとなく、都度、列挙書くのが面倒だったりするから

$data = $request->getSpecifiedParams(モデルクラス::getParamNames());
$r = モデルクラス::insertFromRequest($request);

とか

$r = モデルクラス::insertFromRequest($request);

とかってなりそうな気もするんだけど(とはいえ結局、列挙、書くの面倒なんだよなぁ……またバッチ作るかなぁ。MySQLのはあるから、PostgreSQLの、簡易バッチ、くらい)。


その辺考えると「事実上ホワイトリストっぽくなる」ような気もする……んだけど、Githubに持ち上げて公開もするつもりだから、一応「もうちょっとModel基底クラス単体でもガード」しておきたい、んだよねぇ。
雑に考えると。
テーブル名はまぁ「よしなに」していただくとして(そこに外部起因が入るとはあんまり思ってない)。
カラム名は「半角英数とアンダースコア」のみ、に絞る(それ以外があったらエラー):文字長はどうするかなぁ?
 →「ほかの記号」とか「unicode」とか、一旦、踏みつぶしますw
 →テーブル名も「同じチェックを一端する」でよいかしらん?
PostgreSQLならダブルクォーテーション、MySQLならバックスラッシュで囲う
 → 「囲う」文字列が元文字列にあったら、重ねる(「二重引用符を含めたい場合は、二重引用符を2つ入力します」的な処理):まぁ上述のvalidateやってたら入り込まないが
ってやると、とりあえず「自由度は下がるけど、ある程度の硬さは保持できる」んじゃないかなぁ? と思っていた。


後は「プリペアドステートメント」で使ってる「名前付きプレースホルダ」の名前、か。
……一旦は「半角英数とアンダースコアのみ」なら、そのままでよいかなぁ?
後で改修したほうがいいんだろうけど……この手のライブラリでやるんなら、名前のつかない、?のプレースホルダのほうが楽なのかしらん??
ちと思案してみませう。


とりあえず思考整理を兼ねて、一旦、記述。
後で読み直してみますが、なんか突っ込みどころなどあったら、突っ込みをいただけるとありがたいです。

*1:いやまぁ「値にシングルクォートとかあったらよしなに対応してくれるんだろう」とは思うんですが、その辺面倒なんで未実験。どのみち「シングルクォート」だと駄目だし

Slim docsの解析; System Error Handler

https://www.slimframework.com/docs/v3/handlers/error.html
地味に大事なあたり。


Default error handler
「デフォルトのエラーハンドラは非常に基本的です。 Responseステータスコードを500に設定し、Responseコンテンツタイプをtext / htmlに設定し、Response本体に汎用エラーメッセージを追加します。
 これは、実稼動アプリケーションにはおそらく適切ではありません。 独自のSlimアプリケーションエラーハンドラを実装することを強くお勧めします。(機械翻訳)」。
うん身もふたもないw


「デフォルトのエラーハンドラには、詳細なエラー診断情報を含めることもできます。 これを有効にするには、displayErrorDetails設定をtrueに設定する必要があります。(機械翻訳)」ふむり。
これはデフォでtrueにしておいてもよいのやもしれぬ。


Custom error handler
ふむ……先に余談。
「A Slim Framework application's error handler is a Pimple service.」……Pimple service?
https://pimple.symfony.com/
これ、か。へぇ。
なんか「えらいことシンプルでお好み」だと思ったんだが、ここだけ切り出されてるのか。興味深い。


んで、本題。
「There are two ways to inject handlers:」ほぉ。

$c = new \Slim\Container();
$c['errorHandler'] = function ($c) {
    return function ($request, $response, $exception) use ($c) {
        return $c['response']->withStatus(500)
                             ->withHeader('Content-Type', 'text/html')
                             ->write('Something went wrong!');
    };
};
$app = new \Slim\App($c);
$app = new \Slim\App();
$c = $app->getContainer();
$c['errorHandler'] = function ($c) {
    return function ($request, $response, $exception) use ($c) {
        return $c['response']->withStatus(500)
                             ->withHeader('Content-Type', 'text/html')
                             ->write('Something went wrong!');
    };
};

あんま変わらんw
どっちかってぇと。writeのところのコンテンツを「テンプレートエンジン側で用意した、デフォルトのテンプレートにすり替える」とか、なんだろうなぁ。実際の実装時は。


Class-based error handler
うんまぁこれも。ようは「無名関数返す」か「__invoke()が実装されたクラスインスタンスを返すか」なので、中身的にはあんまり変わらん。


Handling other errors
ふむ……ようは
・notAllowedHandler https://www.slimframework.com/docs/v3/handlers/not-allowed.html :ルーティングで、URIはあったけどmethodがマッチしなかったよ!
・notFoundHandler https://www.slimframework.com/docs/v3/handlers/not-found.html :ルーティングにマッチしなかったよ!
・phpErrorHandler https://www.slimframework.com/docs/v3/handlers/php-error.html :PHP7以降のランタイムエラー
・SlimExceptionは補足できないよ
・上述以外は errorHandler
って感じだねぇ。


Disabling
やらないと思うw


ふむ、シンプルに片付いた。
まぁとりあえず「errorHandlerはmust & 念のために"本番用"もちゃんと用意しよう」ってのと。
それ以外については「ざっくりと"本番用"を用意」しておくとよい、のかなぁ。
まぁ場所は把握できたので、あとは必要に応じて。

Slim docsの解析; 寄り道してCookie

Slim docsには記載がないっぽいのですが。
まぁ普段「sessionとCookieはよく使うよねぇ」というあたりで、その辺を少し検証。


なおセッションはどうも、公式のSkeletonですら
・自力でsession_start()発行
・sessionっぽい字面のクラスがない
ので、「おとなしく生PHPの機能使え」って感じぽいです。
うんまぁそれはそれであり。
なお「おいちゃんSkeleton」では「セッション系記述用のファイルを1つ作ってそこで"随所に関所"する」予定。


あと、セッションについては。最近いくつかのフレームワークで見かける「flash」をどうすっかなぁ? くらい。
……どうすっかなぁ本当に。Laravelのコードとか軽く読んでみたけど、割と「がっつりラッピングして自由度削ってる」しなぁ。
うん。一端放置しよう(笑


で、一方のCookie
確認をすると
・クラスはある( ./vendor/slim/slim/Slim/Http/Cookies.php )
・使ってる箇所はないぽい
な感じ。


厳密には。
vendor/slim/slim/Slim/Http/Request.php が使ってるぽいんだけど

        $cookies = Cookies::parseHeader($headers->get('Cookie', []));

だけ。ちなみにここを見ると
vendor/slim/slim/Slim/Http/Cookies.php

    public static function parseHeader($header)
    {
        if (is_array($header) === true) {
            $header = isset($header[0]) ? $header[0] : '';
        }

        if (is_string($header) === false) {
            throw new InvalidArgumentException('Cannot parse Cookie data. Header value must be a string.');
        }

        $header = rtrim($header, "\r\n");
        $pieces = preg_split('@[;]\s*@', $header);
        $cookies = [];

        foreach ($pieces as $cookie) {
            $cookie = explode('=', $cookie, 2);

            if (count($cookie) === 2) {
                $key = urldecode($cookie[0]);
                $value = urldecode($cookie[1]);

                if (!isset($cookies[$key])) {
                    $cookies[$key] = $value;
                }
            }
        }

        return $cookies;
    }

がっつりと「自力でparseしてる」ので、幾分「ふぅん」という感じ。$_COOKIE使ってるわけじゃないんだ。


んで。
https://github.com/slimphp/Slim/issues/1310
に質問があるんだけど
・ど〜やってCookie使うのん?
に対して
「In the meantime I'd suggest using the standard PHP functions.」とか書いてあって「あぁ標準関数かぁ」とか思うわけでございます。
ちなみに「こんど準備する」って書いてありますが、肝心の https://github.com/slimphp/Slim-HttpCookies が、これを書いている限りでは「3 years ago」以降、変更がございませぬ、のと、ソースコードが一切上がっておりません。


「じゃぁsetcookieでいいぢゃん」とか思わなくもないのですが、他方で、幾分気になるロジックが。
vendor/slim/slim/Slim/Http/Cookies.php

    /**
     * Set response cookie
     *
     * @param string       $name  Cookie name
     * @param string|array $value Cookie value, or cookie properties
     */
    public function set($name, $value)
    {
        if (!is_array($value)) {
            $value = ['value' => (string)$value];
        }
        $this->responseCookies[$name] = array_replace($this->defaults, $value);
    }

と、

    /**
     * Convert to `Set-Cookie` headers
     *
     * @return string[]
     */
    public function toHeaders()
    {
        $headers = [];
        foreach ($this->responseCookies as $name => $properties) {
            $headers[] = $this->toHeader($name, $properties);
        }

        return $headers;
    }
    /**
     * Convert to `Set-Cookie` header
     *
     * @param  string $name       Cookie name
     * @param  array  $properties Cookie properties
     *
     * @return string
     */
    protected function toHeader($name, array $properties)
    {
        $result = urlencode($name) . '=' . urlencode($properties['value']);

        if (isset($properties['domain'])) {
            $result .= '; domain=' . $properties['domain'];
        }

        if (isset($properties['path'])) {
            $result .= '; path=' . $properties['path'];
        }

        if (isset($properties['expires'])) {
            if (is_string($properties['expires'])) {
                $timestamp = strtotime($properties['expires']);
            } else {
                $timestamp = (int)$properties['expires'];
            }
            if ($timestamp !== 0) {
                $result .= '; expires=' . gmdate('D, d-M-Y H:i:s e', $timestamp);
            }
        }

        if (isset($properties['secure']) && $properties['secure']) {
            $result .= '; secure';
        }

        if (isset($properties['hostonly']) && $properties['hostonly']) {
            $result .= '; HostOnly';
        }

        if (isset($properties['httponly']) && $properties['httponly']) {
            $result .= '; HttpOnly';
        }

        if (isset($properties['samesite']) && in_array(strtolower($properties['samesite']), ['lax', 'strict'], true)) {
            // While strtolower is needed for correct comparison, the RFC doesn't care about case
            $result .= '; SameSite=' . $properties['samesite'];
        }

        return $result;
    }

でございます。
つまり
・なんとなく、前提としてのメソッドは一式あるっぽい
んですなぁ。


ということは
CookieのreadはRequestから
・Cookiesのインスタンスを、container あたりに → Cookieの設定は、ここからsetメソッドで
・どこか(基本は終了する処理あたりがよいなぁ)に、「CookiesのインスタンスからtoHeaders()メソッドでresponseヘッダにSet-Cookieを追加」
とかやると、割と「美しい」んじゃなかろうか? と。
したらまぁ、最悪は「Cookiesのインスタンスのsetメソッドでsetcookie発行すればいいや」とかいう、雑なこともできそうなのでw


別解として https://github.com/dflydev/dflydev-fig-cookies ってのが割とあちこちで出てくるんだけどねぇ。
更新が大体2〜3年前、ってのもあるし、一旦は「できるだけ自力実装」したいので(「他人のを使う」のは、自力実装で状況を理解してからでもできるので)。
一旦は、自力で頑張ってみませう。


ってなわけで
・Cookiesのクラスを軽く解析しつつ
・「Cookieを発行して読み込める」ところまで一式
を、レッツらGo*1


何はともあれコンストラクタを拝見。
vendor/slim/slim/Slim/Http/Cookies.php

    public function __construct(array $cookies = [])
    {
        $this->requestCookies = $cookies;
    }

ふぅん。「requestCookies」となっていて、それを保持する、のか。
見ると

    public function get($name, $default = null)
    {
        return isset($this->requestCookies[$name]) ? $this->requestCookies[$name] : $default;
    }

ってのもあるなぁ。
ふむ……
vendor/slim/slim/Slim/Http/Request.php

    public function getCookieParam($key, $default = null)
    {
        $cookies = $this->getCookieParams();
        $result = $default;
        if (isset($cookies[$key])) {
            $result = $cookies[$key];
        }

        return $result;
    }

ってのもあるんで、幾分悩ましいんだけど。
まぁ「Requestに乗っかってくるCookieは、プログラム実行中に変更されない(不変)」のはず、なので。
この辺は「お好み」なのかも、なぁ。


んで。割と重要なのが

    /**
     * Default cookie properties
     *
     * @var array
     */
    protected $defaults = [
        'value' => '',
        'domain' => null,
        'hostonly' => null,
        'path' => null,
        'expires' => null,
        'secure' => false,
        'httponly' => false,
        'samesite' => null
    ];
    /**
     * Set default cookie properties
     *
     * @param array $settings
     */
    public function setDefaults(array $settings)
    {
        $this->defaults = array_replace($this->defaults, $settings);
    }

この辺。
httponlyと、必要に応じてpathやsecure、expires、くらいは変更したい、かもしれない。samesiteもあるので、気になる御仁もいらっしゃろうかと思うのですが如何でしょうか。


さてこのsetDefaults(っつかここで設定される $this->defaults)、割と多様されるかも。
ってのが、どうもset()、toHeaders()、toHeader()を見ていると「setのタイミングの $this->defaults が、個々のnameごとに保持されて、それをもとにSet-Cookieヘッダを作る」ぽい、から。


うん見えてきたんだけど、まずは「実際にコード書いて動きを確定」させて、そこから次の考察を重ねてみませう。
っつわけで、ざっくりと
Cookieの読み込み(まだ未設定だからnullのはずだけど)
・2種類の、パラメタの違うCookieを設定
あたりをやる一連の処理を、ざっくりとでっち上げ。
public/index.php

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        $response = $next($request, $response);
        ob_start();
        $cookie = $this->container->cookie;
        var_dump( $cookie->toHeaders() );
        $s = ob_get_clean();
        return $response->write($s);
    }
}

//
$app = new \Slim\App;

// Cookieインスタンス作成&コンテナに突っ込み
$container = $app->getContainer();
$container['cookie'] = function ($c) {
    // インスタンス作成
    $cobj = new \Slim\Http\Cookies($c->get('request')->getCookieParams());
    // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
    // $cobj->setDefaults( $c->get('settings')['cookie'] );
    //
    return $cobj;
};

// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );

// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    // Cookieの読み込みと表示
    ob_start();
    $cookie = $this->cookie;
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    // Cookieの設定その1
    $cookie->set('hoge', mt_rand(0, 99));
    // Cookieの設定その2
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600), 'secure' => true]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

$app->run();
hoge test
NULL
NULL
array(2) {
  [0]=>
  string(7) "hoge=43"
  [1]=>
  string(63) "foo=25; expires=Thu, 01-Jan-1970 01:00:00 UTC; secure; HttpOnly"
}

うん概ね予想通り。
さて、では本命の「Set-Cookie込み」。
このヘッダは「追加追加」なので、withHeader()じゃなくてwithAddedHeader()である、ってあたりに気を付けてみませう。

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        $response = $next($request, $response);
        // Cookieの設定
        foreach($this->container->cookie->toHeaders() as $cookie_string) {
            $response = $response->withAddedHeader('Set-Cookie', $cookie_string);
        }
        return $response;
    }
}

//
$app = new \Slim\App;

// Cookieインスタンス作成&コンテナに突っ込み
$container = $app->getContainer();
$container['cookie'] = function ($c) {
    // インスタンス作成
    $cobj = new \Slim\Http\Cookies($c->get('request')->getCookieParams());
    // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
    // $cobj->setDefaults( $c->get('settings')['cookie'] );
    //
    return $cobj;
};
// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );
// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    // Cookieの読み込みと表示
    ob_start();
    $cookie = $this->cookie;
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    // Cookieの設定その1
    $cookie->set('hoge', mt_rand(0, 99));
    // Cookieの設定その2
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600) ]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

$app->run();
hoge test
string(2) "48"
string(2) "46"

よし、意図通り。


多分、「比較的綺麗に」やると、こんな感じだなぁ。
………ふむ、この手の「ちょっとしたMiddleware」とか、1パッケージにまとめてみるかしらん???


パッケージにまとめるとすると、何となし、Middlewareクラス一か所に処理まとめたいなぁ。

// Cookie用ミドルウェア
class cookieMiddleware {
    private $container;
    public function __construct($container) {
        $this->container = $container;
    }
    public function __invoke($request, $response, $next) {
        // 事前処理:インスタンスの生成
        $this->container['cookie'] = function ($c) {
            // インスタンス作成
            $cobj = new \Slim\Http\Cookies($this->container->get('request')->getCookieParams());
            // XXX ここに「デフォ設定」が書いてあるつもり & 突っ込むつもり
            $settings = $this->container->get('settings');
            if (true === isset($settings['cookie'])) {
                 $cobj->setDefaults( $settings['cookie'] );
            }
            //
            return $cobj;
        };

        // 本処理
        $response = $next($request, $response);

        // 事後処理:setCookieヘッダ群の出力
        foreach($this->container->get('cookie')->toHeaders() as $cookie_string) {
            $response = $response->withAddedHeader('Set-Cookie', $cookie_string);
        }
        return $response;
    }
}

//
$app = new \Slim\App;

// middlewareの設定
$app->add( new cookieMiddleware($app->getContainer()) );

// ルーティング
$app->get('/', function(Request $request, Response $response, array $args) {
    ob_start();
    $cookie = $this->get('cookie');
    var_dump($cookie->get('hoge'));
    var_dump($cookie->get('foo'));
    $s = ob_get_clean();
    //
    $cookie->set('hoge', mt_rand(0, 99));
    //
    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, time() + 3600) ]);
    $cookie->set('foo', mt_rand(0, 99));
    //
    return $response->write("hoge test\n" . $s);
});

よしまとまった。これだな。
あとは……setDefaultsのところのtypoとかが気になるなぁ。なんか、チェックロジックとか入れてみるかしらん??


もう一個。
Cookieを削除する」時は、大雑把には

    $cookie->setDefaults(['httponly' => true, 'expires' => date(DATE_COOKIE, 0) ]);
    $cookie->set('foo', '');

で消せマッスル。……これだけなんかメソッド切っておいたほうが楽なんじゃないか? って気が、せんでもない。
まぁ気をつけないと「後続のCookieが全部消える」とかありそうなので。どっちかってぇと、getDefaults()がある、とか、その辺のほうが安全なような気もするのだが。
………ラッパークラス、書こうかしらん???


最後。
Cookie自体の運用上の注意として。
defaultsの
・どこは毎回さわって
・どこは変更しない
のかを、内々に決めておかないと、危ない、かも。
この辺は「ルール決め」の範疇だねぇ。
……Cookies、継承クラスとか作ってラップメソッドとか書いたほうが、楽、かもしれない。


まぁとりあえず、一通り「Cookieのreadとwrite」が出来たので、よし、としませう。

*1:びっくりするほどに古いwww

Slim docsの解析; Middleware

https://www.slimframework.com/docs/v3/concepts/middleware.html
ネタ的には「最重要クラス」に重要なネタなので、腰を据えて。


とりあえず、こちらの画像が一番わかりやすいんだろうなぁ、って思う。
https://www.slimframework.com/docs/v3/images/middleware.png


今まで書いてきたのはapp、つまりは「一番真ん中」で。
その外側に「Middleware 1」をラッピングして、さらにその外側に「Middleware 2」をラッピングして……的に、玉ねぎのような状態にする感じ。
なので、実行順番としては
・Middleware 2の入り口
・Middleware 1の入り口
・app(本体)
・Middleware 1の出口
・Middleware 2の出口
って感じで動く。エンジニアなら理解しやすい入れ子構造なんじゃないかなぁ?


個人的には(=MagicWeaponでは)、この辺を「クラスの継承」関係で書いてたんだけど。
いや「クラスの継承で書けばいいじゃん」って思わなくもないんだけど(問題なく書けてたし)、ただまぁ「後で付け足す」って感じで考えると、このMiddlewareって考え方は「面白いなぁ」って思うざますの。


なんて感じで「大まかに概念を把握した」ところで、さっそく、実装方法。
基本的には
・Middleware本体のクラスの記述
・Middlewareを「適用させる」ための、routerへの登録
の2種類の手順が必要ぽ。


Middlewareはどうも実際には「クラスでも関数でもOK」ぽいんだけど、まぁとりあえずクラス。
その場合「実装は__invoke()に書いてね」って感じらしい。
これ、多分「Middlewareを入れる場所を用意する」&「その辺をauto_loaderで読み込めるようにする」的なことが必要なんだろうなぁ。
ただその辺の考察は厳密には「今回の考察の範疇外」なので、今回は「index.phpにべた書き」とかいう、乱雑な方法を使いますw

class ExampleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE:');
        $response = $next($request, $response);
        $response->write(':AFTER');
        return $response;
    }
}

$app = new \Slim\App;
$app->add(new ExampleMiddleware() );
$app->get('/', function(Request $request, Response $response, array $args) {
    echo "hoge test\n";
});
$app->get('/aaa', function(Request $request, Response $response, array $args) {
    echo "hoge aaa\n";
});

BEFORE::AFTERhoge test

あら。………あぁそうか「echoで取ってきてる」から、最後に足されちゃうのか。
ちょいと書き換え。

class ExampleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE:');
        $response = $next($request, $response);
        $response->write(':AFTER');
        return $response;
    }
}

$app = new \Slim\App;
$app->add(new ExampleMiddleware() );
$app->get('/', function(Request $request, Response $response, array $args) {
    return $response->write("hoge test\n");
});
$app->get('/aaa', function(Request $request, Response $response, array $args) {
    return $response->write("hoge aaaa\n");
});

BEFORE:hoge test
:AFTER

はい予想どおり。


軽く、入れ子も確認。

class ExampleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE:');
        $response = $next($request, $response);
        $response->write(':AFTER');
        return $response;
    }
}
class ExampleMiddleware2
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE 2:');
        $response = $next($request, $response);
        $response->write(':AFTER 2');
        return $response;
    }
}


$app = new \Slim\App;
$app->add(new ExampleMiddleware() );
$app->add(new ExampleMiddleware2() );
$app->get('/', function(Request $request, Response $response, array $args) {
    return $response->write("hoge test\n");
});
$app->get('/aaa', function(Request $request, Response $response, array $args) {
    return $response->write("hoge aaaa\n");
});

今度は/aaaにアクセス。

BEFORE 2:BEFORE:hoge aaaa

AFTER
AFTER 2

はい、きれいに入れ子


ふと疑問。「一部広域」「一部局所」だとどうなるんだろ?
多分「広域が一番外側」だと思われるんだけど。

class ExampleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE:');
        $response = $next($request, $response);
        $response->write(':AFTER');
        return $response;
    }
}
class ExampleMiddleware2
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE 2:');
        $response = $next($request, $response);
        $response->write(':AFTER 2');
        return $response;
    }
}


$app = new \Slim\App;
$app->add(new ExampleMiddleware() );
$app->get('/', function(Request $request, Response $response, array $args) {
    return $response->write("hoge test\n");
})->add(new ExampleMiddleware2() );
$app->get('/aaa', function(Request $request, Response $response, array $args) {
    return $response->write("hoge aaaa\n");
});

まず/aaaは

BEFORE:hoge aaaa
:AFTER

なのでOK。
次、/にアクセス。

BEFORE:BEFORE 2:hoge test

AFTER 2
AFTER

うん予想通り。「全体→局所」で動くねぇ。


Group Middleware
あたりまでは大体
・想像できる動き
・「(無名含む)関数」は基本書くつもりなし
なので、放置。


Passing variables from middleware
ミドルウェアから属性を渡す最も簡単な方法は、要求の属性を使用することです。(機械翻訳)」。

$request = $request->withAttribute('foo', 'bar');

で値を設定して

$foo = $request->getAttribute('foo');

で使う、と。
ふむぅ……MagicWeaponのbagとか、突っ込んどこうかしらん?
とりあえず、ほんのり記憶はしておきませう。


Finding available middleware
むぅ。色々あるのかまぁそうか。
https://github.com/oscarotero/psr7-middlewares
https://github.com/slimphp/Slim/wiki/Middleware-for-Slim-Framework-v3.x
https://github.com/lalop/awesome-psr7


さて。
ちょりと気になったのが
・middlewareの中でcontainer使える?
・「Middlewareの途中でエラー」の出し方
の2つ。


「middlewareの中でcontainer使える?」
は、ググってみたら「コンストラクタで渡したら?」ってな記述があった。
うんまぁAppに相当する本体も「コンストラクタ渡し」なので、それでよいかなぁ。よし解決w


疑問の本命「エラー」。
予想してみたコード。

class ExampleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->write('Middleware Error!!');

        /*
        $response->write('BEFORE:');
        $response = $next($request, $response);
        $response->write(':AFTER');
        */
        return $response;
    }
}
class ExampleMiddleware2
{
    public function __invoke($request, $response, $next)
    {
        $response->write('BEFORE 2:');
        $response = $next($request, $response);
        $response->write(':AFTER 2');
        return $response;
    }
}


$app = new \Slim\App;
$app->add(new ExampleMiddleware() );
$app->get('/', function(Request $request, Response $response, array $args) {
    return $response->write("hoge test\n");
})->add(new ExampleMiddleware2() );
$app->get('/aaa', function(Request $request, Response $response, array $args) {
    return $response->write("hoge aaaa\n");
});

Middleware Error!!

よし1mmの狂いもなく予想的中w


比較的あっさりと終わったな。
さすがはSlim、全体的に思想がシンプルだ。素晴らしい。