BEAR.Sunday Complete Manual

このページは、BEAR.Sundayの全ドキュメントを1ページにまとめた包括的なマニュアルです。参照、印刷、オフライン閲覧に便利です。


技術

BEAR.Sundayの特徴的な技術と機能を以下の章に分けて解説します。

アーキテクチャと設計原則

リソース指向アーキテクチャ (ROA)

BEAR.SundayのROAは、WebアプリケーションでRESTful APIを実現するアーキテクチャです。これはBEAR.Sundayの設計原則の核となるものであり、ハイパーメディアフレームワークであると同時にサービスとしてのオブジェクト(Object as a service)として扱います。Webと同様に、全てのデータや機能をリソースとみなし、GET、POST、PUT、DELETEなどの標準化されたインターフェースを通じて操作します。

URI

URI(Uniform Resource Identifier)はWebの成功の鍵となる要素であり、BEAR.SundayのROAの中核でもあります。アプリケーションが扱う全てのリソースにURIを割り当てることで、リソースを識別し、アクセスしやすくなります。URIは、リソースの識別子として機能するだけでなく、リソース間のリンクを表現するためにも使用されます。

ユニフォームインターフェース

リソースへのアクセスはHTTPのメソッド(GET, POST, PUT, DELETE)を用いて行われます。これらのメソッドはリソースに対して実行できる操作を規定しており、リソースの種類にかかわらず共通のインターフェースを提供します。

ハイパーメディア

BEAR.SundayのROAでは、各リソースがハイパーリンクを通じてアフォーダンス(クライアントが利用可能な操作や機能)を提供します。これらのリンクは、クライアントが利用できる操作を表し、アプリケーション内をナビゲートする方法を示します。

状態と表現の分離

BEAR.SundayのROAでは、リソースの状態とそのリソース表現が明確に分離されています。リソースの状態はリソースクラスで管理され、リソースにインジェクトされたレンダラーが様々な形式(JSON, HTMLなど)でリソースの状態をリソース状態表現に変換します。ドメインロジックとプレゼンテーションロジックは疎結合で、同じコードでもコンテキストによって状態表現の束縛を変更すると表現も変わります。

MVCとの相違点

BEAR.SundayのROAは、従来のMVCアーキテクチャとは異なるアプローチを採用しています。 MVCはモデル、ビュー、コントローラーの3つのコンポーネントでアプリケーションを構成し、コントローラーはリクエストオブジェクトを受け取り、一連の処理を制御してレスポンスを返します。一方、リソースはリクエストメソッドにおいて、単一責任原則(SRP)に従い、リソースの状態の指定のみを行い、表現には関与しません。

MVCではコントローラーとモデルの関係に制約はありませんが、リソースはハイパーリンクとURIを使用した他のリソースを含める明示的な制約があります。これにより、呼び出されるリソースの情報隠蔽を維持しながら、宣言的な方法でコンテンツの内包関係とツリー構造を定義できます。

MVCのコントローラーはリクエストオブジェクトから手動で値を取得しますが、リソースは必要な変数をリクエストメソッドの引数として宣言的に定義します。そのため、入力バリデーションもJsonSchemaを使用して宣言的に実行され、引数とその制約が文書化されます。

依存性の注入 (DI)

依存性の注入(Dependency Injection, DI)は、オブジェクト指向プログラミングにおけるアプリケーションの設計と構造を強化するための重要な手法です。DIの中心的な目的は、アプリケーションの機能を複数の独立したドメインまたは役割を持つコンポーネントに分割し、それらの間の依存関係を管理することです。

DIは、1つの機能(関心事、責務)を複数の機能に水平分割するのに役立ちます。分割された機能は「依存」として各部分を独立して開発、テストできるようになります。単一責任原則に基づき明確な責任と役割を持つそれらの依存を外部から注入することで、オブジェクトの再利用性とテスト性を向上させます。また依存は他の依存へと垂直にも分割され、依存関係のツリーを形成します。

BEAR.SundayのDIはRay.Diという独立したパッケージを使用しており、Google社製のDIフレームワークであるGuiceの設計思想を取り入れ、ほぼ全ての機能をカバーしています。

その他に以下の特徴があります。

  • コンテキストにより束縛を変更し、テスト時に異なる実装を注入できます。
  • アトリビュートによる設定でコードの自己記述性が高まります。
  • Ray.Diはコンパイル時に依存性の解決を行うため、ランタイム時のパフォーマンスが向上します。これは、ランタイム時に依存性を解決する他のDIコンテナとは異なる点です。
  • オブジェクトの依存関係をグラフで可視化できます。例)ルートオブジェクト

Ray.Di logo

アスペクト指向プログラミング (AOP)

アスペクト指向プログラミング(AOP)は、ビジネスロジックなどの本質的な関心と、ログやキャッシュなどの横断的関心を分離することで、柔軟なアプリケーションを実現するパターンです。横断的関心とは、複数のモジュールやレイヤーにまたがって存在する機能や処理のことを指します。探索条件に基づいた横断的処理の束縛が可能で、コンテキストに基づいた柔軟な構成が可能です。

BEAR.SundayのAOPはRay.Aopという独立したパッケージを使用しており、PHPのアトリビュートをクラスやメソッドに付与して、横断的処理を宣言的に束縛します。Ray.Aopは、JavaのAOP Allianceに準拠しています。

AOPは「既存の秩序を壊す強い力」と誤解されがちな技術です。その存在意義は制約を超えた力の行使などではなく、マッチャーを使った探索的な機能の割り当てや横断的処理の分離など、オブジェクト指向が不得意とする分野の補完にあります。AOPはアプリケーションの横断的な制約を作ることのできる、つまりアプリケーションフレームワークとして機能するパラダイムです。

パフォーマンスとスケーラビリティ

モダンCDNとの統合によるROAベースのイベントドリブンコンテンツ戦略

BEAR.Sundayは、リソース指向アーキテクチャ(ROA)を中核として、Fastlyなどのインスタントパージ可能なCDNと統合することで、高度なイベントドリブンキャッシュ戦略を実現しています。この戦略では、従来のTTL(Time to Live)によるキャッシュの無効化ではなく、リソースの状態変更イベントに応じてCDNとサーバーサイドのキャッシュ、およびETag(エンティティタグ)を即座に無効化します。

このようにCDNに揮発性のない永続的なコンテンツを配置するというアプローチにより、SPOF(Single Point of Failure)を回避し、高い可用性と耐障害性を実現します。さらに、ユーザー体験とコスト効率を最大化させ、ダイナミックコンテンツでもスタティックコンテンツと同じWeb本来の分散キャッシングを実現します。これは、Webが1990年代から持っていたスケーラブルでネットワークコストを削減する分散キャッシュという原則を、現代的な技術で再実現するものです。

セマンティックメソッドと依存によるキャッシュ無効化

BEAR.SundayのROAでは、各リソース操作にセマンティック(意味的な役割)が与えられています。例えば、GETメソッドはリソースを取得し、PUTメソッドはリソースを更新します。これらのメソッドがイベントドリブン方式で連携し、関連するキャッシュを効率的に無効化します。例えば、特定のリソースが更新された際には、そのリソースを必要とするリソースのキャッシュが無効化されます。これにより、データの一貫性と新鮮さを保ち、ユーザーに最新の情報を提供します。

ETagによる同一性確認と高速な応答

システムがブートする前にETagを設定することで、コンテンツの同一性を迅速に確認し、変更がない場合は304 Not Modified応答を返してネットワークの負荷を最小化します。

ドーナッツキャッシュとESIによる部分的な更新

BEAR.Sundayでは、ドーナッツキャッシュ戦略を採用しており、ESI(Edge Side Includes)を使用してCDNエッジで部分的なコンテンツ更新を可能にしています。この技術により、ページ全体を再キャッシュすることなく、必要な部分だけを動的に更新してキャッシュ効率を向上させます。

このように、BEAR.SundayとFastlyの統合によるROAベースのキャッシュ戦略は、高度な分散キャッシングの実現とともに、アプリケーションのパフォーマンス向上と耐障害性の強化を実現しています。

ランタイム最適化

BEAR.Sundayは、どのサーバー構成でもフレームワークのオーバーヘッドを最小化する設計になっています。

DIの依存解決はコンパイル時に完了し、実行時にコンテナを参照しません。アプリケーション全体は1つのルートオブジェクト変数として生成され、リクエストを超えて再利用されます。php-fpm構成では、コンパイル済みの依存グラフとopcacheにより高速にブートストラップします。

Swoole構成では、永続ワーカーによりブートストラップが初回の1回のみとなります。コルーチンコンテキストによるリクエスト分離で、スーパーグローバルに依存しない安全な並行処理を実現します。これらの最適化は、次に述べる透過的な並列実行と組み合わせることで、I/O待ち時間もさらに最小化されます。

透過的な並列実行

BEAR.Sundayでは、URIは単なる通信プロトコルや場所ではなく「意図」を表現します。app://self/userは「ユーザー情報が欲しい」という意図だけを示し、それがMySQLから取得されるのかRedisから取得されるのかは隠蔽されています。

この「What(何を)」と「How(どう)」の完全分離により、アプリケーションコードを一切変更することなく、#[Embed]で埋め込まれた複数のリソースを並列に取得できます。10年前に書かれたリソースクラスも、Moduleを追加するだけで並列実行の恩恵を受けられます。

サーバー環境の制約に応じて、ext-parallel(スレッドプール)、Swoole(コルーチン)、mysqli(DBクエリのみ並列化)の3段階のソリューションが用意されており、いずれを選択してもアプリケーションコードは変更不要です。開発時は通常のPHPとしてデバッグし、本番では設定の切り替えだけで並列実行に移行できます。

開発者エクスペリエンス

テストの容易性

BEAR.Sundayは、以下の設計上の特徴により、テストが容易で効果的に行えます。

  • 各リソースは独立していて、RESTのステートレスリクエストの性質によりテストが容易です。 リソースの状態と表現が明確に分離されているため、HTML表現の場合でもリソースの状態をテストできます。
  • ハイパーメディアのリンクをたどりながらAPIのテストを行え、PHPとHTTPの同一コードでテストできます。
  • コンテキストによる束縛により、テスト時に異なる実装を束縛できます。

Application as Documentation

BEAR.Sundayでは、アプリケーション自体がドキュメントです。コードから複数形式のドキュメントを自動生成します。

  • ApiDoc HTML: 開発者向けリファレンス
  • OpenAPI 3.1: ツールチェーン統合用
  • JSON Schema: 情報モデル定義
  • llms.txt: AI可読なアプリケーション概要

ALPSプロファイルをSSOT(Single Source of Truth)として使用する場合、アプリケーションのセマンティクス(語彙、状態遷移、操作の意味)を先に定義し、それを元にコードを生成できます。同じドキュメントが読み手によって異なる意味を持ち、開発者はエンドポイントを、アーキテクトは状態遷移を、AIはオントロジーを読み取ります。

視覚化とデバッグ

リソースが自身でレンダリングする技術的特徴を生かし、開発時にHTML上でリソースの範囲を示し、リソース状態をモニターできます。また、PHPコードやHTMLテンプレートをオンラインエディターで編集し、リアルタイムに反映することもできます。

拡張性と統合

PHPインターフェイスとSQL実行の統合

BEAR.SundayではPHPのインターフェイスを通じて、データベースとのやり取りを行うSQL文の実行を簡単に管理できます。クラスを実装することなく、PHPインターフェイスに直接SQLの実行オブジェクトを束縛することが可能です。ドメインとインフラストラクチャーの境界をPHPインターフェイスで結びます。

引数には型も指定でき、不足している分はDIが依存解決を行い文字列として利用されます。SQL実行に現在時刻が必要な場合でも渡す必要はなく、自動束縛されます。クライアントが全ての引数を渡す責任がなく、コードの簡潔さを保つことができます。

また、SQLの直接管理は、エラー発生時のデバッグを容易にします。SQLクエリの動作を直接観察し、問題の特定と修正を迅速に行うことができます。

他システムとの統合

BEAR.Sundayのリソースは様々なインターフェースから利用可能です。Webインターフェースに加え、コンソールからリソースに直接アクセスでき、ソースコードを変えずにWebとコマンドライン双方から同じリソースを利用できます。さらにBEAR.CLIを使用することで、リソースを独立したUNIXコマンドとして配布することも可能です。また、同一PHPランタイム内で異なるBEAR.Sundayアプリケーションを並行実行できることで、マイクロサービスを構築することなく独立した複数のアプリケーションを連携できます。

ストリーム出力

リソースのボディにファイルのポインタなどのストリームを割り当てることで、メモリ上では扱えない大規模なコンテンツを出力できます。その際、ストリームは通常の実変数と混在させることも可能で、大規模なレスポンスを柔軟に出力できます。

他のシステムからの段階的移行

BEAR.Sundayは段階的な移行パスを提供し、LaravelやSymfonyなどの他のフレームワークやシステムとのシームレスな統合を可能にします。このフレームワークはComposerパッケージとして実装できるため、開発者は既存のコードベースにBEAR.Sundayの機能を段階的に導入できます。

技術移行の柔軟性

BEAR.Sundayは、将来の技術的変化や要件の進化に備えて投資を保護します。このフレームワークから別のフレームワークや言語に移行する必要がある場合でも、構築したリソースは無駄になりません。PHP環境では、BEAR.SundayアプリケーションをComposerパッケージとして統合して継続的に利用できます。また、BEAR.Thriftを使用すると、他の言語からBEAR.Sundayリソースに効率的にアクセスでき、Thriftを使用しない場合でもHTTPでアクセスが可能です。さらに、SQLコードの再利用も容易です。

また、使用しているライブラリが特定のPHPバージョンに強く依存している場合でも、BEAR.Thriftを使用して異なるバージョンのPHPを共存させることができます。

設計思想と品質

標準技術の採用と独自規格の排除

BEAR.Sundayは、可能な限り標準技術を採用し、フレームワーク独自の規格やルールを排除するという設計思想を持っています。例えば、デフォルトでJSON形式とwwwフォーム形式のHTTPリクエストのコンテントネゴシエーションをサポートし、エラーレスポンスにはvnd.error+jsonメディアタイプ形式を使用します。リソース間のリンクにはHAL(Hypertext Application Language)を採用し、バリデーションにはJsonSchemaを用いるなど、標準的な技術や仕様を積極的に取り入れています。

一方で、独自のバリデーションルールや、フレームワーク特有の規格・ルールは可能な限り排除しています。

オブジェクト指向原則

BEAR.Sundayはアプリケーションを長期的にメンテナンス可能とするためのオブジェクト指向原則を重視しています。

継承より合成

継承クラスよりコンポジションを推奨します。一般に子クラスから親クラスのメソッドを直接呼び出すことは、クラス間の結合度を高くする可能性があります。設計上、ランタイムで継承が必要な抽象クラスはリソースクラスのBEAR\Resource\ResourceObjectのみですが、これもResourceObjectのメソッドは他のクラスが利用するためだけに存在します。ユーザーが継承したフレームワークの親クラスのメソッドをランタイムに呼び出すことは、BEAR.Sundayではどのクラスにもありません。

全てがインジェクション

フレームワークのクラスが「設定ファイル」や「デバッグ定数」を実行中に参照して振る舞いを決定することはありません。振る舞いに応じた依存が注入されます。これにより、アプリケーションの振る舞いを変更するためには、コードを変更する必要がなく、インターフェイスに対する依存性の実装の束縛を変更するだけで済みます。APP_DEBUGやAPP_MODE定数は存在しません。ソフトウェアが起動した後に現在どのモードで動作しているか知る方法はありませんし、知る必要もありません。

後方互換性の永続的確保

BEAR.Sundayは、ソフトウェアの進化において後方互換性の維持を重視して設計されており、リリース以来、後方互換性を破壊することなく進化を続けています。現代のソフトウェア開発では、頻繁な後方互換性の破壊と、それに伴う改修やテストの負担が課題となっていますが、BEAR.Sundayはこの問題を回避してきました。

BEAR.Sundayでは、セマンティックバージョニングを採用するだけでなく、破壊的な変更を伴うメジャーバージョンアップを行いません。新しい機能の追加や既存機能の変更が既存のコードに影響を与えることを防いでいます。古くなって使われなくなったコードには「deprecated」の属性が与えられますが、削除されることはなく、既存のコードの動作にも影響を与えません。その代わりに、新しい機能が追加され、進化が続けられます。

非環式依存原則

非環式依存原則(ADP)とは、依存関係が一方向であり、循環していないことを意味します。BEAR.Sundayフレームワークはこの原則に基づき、一連のパッケージで構成されており、大きなフレームワークパッケージが小さなフレームワークパッケージに依存する階層構造を持っています。各レベルはそれを包含する他のレベルの存在自体を知る必要はなく、依存関係は一方向のみで循環しません。例えば、Ray.AopはRay.Diの存在すら知りませんし、Ray.DiはBEAR.Sundayの存在を知りません。

非環式依存原則に従ったフレームワーク構造

後方互換性が保持されているため、各パッケージは独立して更新できます。また、他のフレームワークで見られるような全体をロックするバージョン番号は存在せず、オブジェクト間を横断する依存関係を持つオブジェクトプロキシーの機構もありません。

この非環式依存原則はDI(依存性注入)の原則と調和しており、BEAR.Sundayが起動する際に生成されるルートオブジェクトも、この非環式依存原則の構造に従って構築されます。

ランタイムも同様です。リソースにアクセスが行われる際、まずメソッドに結び付けられたAOPアスペクトの横断的な処理が行われ、その後でメソッドがリソースの状態を決定しますが、この時点でメソッドは結び付けられたアスペクトの存在を認識していません。リソースの状態に埋め込まれたリソースも同じです。それらは外側の層や要素の知識を持っていません。関心の分離が明確にされています。

コード品質

高品質なアプリケーションを提供するため、BEAR.Sundayフレームワークも高い水準でコード品質を維持するよう努めています。

  • フレームワークのコードは静的解析ツールのPsalmとPHPStan双方で最も厳しいレベルを適用しています。
  • テストカバレッジ100%を保持しており、タイプカバレッジもほぼ100%です。
  • 原則的にイミュータブルなシステムであり、テストでも毎回初期化が不要なほどクリーンです。SwooleのようなPHPの非同期通信エンジンの性能を最大限に引き出します。

アーキテクチャによるセキュリティ解析

BEAR.Sundayのアーキテクチャは、セキュリティ解析を根本的に容易にします。

すべてのエンドポイントはonGetonPostメソッドを持つResourceObjectであり、入力はJSON Schemaで宣言され、依存関係はコンストラクタインジェクションで明示されます。隠れたマジックやグローバル状態がないため、静的解析ツールは完全なデータフローを追跡できます。

この宣言的なアーキテクチャにより、SAST(静的解析)、DAST(動的テスト)、テイント解析、AI監査を組み合わせた多層的なセキュリティスキャンが可能です。汎用ツールでは検出困難な脆弱性も、フレームワークを理解した専用ツールが検出します。

BEAR.Sundayのもたらす価値

開発者にとっての価値

  • 生産性の向上: 堅牢な設計パターンと原則に基づく、時間が経過しても変わることのない制約により、開発者はコアとなるビジネスロジックに集中できます。

  • チームでの協業: 開発チームに一貫性のあるガイドラインと構造を提供することで、異なる開発者のコードを疎結合のまま統一的に保ち、コードの可読性とメンテナンス性を向上させます。

  • 柔軟性と拡張性: BEAR.Sundayがライブラリを含まないという方針により、開発者はコンポーネントの選択において高い柔軟性と自由度を得られます。

  • テストの容易性: DI(依存性の注入)とROA(リソース指向アーキテクチャ)の採用により、効果的かつ効率的なテストの実施が可能です。

ユーザーにとっての価値

  • 高いパフォーマンス: 最適化された高速起動とCDNを中心としたキャッシュ戦略により、ユーザーには高速で応答性の優れたエクスペリエンスが提供されます。

  • 信頼性と可用性: CDNを中心としたキャッシュ戦略により、単一障害点(SPOF)を最小化し、ユーザーに安定したサービスを提供し続けることができます。

  • 使いやすさ: 優れた接続性により、他の言語やシステムとの円滑な連携が実現します。また、リソースをCLIツールとして提供することで、エンドユーザーは複雑な環境設定なしにアプリケーションの機能を利用できます。

ビジネスにとっての価値

  • 開発コストの削減: 一貫性のあるガイドラインと構造の提供により、持続的で効率的な開発プロセスを実現し、開発コストを抑制します。

  • 保守コストの削減: 後方互換性を重視するアプローチにより、技術的な継続性を高め、変更対応にかかる時間とコストを最小限に抑えます。

  • 高い拡張性: DI(依存性の注入)やAOP(アスペクト指向プログラミング)などの技術により、コードの変更を最小限に抑えながら振る舞いを変更でき、ビジネスの成長や変化に合わせて柔軟にアプリケーションを拡張できます。

  • 優れたユーザーエクスペリエンス(UX): 高いパフォーマンスと可用性の提供により、ユーザー満足度を向上させ、顧客ロイヤリティの強化と顧客基盤の拡大を通じて、ビジネスの成功に貢献します。

まとめ

優れた制約は不変です。BEAR.Sundayが提供する制約は、開発者、ユーザー、ビジネスのそれぞれに対して、具体的かつ持続的な価値をもたらします。

BEAR.Sundayは、Webの原則と精神に基づいて設計されたフレームワークです。開発者に明確な制約を提供することで、柔軟性と堅牢性を兼ね備えたアプリケーションを構築する力を与えます。


バージョン

サポートするPHP

Continuous Integration

BEAR.SundayはPHPの公式サポート期間(Supported Versions)に準じてPHPバージョンをサポートしています。最新のサポート状況は公式サイトを参照してください。

  • 8.2 (初回リリース 8 Dec 2022 / サポート終了 31 Dec 2026)
  • 8.3 (初回リリース 23 Nov 2023 / サポート終了 31 Dec 2027)
  • 8.4 (初回リリース 21 Nov 2024 / サポート終了 31 Dec 2028)
  • 8.5 (初回リリース 20 Nov 2025 / サポート終了 31 Dec 2029)

サポート終了(EOL

  • 5.5 (21 Jul 2016)
  • 5.6 (31 Dec 2018)
  • 7.0 (3 Dec 2018)
  • 7.1 (1 Dec 2019)
  • 7.2 (30 Nov 2020)
  • 7.3 (6 Dec 2021)
  • 7.4 (28 Nov 2022)
  • 8.0 (26 Nov 2023)
  • 8.1 (31 Dec 2025)

Semver

BEAR.Sundayはセマンティックバージョニングに従います。マイナーバージョンアップ(バージョン番号が0.1増加)ではアプリケーションコードの修正は不要です。

バージョニング・ポリシー

  • フレームワークのコアパッケージは破壊的変更を行いません。1
  • PHPのサポート要件が変更され、必要なPHPバージョンが上がっても(例:5.67.0)、フレームワークのメジャーバージョンアップは行いません。後方互換性は維持されます。
  • 新しいモジュールの導入によりPHPバージョンの要件が上がることはありますが、それに伴う破壊的変更は行いません。
  • 後方互換性維持のため、古い機能は削除せず2、新機能は既存機能の置き換えではなく追加として実装されます。

BEAR.Sundayは堅牢で進化可能3な、長期的な保守性を重視したフレームワークを目指しています。

パッケージのバージョン

フレームワークは依存ライブラリのバージョンを固定しません。ライブラリはフレームワークのバージョンに関係なくアップデート可能です。composer updateによる定期的な依存関係の更新を推奨します。



クイックスタート

インストールは composer で行います。

VENDOR=MyVendor PACKAGE=MyProject composer create-project bear/skeleton my-project
cd my-project

VENDORでベンダー名を、PACKAGEでパッケージ名を指定してインストールします。環境変数を指定しない場合は対話的にプロンプトされます。

次にPageリソースを作成します。PageリソースはWebページに対応したクラスです。src/Resource/Page/Hello.phpに作成します。

<?php

namespace MyVendor\MyProject\Resource\Page;

use BEAR\Resource\ResourceObject;

class Hello extends ResourceObject
{
    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $this->body = [
            'greeting' => 'Hello ' . $name
        ];

        return $this;
    }
}

GETメソッドでリクエストされると$name$_GET['name']が渡されるので、挨拶をgreetingにセットし$thisを返します。

作成したアプリケーションはコンソールでもWebサーバーでも動作します。

php bin/page.php get /hello
php bin/page.php get '/hello?name=World'
200 OK
Content-Type: application/hal+json

{
    "greeting": "Hello World",
    "_links": {
        "self": {
            "href": "/hello?name=World"
        }
    }
}

ビルトインウェブサーバーを起動し

php -S 127.0.0.1:8080 -t public

Webブラウザまたはcurlコマンドでhttp://127.0.0.1:8080/helloをリクエストします。

curl -i 127.0.0.1:8080/hello

環境構築

お使いの OS / チーム体制に合わせて、malt / Docker / 手動構築のいずれかを選べます。本書はそれぞれの特徴・導入手順・運用ポイントを1か所にまとめた実践ガイドです。


構築方法の選択

方法 対象OS 特徴 推奨用途
malt macOS, WSL2, Linux Homebrew ベース、軽量、設定共有可能、ローカル完結
サービス一括管理コマンド
個人開発、チーム開発
Docker macOS, Windows, Linux コンテナベースで環境を完全再現、CI/CD と親和 チーム開発、CI/CD、本番近似
手動構築 全OS 既存環境をそのまま活用、細かい制御 既存インフラ活用、制約が多い環境

malt による環境構築

概要

malt は Homebrew をベースとした開発環境管理ツールです。プロジェクト直下に設定・データを集約し、ローカルで完結します。

主な特徴

  • 完全ローカル: 全ての設定・データはプロジェクト内に保存
  • クリーンな削除:フォルダ削除=環境削除
  • 専用ポートコマンドmysql@3306 / redis@6379 などのエイリアス
  • グローバル汚染なし:システムの MySQL/Redis 等に影響しない
  • 設定の可視化:設定ファイルをプロジェクト内で共有・レビュー可能
  • 全サービス一括管理malt start / malt stop で関連サービスをまとめて起動・停止可能

前提条件

  • macOS または Linux(WSL2 含む)
  • Homebrew がインストール済み

インストール

# Homebrew tap の追加
brew tap shivammathur/php
brew tap shivammathur/extensions
brew tap koriym/malt

# malt のインストール
brew install malt

基本操作(最短導線)

malt init && malt install && malt create && malt start
source <(malt env)

設定ファイル

malt.json          # malt の設定
malt/
  conf/
    my_3306.cnf     # MySQL 設定
    php.ini         # PHP 設定
    httpd_8080.conf # Apache 設定
    nginx_80.conf   # Nginx 設定

これらのファイルはプロジェクトに含めることで、チーム全体で環境を共有できます。

サービス管理

# 状態確認
malt status

# 全サービス開始 / 停止 / 再起動
malt start
malt stop
malt restart

# 特定サービスのみ
malt start mysql
malt stop nginx

データベース操作

mysql@3306  # プロジェクト専用 MySQL へ接続
redis@6379  # プロジェクト専用 Redis へ接続
mysql@3306 -e "CREATE DATABASE IF NOT EXISTS myapp"

重要mysql@3306プロジェクト専用接続 です。システムのグローバル MySQL と分離されます。


Docker による環境構築

概要

Dockerを使用することで、OS に依存しない一貫した開発環境を構築できます。

Dockerの注意点:

  • グローバルコマンドの競合: システムの mysql コマンドはグローバルなMySQLを指す
  • コンテナ専用接続: Dockerコンテナ内のMySQLには専用の接続方法が必要
  • ポート競合リスク: 3306ポートなどがシステムサービスと競合する可能性
  • MacOSファイルアクセス: ホストとコンテナ間のファイルマウントでアクセス速度が低下、大量のファイル操作(ビルド、テスト)で顕著

前提条件

  • Docker Desktop がインストール済み
  • Docker Compose が利用可能

基本的な docker-compose.yml

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ""
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
      MYSQL_DATABASE: myapp
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
    command: --default-authentication-plugin=mysql_native_password
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 3
    
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
      
  memcached:
    image: memcached:alpine
    ports:
      - "11211:11211"

volumes:
  mysql_data:

使用方法

# 環境の起動
docker-compose up -d

# 状態確認
docker-compose ps

# ログ確認
docker-compose logs mysql

# 環境の停止
docker-compose stop

# 完全削除(データも削除)
docker-compose down -v

データベース接続

# ホストから接続(ポート指定必須)
mysql -h 127.0.0.1 -P 3306 -u root

# コンテナ内から接続(推奨)
docker-compose exec mysql mysql -u root

注意: システムに mysql がインストールされている場合、単に mysql と実行するとシステムのMySQLに接続してしまいます。Dockerコンテナのデータベースにアクセスするには、必ずホスト・ポート指定またはコンテナ内実行が必要です。


手動環境構築

PHP

# macOS (Homebrew)
brew install php@8.4
brew install composer

# Ubuntu/Debian
sudo apt update
sudo apt install php8.4 php8.4-{cli,mysql,mbstring,xml,zip,curl}
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# CentOS/RHEL
sudo dnf install php php-{cli,mysql,mbstring,xml,zip,curl}

MySQL

# macOS (Homebrew)
brew install mysql@8.0
brew services start mysql@8.0

# Ubuntu/Debian
sudo apt install mysql-server-8.0
sudo systemctl start mysql

# CentOS/RHEL
sudo dnf install mysql-server
sudo systemctl start mysqld

開発に有用な PHP 拡張

# Xdebug(デバッグ用)
brew install shivammathur/extensions/xdebug@8.4    # Homebrew
sudo apt install php8.4-xdebug                    # Ubuntu

# XHProf(プロファイリング用)
brew install shivammathur/extensions/xhprof@8.4   # Homebrew
sudo apt install php8.4-xhprof                    # Ubuntu

# Redis
brew install shivammathur/extensions/redis@8.4    # Homebrew
sudo apt install php8.4-redis                     # Ubuntu

# APCu(キャッシュ)
brew install shivammathur/extensions/apcu@8.4     # Homebrew
sudo apt install php8.4-apcu                      # Ubuntu

重要: Xdebug と XHProf はパフォーマンスに影響するため、常時有効化は避けてください。設定する場合は、Xdebug は zend_extension=xdebug.so、XHProf は extension=xhprof.so を使い、必要なときだけ CLI から有効化する運用を推奨します。

# デバッグ時のみ Xdebug を有効化
php -dzend_extension=xdebug.so -S 127.0.0.1:8080 -t public

# プロファイリング時のみ XHProf を有効化
php -dextension=xhprof.so script.php

# Composer 実行時に Xdebug を無効化(推奨)
XDEBUG_MODE=off composer install

# あるいは PHP の ini 上書きを利用
php -dxdebug.mode=off /usr/local/bin/composer install

BEAR.Sunday 最短導入例

composer create-project bear/skeleton my-app
cd my-app
malt init && malt install && malt create && malt start
source <(malt env)

プロジェクト固有の設定

.env(例)

# MySQL
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=myapp
DB_USER=root
DB_PASS=
DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=myapp

# SQLite(切り替え例)
# DB_DSN=sqlite:var/db.sqlite3

# Redis
REDIS_HOST=127.0.0.1:6379

# Memcached
MEMCACHED_HOST=127.0.0.1:11211

マイグレーション(Phinx 例)

composer require --dev robmorgan/phinx
./vendor/bin/phinx init
./vendor/bin/phinx create MyMigration
./vendor/bin/phinx migrate

開発サーバー

PHP 内蔵サーバー

# 8080 番で起動
php -S 127.0.0.1:8080 -t public

# デバッグ時のみ Xdebug 有効で起動
php -dzend_extension=xdebug.so -S 127.0.0.1:8080 -t public

malt サーバー

# Apache / Nginx を選択して起動
malt start apache   # http://127.0.0.1:8080
malt start nginx    # http://127.0.0.1:80

# サービス確認
malt status

# 全サービス起動/停止
malt start
malt stop

# 個別停止
malt stop apache
malt stop nginx

トラブルシューティング

ポート競合

# macOS/Linux
lsof -i :3306
netstat -tulpn | grep :3306

# 該当プロセスの終了
kill -9 <PID>

PHP 設定の確認

php --ini     # 読み込み設定
php -m        # ロード済みモジュール

MySQL 接続エラー

# 接続テスト
mysql -h 127.0.0.1 -P 3306 -u root -p

# Linux のサービス状況
sudo systemctl status mysql

# エラーログ
sudo tail -f /var/log/mysql/error.log

malt 固有の問題

# 状態確認
malt status

# 設定をリセット
malt stop
rm -rf malt/
malt create
malt start

チーム開発での .gitignore

malt/logs/
malt/data/
malt/tmp/

CI/CD(GitHub Actions 例)

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: ""
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE: test_db
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping" 
          --health-interval=10s 
          --health-timeout=5s 
          --health-retries=3

    steps:
      - uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, pdo_mysql, mysqli, intl, curl, zip

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Run tests
        run: ./vendor/bin/phpunit

環境選択の指針

開発環境では透明性と直接性が重要で、本番環境では再現性と監視機能が重要です。

  • 日常開発・学習:malt(即座に mysql@3306、設定が見える、ファイルアクセスが速い)
  • チーム開発:malt(設定共有)または Docker(再現性重視)
  • 本番環境・CI/CDDocker 一択(どこでも同じ挙動、監視ツールが豊富)
  • 複雑な構成:Docker(依存サービスの統合やスケールを前提に)

各環境の詳細な設定や BEAR.Sunday のベストプラクティスは、プロジェクトのチュートリアルやチーム規約に従ってください。


チュートリアル

このチュートリアルでは、BEAR.Sundayの基本機能である依存性注入(DI)アスペクト指向プログラミング(AOP)REST APIを紹介します。 tutorial1のコミットを参考にして進めましょう。

プロジェクト作成

年月日を入力すると対応する曜日を返すWebサービスを作成します。 まずプロジェクトを作成しましょう。

VENDOR=MyVendor PACKAGE=Weekday composer create-project bear/skeleton weekday
cd weekday

3

リソース

BEAR.Sundayアプリケーションはリソースで構成されます。ResourceObjectは、Webリソースそのものを表現するオブジェクトです。HTTPリクエストを受け取り、自分自身をそのリソースの現在の状態にします。

最初にアプリケーションリソースファイルをsrc/Resource/App/Weekday.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
use DateTimeImmutable;

class Weekday extends ResourceObject
{
    public function onGet(int $year, int $month, int $day): static
    {
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
        $weekday = $dateTime->format('D');
        $this->body = ['weekday' => $weekday];

        return $this;
    }
}

MyVendor\Weekday\Resource\App\Weekdayクラスは/weekdayというパスでアクセス可能なリソースです。 GETメソッドのクエリーパラメータがonGetメソッドの引数として渡されます。

ResourceObjectの仕事はリクエストを受け取って自己の状態を決定することです。

コンソールでアクセスしてみましょう。まずはエラーケースを試します。

php bin/app.php get /weekday
400 Bad Request
content-type: application/vnd.error+json

{
    "message": "Bad Request",
    "logref": "e29567cd",

エラーはapplication/vnd.error+jsonメディアタイプで標準化された形式で返されます。 400はリクエストに問題があることを示すエラーコードです。エラーにはlogrefIDが付与され、var/log/でエラーの詳細を参照できます。

次は引数を指定して正しいリクエストを試します。

php bin/app.php get '/weekday?year=2001&month=1&day=1'
200 OK
Content-Type: application/hal+json

{
    "weekday": "Mon",
    "_links": {
        "self": {
            "href": "/weekday?year=2001&month=1&day=1"
        }
    }
}

application/hal+jsonメディアタイプで結果が返ってきました。HAL+JSONは、_linksセクションで関係のあるリソースをリンクするJSON形式です。HAL+JSONについて詳しくはHAL specificationをご覧ください。

これをWeb APIサービスとして公開してみましょう。 まずBuilt-inサーバーを立ち上げます。

php -S 127.0.0.1:8080 bin/app.php

curlコマンドでHTTPのGETリクエストを実行する前に、public/index.phpを以下のように書き換えます。

<?php

declare(strict_types=1);

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
- exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'hal-app' : 'prod-hal-app', $GLOBALS, $_SERVER));
+ exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'hal-api-app' : 'prod-hal-api-app', $GLOBALS, $_SERVER));
curl -i 'http://127.0.0.1:8080/weekday?year=2001&month=1&day=1'
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Tue, 04 May 2021 01:55:59 GMT
Connection: close
X-Powered-By: PHP/8.0.3
Content-Type: application/hal+json

{
    "weekday": "Mon",
    "_links": {
        "self": {
            "href": "/weekday/2001/1/1"
        }
    }
}

このリソースクラスはGETメソッドのみ実装しているため、他のメソッドでアクセスすると405 Method Not Allowedが返されます。試してみましょう。

curl -i -X POST 'http://127.0.0.1:8080/weekday?year=2001&month=1&day=1'
HTTP/1.1 405 Method Not Allowed
...

また、HTTP OPTIONSメソッドを使用すると、利用可能なHTTPメソッドと必要なパラメーターを確認できます(RFC7231)。

curl -i -X OPTIONS http://127.0.0.1:8080/weekday
HTTP/1.1 200 OK
...
Content-Type: application/json
Allow: GET

{
    "GET": {
        "parameters": {
            "year": {
                "type": "integer"
            },
            "month": {
                "type": "integer"
            },
            "day": {
                "type": "integer"
            }
        },
        "required": [
            "year",
            "month",
            "day"
        ]
    }
}

テスト

PHPUnitを使用してリソースのテストを作成します。

tests/Resource/App/WeekdayTest.phpに以下のテストコードを記述します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceInterface;
use MyVendor\Weekday\Injector;
use PHPUnit\Framework\TestCase;

class WeekdayTest extends TestCase
{
    private ResourceInterface $resource;

    protected function setUp(): void
    {
        $injector = Injector::getInstance('app');
        $this->resource = $injector->getInstance(ResourceInterface::class);
    }

    public function testOnGet(): void
    {
        $ro = $this->resource->get('app://self/weekday', ['year' => '2001', 'month' => '1', 'day' => '1']);
        $this->assertSame(200, $ro->code);
        $this->assertSame('Mon', $ro->body['weekday']);
    }
}

setUp()メソッドでは、コンテキスト(app)を指定してアプリケーションインジェクター(Injector)からリソースクライアント(ResourceInterface)を取得します。テストメソッドtestOnGetでは、このリソースクライアントを使用してリクエストを実行し、結果を検証します。

テストを実行してみましょう。

./vendor/bin/phpunit
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

....                                                                4 / 4 (100%)

Time: 00:00.281, Memory: 14.00 MB

プロジェクトにはテストと品質管理のための各種コマンドが用意されています。

テストカバレッジを取得するには:

composer coverage

より高速なカバレッジ計測を行うpcovを使用する場合:

composer pcov

カバレッジレポートはbuild/coverage/index.htmlをWebブラウザで開いて確認できます。

コーディング規約への準拠を確認:

composer cs

コーディング規約違反の自動修正:

composer cs-fix

静的解析

コードの静的解析はcomposer saコマンドで実行します。

composer sa

これまでのコードを解析すると、以下のエラーがPHPStanで検出されます。

 ------ --------------------------------------------------------- 
  Line   src/Resource/App/Weekday.php                             
 ------ --------------------------------------------------------- 
  15     Cannot call method format() on DateTimeImmutable|false.  
 ------ --------------------------------------------------------- 

現在のコードでは、DateTimeImmutable::createFromFormatが不正な値(年が-1など)を受け取った場合にfalseを返すことを考慮していません。

実際に試してみましょう。

php bin/app.php get '/weekday?year=-1&month=1&day=1'

PHPエラーが発生した場合でもエラーハンドラーがキャッチし、正しいapplication/vnd.error+jsonメディアタイプでエラーメッセージが返されます。しかし、静的解析の検査をパスするには、DateTimeImmutableの結果をassertするか型を検査して例外を投げるコードを追加する必要があります。

assertを使用する場合

$dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
assert($dateTime instanceof DateTimeImmutable);

例外を投げる場合

まず、専用の例外クラスsrc/Exception/InvalidDateTimeException.phpを作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Exception;

use RuntimeException;

class InvalidDateTimeException extends RuntimeException
{
}

次に、値の検査を行うように元のコードを修正します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
use DateTimeImmutable;
+use MyVendor\Weekday\Exception\InvalidDateTimeException;

class Weekday extends ResourceObject
{
    public function onGet(int $year, int $month, int $day): static
    {
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
+        if (! $dateTime instanceof DateTimeImmutable) {
+            throw new InvalidDateTimeException("$year-$month-$day");
+        }

        $weekday = $dateTime->format('D');
        $this->body = ['weekday' => $weekday];

        return $this;
    }
}

テストケースも追加します。

+    public function testInvalidDateTime(): void
+    {
+        $this->expectException(InvalidDateTimeException::class);
+        $this->resource->get('app://self/weekday', ['year' => '-1', 'month' => '1', 'day' => '1']);
+    }

例外作成のベストプラクティス

入力値の妥当性チェックで検出されるエラーは、コード自体の問題ではありません。このような実行時に判明するエラーはRuntimeExceptionを使用します。一方、例外の発生がバグによるものでコードの修正が必要な場合はLogicExceptionを使用します。エラーの種類はメッセージではなく、個別の例外クラスとして表現するのがベストプラクティスです。

防御的プログラミング

この修正により、$dateTime->format('D')の実行時に$dateTimeにfalseが入る可能性が排除されました。 このように問題の発生を事前に防ぐプログラミング手法を防御的プログラミング(defensive programming)と呼びます。静的解析はこのような問題の早期発見に役立ちます。

コミット前のチェック

composer testsコマンドは、composer testに加えて、コーディング規約(cs)と静的解析(sa)の検査も実行します。

composer tests

ルーティング

デフォルトのルーターはURLをディレクトリにマップするWebRouterです。 ここでは動的なパラメーターをパスで受け取るためにAuraルーターを使用します。

最初にcomposerでインストールします。

composer require bear/aura-router-module ^2.0

次にsrc/Module/AppModule.phpAuraRouterModulePackageModuleの前でインストールします。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Module;

use BEAR\Dotenv\Dotenv;
use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;
+use BEAR\Package\Provide\Router\AuraRouterModule;
use function dirname;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
+        $appDir = $this->appMeta->appDir;
+        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
        $this->install(new PackageModule());
    }
}

ルータースクリプトファイルをvar/conf/aura.route.phpに設置します。

<?php
/** 
 * @see https://bearsunday.github.io/manuals/1.0/ja/router.html
 * @var \Aura\Router\Map $map 
 */

$map->route('/weekday', '/weekday/{year}/{month}/{day}');

試してみましょう。

php bin/app.php get /weekday/1981/09/08
200 OK
Content-Type: application/hal+json

{
    "weekday": "Tue",
    "_links": {
        "self": {
            "href": "/weekday/1981/09/08"
        }
    }
}

DI

求めた曜日をログする機能を追加してみましょう。

まず曜日をログするsrc/MyLoggerInterface.phpを作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday;

interface MyLoggerInterface
{
    public function log(string $message): void;
}

リソースはこのログ機能を使うように変更します。

<?php
namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
use MyVendor\Weekday\MyLoggerInterface;

class Weekday extends ResourceObject
{
+    public function __construct(public MyLoggerInterface $logger)
+    {
+    }

    public function onGet(int $year, int $month, int $day): static
    {
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
        $weekday = $dateTime->format('D');
        $this->body = [
            'weekday' => $weekday
        ];
+        $this->logger->log("$year-$month-$day {$weekday}");

        return $this;
    }
}

Weekdayクラスはロガーサービスをコンストラクタで受け取って利用しています。 このように必要なもの(依存)をnewで生成したりコンテナから取得しないで、外部から代入してもらう仕組みを DI といいます。

次にMyLoggerInterfaceMyLoggerに実装します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday;

use BEAR\AppMeta\AbstractAppMeta;

use function error_log;

use const PHP_EOL;

class MyLogger implements MyLoggerInterface
{
    private string $logFile;

    public function __construct(AbstractAppMeta $meta)
    {
        $this->logFile = $meta->logDir . '/weekday.log';
    }

    public function log(string $message): void
    {
        error_log($message . PHP_EOL, 3, $this->logFile);
    }
}

MyLoggerを実装するためにはアプリケーションのログディレクトリの情報(AbstractAppMeta)が必要ですが、これも依存としてコンストラクタで受け取ります。 つまりWeekdayリソースはMyLoggerに依存していますが、MyLoggerもログディレクトリ情報を依存にしています。このようにDIで構築されたオブジェクトは、依存が依存を..と繰り返し依存の代入が行われます。

この依存解決を行うのがDIツール(dependency injector)です。

DIツールでMyLoggerInterfaceMyLoggerを束縛(bind)するためにsrc/Module/AppModule.phpconfigureメソッドを編集します。

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $appDir = $this->appMeta->appDir;
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
+        $this->bind(MyLoggerInterface::class)->to(MyLogger::class);
        $this->install(new PackageModule());
    }
}

これでどのクラスでもコンストラクタでMyLoggerInterfaceでロガーを受け取ることができるようになりました。

実行してvar/log/cli-hal-api-app/weekday.logに結果が出力されていることを確認しましょう。

php bin/app.php get /weekday/2011/05/23
cat var/log/cli-hal-api-app/weekday.log

アスペクト指向プログラミング(AOP)

メソッドの実行時間を計測するベンチマーク処理を例に、AOPの活用方法を見ていきましょう。 従来の方法では、以下のようなコードを各メソッドに追加する必要がありました:

$start = microtime(true);
// メソッド実行
$time = microtime(true) - $start;

このようなコードを必要に応じて追加・削除するのは手間がかかり、ミスの原因にもなります。 アスペクト指向プログラミング(AOP) を使用すると、このようなメソッドの前後に実行される処理をうまく合成することができます。

まず、メソッドの実行を横取り(インターセプト)してベンチマークを行うインターセプターsrc/Interceptor/BenchMarker.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Interceptor;

use MyVendor\Weekday\MyLoggerInterface;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

use function microtime;
use function sprintf;

class BenchMarker implements MethodInterceptor
{
    public function __construct(private MyLoggerInterface $logger)
    {
    }

    public function invoke(MethodInvocation $invocation): mixed
    {
        $start = microtime(true);
        $result = $invocation->proceed(); // 元のメソッドの実行
        $time = microtime(true) - $start;
        $message = sprintf('%s: %0.5f(µs)', $invocation->getMethod()->getName(), $time);
        $this->logger->log($message);

        return $result;
    }
}

このインターセプターでは、元のメソッドの実行($invocation->proceed())の前後で時間を計測し、実行時間をログに記録しています。

次に、ベンチマークを適用したいメソッドを指定するためのアトリビュートsrc/Annotation/BenchMark.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Annotation;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class BenchMark
{
}

AppModuleMatcherを使用して、このインターセプターを適用するメソッドを指定します。

+use MyVendor\Weekday\Annotation\BenchMark;
+use MyVendor\Weekday\Interceptor\BenchMarker;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $appDir = $this->appMeta->appDir;
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
        $this->bind(MyLoggerInterface::class)->to(MyLogger::class);
+        $this->bindInterceptor(
+            $this->matcher->any(),                           // どのクラスでも
+            $this->matcher->annotatedWith(BenchMark::class), // #[BenchMark]のアトリビュートが付けられたメソッドに
+            [BenchMarker::class]                             // BenchMarkerインターセプターを適用
+        );
        $this->install(new PackageModule());
    }
}

ベンチマークを行いたいメソッドには#[BenchMark]アトリビュートを付与します。

+use MyVendor\Weekday\Annotation\BenchMark;

class Weekday extends ResourceObject
{

+   #[BenchMark]
    public function onGet(int $year, int $month, int $day): static
    {

これにより、#[BenchMark]アトリビュートを付与したメソッドの実行時間が自動的に計測されるようになります。

AOPの利点は、このような機能追加が非常に柔軟に行えることです:

  • 対象メソッドのコードを変更する必要がない
  • メソッドを呼び出す側のコードも変更不要
  • アトリビュートはそのままで束縛を外せば機能を無効化できる
  • 開発時のみ有効にするなど、環境に応じた制御が容易

実際に動作を確認してみましょう。

php bin/app.php get '/weekday/2015/05/28'

ログファイルで実行時間を確認:

cat var/log/cli-hal-api-app/weekday.log

HTMLの出力

これまでのAPIアプリケーションに、HTML出力機能を追加してみましょう。 既存のappリソースに加えて、新しくsrc/Resource/Page/Index.phppageリソースを作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Resource\Annotation\Embed;

class Index extends ResourceObject
{
    #[Embed(rel:"_self", src: "app://self/weekday{?year,month,day}")]
    public function onGet(int $year, int $month, int $day): static
    {
        $this->body += [
            'year' => $year,
            'month' => $month,
            'day' => $day,
        ];

        return $this;
    }
}

pageリソースクラスはappリソースと同じクラスですが、HTML表示に特化した役割を持たせたい時などに公開リソースとしてpageリソースを使い、内部のリソースとしてappリソースを使ったりと役割を変えて使うことができます。

動作を確認してみましょう。

php bin/page.php get '/?year=2000&month=1&day=1'
200 OK
Content-Type: application/hal+json

{
    "year": 2000,
    "month": 1,
    "day": 1,
    "weekday": "Sat",
    "_links": {
        "self": {
            "href": "/index?year=2000&month=1&day=1"
        }
    }
}

現状ではapplication/hal+json形式で出力されていますが、これをHTML(text/html)形式で出力できるようにします。 まず、HTML出力用のモジュールをインストールします。

composer require madapaja/twig-module ^2.0

src/Module/HtmlModule.phpを作成します。

<?php
namespace MyVendor\Weekday\Module;

use Madapaja\TwigModule\TwigErrorPageModule;
use Madapaja\TwigModule\TwigModule;
use Ray\Di\AbstractModule;

class HtmlModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new TwigModule);
        $this->install(new TwigErrorPageModule);
    }
}

テンプレートファイルを配置するディレクトリを作成します。

cp -r vendor/madapaja/twig-module/var/templates var

bin/page.phpの出力形式をhtmlにするためにコンテキストをhtml-appに変更します。

<?php
use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())(PHP_SAPI === 'cli' ? 'cli-html-app' : 'html-app', $GLOBALS, $_SERVER));

最後に、表示用のテンプレートvar/templates/Page/Index.html.twigを作成します。

{% extends 'layout/base.html.twig' %}
{% block title %}Weekday{% endblock %}
{% block content %}
The weekday of {{ year }}/{{ month }}/{{ day }} is {{ weekday }}.
{% endblock %}

これでHTML出力の準備が完了しました。コンソールで動作を確認してみましょう。

php bin/page.php get '/?year=1991&month=8&day=1'
200 OK
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html>
...

HTMLが表示されない場合は、テンプレートエンジンでエラーが発生している可能性があります。 ログファイル(var/log/cli-html-app/last.logref.log)でエラー内容を確認してください。

Webサービスとして利用できるよう、public/index.phpも同様に変更します。

<?php

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'html-app' : 'prod-html-app', $GLOBALS, $_SERVER));

PHPの開発サーバーを起動し、Webブラウザでアクセスして確認してみましょう。

php -S 127.0.0.1:8080 public/index.php

http://127.0.0.1:8080/?year=2001&month=1&day=1

コンテキストについて

コンテキストは、アプリケーションの実行環境(開発・テスト・本番など)や表現形式(html・JSON)を表し、複数指定することができます。

例えば:

<?php
// 最小構成のJSONアプリケーション
require dirname(__DIR__) . '/autoload.php';
exit((require dirname(__DIR__) . '/bootstrap.php')('app'));
<?php
// プロダクション用HALアプリケーション
require dirname(__DIR__) . '/autoload.php';
exit((require dirname(__DIR__) . '/bootstrap.php')('prod-hal-app'));

コンテキストに応じたインスタンス生成用のPHPコードが自動的に生成されます。 これらのコードはvar/tmp/{context}/diフォルダに保存されます。通常は確認する必要はありませんが、 インスタンスがどのように生成されているか知りたい場合に参照できます。

REST API

ここでは、SQLite3を使用したRESTfulなTodoアプリケーションリソースを作成します。 まず、コンソールでvar/db/todo.sqlite3にデータベースを作成します。

mkdir var/db
sqlite3 var/db/todo.sqlite3

sqlite> create table todo(id integer primary key, todo, created_at);
sqlite> .exit

データベースアクセスにはAuraSqlDoctrine DbalCakeDBなどが利用可能です。ここではRay.AuraSqlModuleを使用します。

composer require ray/aura-sql-module

src/Module/AppModule::configure()でモジュールをインストールし、DateTimeImmutableを束縛します。

<?php
+use Ray\AuraSqlModule\AuraSqlModule;
+use DateTimeImmutable;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
+        $this->bind(DateTimeImmutable::class);        
+        $this->install(new AuraSqlModule(sprintf('sqlite:%s/var/db/todo.sqlite3', $this->appMeta->appDir)));
        $this->install(new PackageModule());
    }
}

次に、Todoリソースをsrc/Resource/App/Todos.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Package\Annotation\ReturnCreatedResource;
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\ResourceObject;
use DateTimeImmutable;
use Ray\AuraSqlModule\Annotation\Transactional;

use function sprintf;

#[Cacheable]
class Todos extends ResourceObject
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo,
        private readonly DateTimeImmutable $date,
    ) {
    }

    public function onGet(string $id = ''): static
    {
        $sql = $id ? /** @lang SQL */'SELECT * FROM todo WHERE id=:id' : /** @lang SQL */'SELECT * FROM todo';
        $this->body = $this->pdo->fetchAssoc($sql, ['id' => $id]);

        return $this;
    }

    #[Transactional, ReturnCreatedResource]
    public function onPost(string $todo): static
    {
        $this->pdo->perform(/** @lang SQL */'INSERT INTO todo (todo, created_at) VALUES (:todo, :created_at)', [
            'todo' => $todo,
            'created_at' => $this->date->format('Y-m-d H:i:s')
        ]);
        $this->code = 201; // Created
        $this->headers['Location'] = sprintf('/todos?id=%s', $this->pdo->lastInsertId());

        return $this;
    }

    #[Transactional]
    public function onPut(int $id, string $todo): static
    {
        $this->pdo->perform(/** @lang SQL */'UPDATE todo SET todo = :todo WHERE id = :id', [
            'id' => $id,
            'todo' => $todo
        ]);
        $this->code = 204; // No content

        return $this;
    }
}

このリソースクラスには、以下のような重要なアトリビュートが付与されています:

#[Cacheable]

クラスに付与された#[Cacheable]は、このリソースのGETメソッドがキャッシュ可能であることを示します。

#[Transactional]

onPostonPutメソッドの#[Transactional]は、データベースアクセスをトランザクション管理下に置くことを示します。

#[ReturnCreatedResource]

onPostメソッドの#[ReturnCreatedResource]は、新規作成されたリソースを返すことを示します。 この時Locationヘッダーで示されたURIに対して自動的にonGetが呼び出され、その結果がレスポンスに含まれます。これにより、 Locationヘッダーの内容が正しいことが保証され、同時にキャッシュも作成されます。

APIの動作確認

まず、キャッシュを有効にしたテスト用のブートストラップファイルbin/test.phpを作成します。

<?php

declare(strict_types=1);

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('prod-cli-hal-api-app', $GLOBALS, $_SERVER));

POSTリクエスト

新しいTodoを作成してみましょう。 BEAR.Sundayでは、POSTパラメータもクエリパラメータとして渡します。

php bin/test.php post '/todos?todo=shopping'
201 Created
Location: /todos?id=1

{
    "id": "1",
    "todo": "shopping",
    "created": "2017-06-04 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

ステータスコード201 Createdは、リソースが正常に作成されたことを示します。 また、Locationヘッダーには、新しく作成されたリソースのURIが含まれています。 (RFC7231 Section-6.3.2 日本語訳)

GETリクエスト

作成したリソースを取得してみましょう。

php bin/test.php get '/todos?id=1'
200 OK
ETag: 2527085682
Last-Modified: Sun, 04 Jun 2017 15:23:39 GMT
content-type: application/hal+json

{
    "id": "1",
    "todo": "shopping",
    "created": "2017-06-04 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

これでハイパーメディアAPIが完成しました。 APIサーバーを起動して、実際にHTTPリクエストを送信してみましょう。

php -S 127.0.0.1:8081 bin/app.php

curlコマンドでGETリクエストを送信:

curl -i 'http://127.0.0.1:8081/todos?id=1'
HTTP/1.1 200 OK
Host: 127.0.0.1:8081
Date: Sun, 02 May 2021 17:10:55 GMT
Connection: close
X-Powered-By: PHP/8.0.3
Content-Type: application/hal+json
ETag: 197839553
Last-Modified: Sun, 02 May 2021 17:10:55 GMT
Cache-Control: max-age=31536000

{
    "id": "1",
    "todo": "shopping",
    "created": "2024-11-07 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

複数回リクエストを送信して、Last-Modifiedの日付が変わらないことを確認してください。 この時、onGetメソッドは実際には実行されていません(試しにメソッド内にecho文を追加して確認できます)。

#[Cacheable]アトリビュートを使用したキャッシュは、有効期限を明示的に設定しない限り時間経過では無効化されません。 キャッシュの再生成は、onPut($id, $todo)onDelete($id)などでリソースが変更された時に行われます。

PUTリクエスト

既存のリソースを更新してみましょう。

curl -i http://127.0.0.1:8081/todos -X PUT -d "id=1&todo=think"

レスポンスは、コンテンツがないことを示す204 No Contentとなります。

HTTP/1.1 204 No Content
...

Content-Typeヘッダーでメディアタイプを指定することもできます。 JSONでリクエストを送信してみましょう。

curl -i http://127.0.0.1:8081/todos -X PUT -H 'Content-Type: application/json' -d '{"id": 1, "todo":"think" }'

再度GETリクエストを送信すると、EtagLast-Modifiedが更新されていることが確認できます。

curl -i 'http://127.0.0.1:8081/todos?id=1'

#[Cacheable]アトリビュートにより、Last-Modifiedの日付は自動的に管理されます。 アプリケーション側でこれらを管理したり、データベースに専用のカラムを用意したりする必要はありません。

#[Cacheable]を使用すると、リソースの内容は書き込み用データベースとは別の「クエリーリポジトリ」で管理され、 EtagLast-Modifiedヘッダーが自動的に付与されます。

Because Everything is A Resource

BEARでは全てがリソースです。

リソースの識別子URI、統一されたインターフェイス、ステートレスなアクセス、強力なキャッシュシステム、ハイパーリンク、レイヤードシステム、自己記述性。 BEAR.SundayアプリケーションのリソースはこれらのRESTの特徴を備えたものです。HTTPの標準に従い再利用性に優れています。

BEAR.SundayはDIで依存を結び、AOPで横断的関心事を結び、RESTの力でアプリケーションの情報をリソースとして結ぶコネクティングレイヤーのフレームワークです。



チュートリアル2

このチュートリアルでは、以下のツールを用いて標準に基づいた高品質なREST(ハイパーメディア)アプリケーション開発を学びます。

  • JSONのスキーマを定義し、バリデーションやドキュメンテーションに利用する JSON Schema
  • ハイパーメディアタイプ HAL (Hypertext Application Language)
  • CakePHPが開発しているDBマイグレーションツール Phinx
  • PHPのインターフェイスとSQL文実行を束縛する Ray.MediaQuery

tutorial2のコミットを参考にして進めましょう。

プロジェクト作成

プロジェクトスケルトンを作成します。

VENDOR=MyVendor PACKAGE=Ticket composer create-project bear/skeleton ticket
cd ticket

マイグレーション

Phinxをインストールします。

composer require --dev robmorgan/phinx

プロジェクトルートフォルダの.env.distファイルにDB接続情報を記述します。

TKT_DB_HOST=127.0.0.1:3306
TKT_DB_NAME=ticket
TKT_DB_USER=root
TKT_DB_PASS=''
TKT_DB_SLAVE=''
TKT_DB_DSN=mysql:host=${TKT_DB_HOST}

.env.distファイルはこのようにして、実際の接続情報は.envに記述しましょう。1

次にPhinxが利用するフォルダを作成します。

mkdir -p var/phinx/migrations
mkdir var/phinx/seeds

.envの接続情報をPhinxで利用するためにvar/phinx/phinx.phpを設置します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__, 2) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__, 2));

$development = new PDO(getenv('TKT_DB_DSN'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$test = new PDO(getenv('TKT_DB_DSN') . '_test', getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
return [
    'paths' => [
        'migrations' => __DIR__ . '/migrations',
    ],
    'environments' => [
        'development' => [
            'name' => $development->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $development
        ],
        'test' => [
            'name' => $test->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $test
        ]
    ]
];

setupスクリプト

データベース作成やマイグレーションを簡単に実行できるように、bin/setup.phpを編集します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__));

chdir(dirname(__DIR__));
passthru('rm -rf var/tmp/*');
passthru('chmod 775 var/tmp');
passthru('chmod 775 var/log');
// db
$pdo = new \PDO('mysql:host=' . getenv('TKT_DB_HOST'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$pdo->exec('CREATE DATABASE IF NOT EXISTS ' . getenv('TKT_DB_NAME'));
$pdo->exec('DROP DATABASE IF EXISTS ' . getenv('TKT_DB_NAME') . '_test');
$pdo->exec('CREATE DATABASE ' . getenv('TKT_DB_NAME') . '_test');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e development');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e test');

次にticketテーブルを作成するためにマイグレーションクラスを作成します。

./vendor/bin/phinx create Ticket -c var/phinx/phinx.php
Phinx by CakePHP - https://phinx.org.

...
created var/phinx/migrations/20210520124501_ticket.php

var/phinx/migrations/{current_date}_ticket.phpを編集してchange()メソッドを実装します。

<?php
use Phinx\Migration\AbstractMigration;

final class Ticket extends AbstractMigration
{
    public function change(): void
    {
        $table = $this->table('ticket', ['id' => false, 'primary_key' => ['id']]);
        $table->addColumn('id', 'uuid', ['null' => false])
            ->addColumn('title', 'string')
            ->addColumn('date_created', 'datetime')
            ->create();
    }
}

.env.distファイルを以下のように変更します。

 TKT_DB_USER=root
 TKT_DB_PASS=
 TKT_DB_SLAVE=
-TKT_DB_DSN=mysql:host=${TKT_DB_HOST}
+TKT_DB_DSN=mysql:host=${TKT_DB_HOST};dbname=${TKT_DB_NAME}

準備が完了したので、セットアップコマンドを実行してテーブルを作成します。

composer setup
> php bin/setup.php
...
All Done. Took 0.0248s

テーブルが作成されました。次回からこのプロジェクトのデータベース環境を整えるにはcomposer setupを実行するだけで行えます。

マイグレーションクラスの記述について詳しくはPhinxのマニュアル: Writing Migrationsをご覧ください。

モジュール

モジュールをComposerでインストールします。

composer require ray/identity-value-module ray/media-query -w

AppModuleでパッケージをインストールします。

src/Module/AppModule.php

<?php
namespace MyVendor\Ticket\Module;

use BEAR\Dotenv\Dotenv;
use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;

use BEAR\Resource\Module\JsonSchemaModule;
use Ray\AuraSqlModule\AuraSqlModule;
use Ray\IdentityValueModule\IdentityValueModule;
use Ray\MediaQuery\DbQueryConfig;
use Ray\MediaQuery\MediaQueryModule;
use Ray\MediaQuery\Queries;
use function dirname;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $this->install(
            new AuraSqlModule(
                (string) getenv('TKT_DB_DSN'),
                (string) getenv('TKT_DB_USER'),
                (string) getenv('TKT_DB_PASS'),
                (string) getenv('TKT_DB_SLAVE')
            )
        );
        $this->install(
            new MediaQueryModule(
                Queries::fromDir($this->appMeta->appDir . '/src/Query'), [
                    new DbQueryConfig($this->appMeta->appDir . '/var/sql'),
                ]
            )
        );
        $this->install(new IdentityValueModule());
        $this->install(
            new JsonSchemaModule(
                $this->appMeta->appDir . '/var/schema/response',
                $this->appMeta->appDir . '/var/schema/request'
            )
        );
        $this->install(new PackageModule());
    }
}

SQL

チケット用の3つのSQLをvar/sqlに保存します。4

var/sql/ticket_add.sql

/* ticket add */
INSERT INTO ticket (id, title, date_created)
VALUES (:id, :title, :dateCreated);

var/sql/ticket_list.sql

/* ticket list */
SELECT id, title, date_created
  FROM ticket
 LIMIT 3;

var/sql/ticket_item.sql

/* ticket item */
SELECT id, title, date_created
  FROM ticket
 WHERE id = :id;

作成時に単体でそのSQLが動作するか確認しましょう。

例えば、PHPStormにはデータベースツールのDataGripが含まれていて、コード補完やSQLのリファクタリングなどSQL開発に必要な機能が揃っています。 DB接続などのセットアップを行えば、SQLファイルをIDEで直接実行できます。25

JSON Schema

Ticket(チケットアイテム)、Tickets(チケットアイテムリスト)のリソース表現をJSON Schemaで定義し、それぞれ保存します。

var/schema/response/ticket.json

{
  "$id": "ticket.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ticket",
  "type": "object",
  "required": ["id", "title", "dateCreated"],
  "properties": {
    "id": {
      "description": "The unique identifier for a ticket.",
      "type": "string",
      "maxLength": 64
    },
    "title": {
      "description": "The title of the ticket.",
      "type": "string",
      "maxLength": 255
    },
    "dateCreated": {
      "description": "The date and time that the ticket was created.",
      "type": "string",
      "format": "date-time"
    }
  }
}

var/schema/response/tickets.json

Ticketsはticketの配列です。

{
  "$id": "tickets.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Tickets",
  "type": "object",
  "required": ["tickets"],
  "properties": {
    "tickets": {
      "type": "array",
      "items": { "$ref": "./ticket.json" }
    }
  }
}
  • $id - ファイル名を指定しますが、公開する場合はURLを記述します。
  • title - オブジェクト名としてAPIドキュメントで扱われます。
  • examples - 適宜、例を指定しましょう。オブジェクト全体のものも指定できます。

PHPStormではエディタの右上に緑色のチェックが出ていて問題がないことが分かります。スキーマ作成時にスキーマ自身もバリデートしましょう。

クエリーインターフェイス

インフラストラクチャへのアクセスを抽象化したPHPのインターフェイスを作成します。

  • Ticketリソースを読み出す TicketQueryInterface
  • Ticketリソースを作成する TicketCommandInterface

src/Query/TicketQueryInterface.php

<?php

namespace MyVendor\Ticket\Query;

use MyVendor\Ticket\Entity\Ticket;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketQueryInterface
{
    #[DbQuery('ticket_item')]
    public function item(string $id): Ticket|null;

    /** @return array<Ticket> */
    #[DbQuery('ticket_list')]
    public function list(): array;
}

src/Query/TicketCommandInterface.php

<?php

namespace MyVendor\Ticket\Query;

use DateTimeInterface;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketCommandInterface
{
    #[DbQuery('ticket_add')]
    public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;
}

#[DbQuery]アトリビュートでSQL文を指定します。

このインターフェイスに対する実装を用意する必要はありません。指定されたSQLのクエリを行うオブジェクトが自動生成されます。

インターフェイスを副作用が発生するコマンドまたは値を返すクエリーという2つの関心に分けていますが、リポジトリパターンのように1つにまとめたり、ADRパターンのように1インターフェイス1メソッドにしても構いません。アプリケーション設計者が方針を決定します。

エンティティ

メソッドの返り値にarrayを指定すると、データベースの結果はそのまま連想配列として得られますが、メソッドの返り値にエンティティの型を指定すると、その型にハイドレーションされます。

#[DbQuery('ticket_item')]
public function item(string $id): array // 配列が得られる
#[DbQuery('ticket_item')]
public function item(string $id): Ticket|null; // Ticketエンティティが得られる

複数行(row_list)の時は/** @return array<Ticket>*/とPHPDocでTicketが配列で返ることを指定します。

/** @return array<Ticket> */
#[DbQuery('ticket_list')]
public function list(): array; // Ticketエンティティの配列が得られる

各行の値は名前引数でコンストラクタに渡されます。6

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Entity;

class Ticket
{
    public readonly string $dateCreated;

    public function __construct(
        public readonly string $id,
        public readonly string $title,
        string $date_created
    ) {
        $this->dateCreated = $date_created;
    }
}

データベースはスネークケース(date_created)、JSONはキャメルケース(dateCreated)という異なる命名慣習を持ちます。2語以上のプロパティ名では、コンストラクターで明示的に変換することで、データベースとJSONの命名文化のギャップを解消できます。

リソース

リソースクラスはクエリーインターフェイスに依存します。

ticketリソース

ticketリソースをsrc/Resource/App/Ticket.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\ResourceObject;
use MyVendor\Ticket\Query\TicketQueryInterface;

class Ticket extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query
    ){}
    
    #[JsonSchema("ticket.json")]
    public function onGet(string $id = ''): static
    {
        $this->body = (array) $this->query->item($id);

        return $this;
    }
}

アトリビュート#[JsonSchema]onGet()で出力される値がticket.jsonのスキーマで定義されていることを表します。 AOPによってリクエスト毎にバリデートされます。

シードを入力してリソースをリクエストしてみましょう。7

% mysql -u root -e "INSERT INTO ticket (id, title, date_created) VALUES ('1', 'foo', '1970-01-01 00:00:00')" ticket
% php bin/app.php get '/ticket?id=1'
200 OK
Content-Type: application/hal+json

{
    "id": "1",
    "title": "foo",
    "date_created": "1970-01-01 00:00:00",
    "_links": {
        "self": {
            "href": "/ticket?id=1"
        }
    }
}

Ray.MediaQuery

Ray.MediaQueryを使えば、ボイラープレートとなりやすい実装クラスをコーディングすることなく、インターフェイスから自動生成されたSQL実行オブジェクトがインジェクトされます。8

SQL文には;で区切った複数のSQL文を記述することができ、複数のSQLに同じパラメータが名前でバインドされます。SELECT以外のクエリではトランザクションも実行されます。

利用クラスはインターフェイスにしか依存していないので、動的にSQLを生成したい場合にはRay.MediaQueryの代わりにクエリービルダーをインジェクトしたSQL実行クラスで組み立てたSQLを実行すれば良いでしょう。 詳しくはマニュアルのデータベースをご覧ください。

埋め込みリンク

通常Webサイトのページは複数のリソースを内包します。例えばブログの記事ページであれば、記事以外にもおすすめや広告、カテゴリーリンクなどが含まれるかもしれません。 それらをクライアントがバラバラに取得する代わりに、独立したリソースとして埋め込みリンクで1つのリソースに束ねることができます。

HTMLとそこに記述される<img>タグをイメージしてください。どちらも独立したURLを持ちますが、画像リソースがHTMLリソースに埋め込まれていてHTMLを取得するとHTML内に画像が表示されます。 これらはハイパーメディアタイプのEmbedding links (LE)と呼ばれるもので、埋め込まれるリソースがリンクされています。

ticketリソースにprojectリソースを埋め込んでみましょう。Projectクラスを用意します。

src/Resource/App/Project.php

<?php

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\ResourceObject;

class Project extends ResourceObject
{
    public function onGet(): static
    {
        $this->body = ['title' => 'Project A'];

        return $this;
    }
}

Ticketリソースにアトリビュート#[Embed]を追加します。

+use BEAR\Resource\Annotation\Embed;
+use BEAR\Resource\Request;
+
+   #[Embed(src: '/project', rel: 'project')]
    #[JsonSchema("ticket.json")]
    public function onGet(string $id = ''): static
    {
+        assert($this->body['project'] instanceof Request);
-        $this->body = (array) $this->query->item($id);
+        $this->body += (array) $this->query->item($id);

#[Embed]アトリビュートのsrcで指定されたリソースのリクエストがbodyプロパティのrelキーにインジェクトされ、レンダリング時に遅延評価され文字列表現になります。

例を簡単にするためにこの例ではパラメータを渡していませんが、メソッド引数が受け取った値をURIテンプレートを使って渡すこともできますし、インジェクトされたリクエストのパラメータを修正、追加することもできます。 詳しくはリソースをご覧ください。

もう一度リクエストすると_embeddedというプロパティにprojectリソースの状態が追加されているのが分かります。

% php bin/app.php get '/ticket?id=1'
{
    "id": "1",
    "title": "foo",
    "date_created": "1970-01-01 00:00:00",
+    "_embedded": {
+        "project": {
+            "title": "Project A"
+        }
    }
}

埋め込みリソースはREST APIの重要な機能です。コンテンツにツリー構造を与えHTTPリクエストコストを削減します。 情報が他の何の情報を含んでいるかはドメインの関心事です。クライアントで都度取得するのではなく、その関心事はサーバーサイドのLE(埋め込みリンク)でうまく表すことができます。9

ticketsリソース

POSTで作成、GETでチケットリストが取得できるticketsリソースをsrc/Resource/App/Tickets.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use MyVendor\Ticket\Query\TicketCommandInterface;
use MyVendor\Ticket\Query\TicketQueryInterface;
use Ray\IdentityValueModule\UuidInterface;
use function uri_template;

class Tickets extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query,
        private TicketCommandInterface $command,
        private UuidInterface $uuid
    ){}

    #[Link(rel: "doPost", href: '/tickets')]
    #[Link(rel: "goTicket", href: '/ticket{?id}')]
    #[JsonSchema("tickets.json")]
    public function onGet(): static
    {
        $this->body = [
            'tickets' => $this->query->list()
        ];
        
        return $this;
    }

    #[Link(rel: "goTickets", href: '/tickets')]
    public function onPost(string $title): static
    {
        $id = (string) $this->uuid;
        $this->command->add($id, $title);

        $this->code = StatusCode::CREATED;
        $this->headers[ResponseHeader::LOCATION] = uri_template('/ticket{?id}', ['id' => $id]);

        return $this;
    }
}

インジェクトされた$uuidを文字列にキャストすることでUUIDの文字列表現が得られます。また#[Link]は他のリソース(アプリケーション状態)へのリンクを表します。

add()メソッドで現在時刻を渡していないことに注目してください。 値が渡されない場合、nullではなく、MySQLの現在時刻文字列がSQLにバインドされます。 なぜならDateTimeInterfaceに束縛された現在時刻DateTimeオブジェクトの文字列表現(現在時刻文字列)がSQLに束縛されているからです。

public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;

SQL内部でNOW()とハードコーディングすることや、メソッドに毎回現在時刻を渡す手間を省きます。 DateTimeオブジェクトを渡すこともできますし、テストのコンテキストでは固定のテスト用時刻を束縛することもできます。

このようにクエリーの引数にインターフェイスを指定するとそのオブジェクトをDIを使って取得し、その文字列表現がSQLに束縛されます。 例えばログインユーザーIDなどを束縛してアプリケーションで横断的に利用できます。10

ハイパーメディアAPIテスト

REST(Representational State Transfer)という用語は、2000年にRoy Fieldingが博士論文の中で紹介、定義したもので「適切に設計されたWebアプリケーションの動作」をイメージさせることを目的としています。 それはWebリソースのネットワーク(仮想ステートマシン)であり、ユーザーはリソース識別子(URL)と、GETやPOSTなどのリソース操作(アプリケーションステートの遷移)を選択することで、アプリケーションを進行させ、その結果、次のリソースの表現(次のアプリケーションステート)がエンドユーザーに転送されて使用されるというものです。

Wikipedia (REST)

RESTアプリケーションでは次のアクションがURLとしてサービスから提供され、クライアントはそれを選択します。

HTML Webアプリケーションは完全にRESTfulです。その操作は「(aタグなどで)提供されたURLに遷移する」または「提供されたフォームを埋めて送信する」のいずれかでしかありません。

REST APIのテストも同様に記述します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Hypermedia;

use BEAR\Resource\ResourceInterface;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use MyVendor\Ticket\Injector;
use MyVendor\Ticket\Query\TicketQueryInterface;
use PHPUnit\Framework\TestCase;
use Ray\Di\InjectorInterface;
use function json_decode;

class WorkflowTest extends TestCase
{
    protected ResourceInterface $resource;
    protected InjectorInterface $injector;

    protected function setUp(): void
    {
        $this->injector = Injector::getInstance('hal-api-app');
        $this->resource = $this->injector->getInstance(ResourceInterface::class);
    }

    public function testIndex(): static
    {
        $index = $this->resource->get('/');
        $this->assertSame(200, $index->code);

        return $index;
    }

    /**
     * @depends testIndex
     */
    public function testGoTickets(ResourceObject $response): static
    {
        $json = (string) $response;
        $href = json_decode($json)->_links->{'goTickets'}->href;
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }

    /**
     * @depends testGoTickets
     */
    public function testDoPost(ResourceObject $response): static
    {
        $json = (string) $response;
        $href = json_decode($json)->_links->{'doPost'}->href;
        $ro = $this->resource->post($href, ['title' => 'title1']);
        $this->assertSame(201, $ro->code);

        return $ro;
    }

    /**
     * @depends testDoPost
     */
    public function testGoTicket(ResourceObject $response): static
    {
        $href = $response->headers[ResponseHeader::LOCATION];
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }
}

起点となるルートページも必要です。

src/Resource/App/Index.php

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;

class Index extends ResourceObject
{
    #[Link(rel: 'goTickets', href: '/tickets')]
    public function onGet(): static
    {
        return $this;
    }
}
  • setUpではリソースクライアントを生成し、testIndex()でルートページにアクセスしています。
  • レスポンスを受け取ったtestGoTickets()メソッドではそのレスポンスオブジェクトをJSON表現にして、次のチケット一覧を取得するリンクgoTicketsを取得しています。
  • リソースボディのテストを記述する必要はありません。レスポンスのJSON Schemaバリデーションが通ったという保証がされているので、ステータスコードの確認だけでOKです。
  • RESTの統一インターフェイスに従い、次にアクセスするリクエストURLは常にレスポンスに含まれます。それを次々に検査します。

RESTの統一インターフェイス

1) リソースの識別、2) 表現によるリソースの操作、3) 自己記述メッセージ、 4) アプリケーション状態のエンジンとしてのハイパーメディア(HATEOAS)の4つのインターフェイス制約です。11

実行してみましょう。

./vendor/bin/phpunit --testsuite hypermedia

ハイパーメディアAPIテスト(RESTアプリケーションテスト)はRESTアプリケーションがステートマシンであるということをよく表し、ワークフローをユースケースとして記述することができます。 REST APIテストを見ればそのアプリケーションがどのように使われるか網羅されているのが理想です。

HTTPテスト

HTTPでREST APIのテストを行うためにはテスト全体を継承して、setUpでクライアントをHTTPテストクライアントにします。

class WorkflowTest extends Workflow
{
    protected function setUp(): void
    {
        $this->resource = new HttpResource('127.0.0.1:8080', __DIR__ . '/index.php', __DIR__ . '/log/workflow.log');
    }
}

このクライアントはリソースクライアントと同じインターフェイスを持ちますが、実際のリクエストはビルトインサーバーに対してHTTPリクエストで行われ、サーバーからのレスポンスを受け取ります。 1つ目の引数はビルトインサーバーのURLです。newされると2番目の引数で指定されたブートストラップスクリプトでビルトインサーバーが起動します。

テストサーバー用のブートストラップスクリプトもAPIコンテキストに変更します。

tests/Http/index.php

-exit((new Bootstrap())('hal-app', $GLOBALS, $_SERVER));
+exit((new Bootstrap())('hal-api-app', $GLOBALS, $_SERVER));

実行してみましょう。

./vendor/bin/phpunit --testsuite http

HTTPアクセスログ

curlで行われた実際のHTTPリクエスト/レスポンスログが3番目の引数のリソースログに記録されます。

curl -s -i 'http://127.0.0.1:8080/'

HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Content-Type: application/hal+json

{
    "_links": {
        "self": {
            "href": "/index"
        },
        "goTickets": {
            "href": "/tickets"
        }
    }
}
curl -s -i -H 'Content-Type:application/json' -X POST -d '{"title":"title1"}' http://127.0.0.1:8080/tickets

HTTP/1.1 201 Created
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Location: /ticket?id=421d997c-9a0e-4018-a6c2-9b8758cac6d6

実際に記録されたJSONは、特に複雑な構造を持つ場合に確認するのに役に立ちます。APIドキュメントと併せて確認するのにもいいでしょう。 HTTPクライアントはE2Eテストにも利用することができます。

APIドキュメント

ResourceObjectではメソッドシグネチャがAPIの入力パラメータになっていて、レスポンスがスキーマ定義されています。 その自己記述性の高さからAPIドキュメントが自動生成することができます。

作成してみましょう。docsフォルダにドキュメントが出力されます。

composer doc

IDL(インターフェイス定義言語)を記述する労力を削減しますが、より価値があるのはドキュメントが最新のPHPコードに追従し常に正確なことです。 CIに組み込み常にコードとAPIドキュメントが同期している状態にするのがいいでしょう。

関連ドキュメントをリンクすることもできます。設定について詳しくはApiDocをご覧ください。

コード例

以下のコード例も用意しています。

  • Testコンテキストを追加してテスト毎にDBをクリアするTestModule 4e9704d
  • DBクエリで連想配列を返す代わりにハイドレートされたエンティティクラスを返すRay.MediaQueryentityオプション 29f0a1f
  • 静的なSQLと動的なSQLを合成したクエリービルダー 9d095ac

RESTフレームワーク

Web APIには以下の3つのスタイルがあります。

  • トンネル(SOAP、GraphQL)
  • URI(オブジェクト、CRUD)
  • ハイパーメディア(REST)

リソースを単なるRPCとして扱うURIスタイル12に対して、このチュートリアルで学んだのはリソースがリンクされているRESTです。13 リソースは#[Link]のLO(アウトバウンドリンク)で結ばれワークフローを表し、#[Embed]のLE(埋め込みリンク)でツリー構造を表しています。

BEAR.Sundayは標準に基づいたクリーンなコードであることを重視します。

フレームワーク固有のバリデータよりJSON Schema。独自ORMより標準SQL。独自構造JSONよりIANA標準メディアタイプ14JSON。

アプリケーション設計は「実装が自由である」ことではなく「制約の選択が自由である」ということが重要です。 アプリケーションはその制約に基づき、開発効率やパフォーマンス、後方互換性を壊さない進化可能性を目指すと良いでしょう。


コメントは説明になるだけでなく、スロークエリーログ等からもSQLを特定しやすくなります。

※ 以前のPHP 7対応のチュートリアルはtutorial2_v1にあります。


BEAR.Sunday CLI チュートリアル

前提条件

  • PHP 8.2以上
  • Composer
  • Git

ステップ1: プロジェクトの作成

1.1 新規プロジェクトの作成

VENDOR=MyVendor PACKAGE=Greet composer create-project bear/skeleton greet
cd greet

1.2 開発サーバーの起動確認

php -S 127.0.0.1:8080 -t public

ブラウザで http://127.0.0.1:8080 にアクセスし、”Hello BEAR.Sunday” が表示されることを確認します。

{
    "greeting": "Hello BEAR.Sunday",
    "_links": {
        "self": {
            "href": "/index"
        }
    }
}

ステップ2: BEAR.Cliのインストール

composer require bear/cli

ステップ3: 挨拶リソースの作成

src/Resource/Page/Greeting.phpを作成します:

<?php

namespace MyVendor\Greet\Resource\Page;

use BEAR\Cli\Attribute\Cli;
use BEAR\Cli\Attribute\Option;
use BEAR\Resource\ResourceObject;

class Greeting extends ResourceObject
{
    #[Cli(
        name: 'greet',
        description: '多言語で挨拶するコマンド',
        output: 'greeting'
    )]
    public function onGet(
        #[Option(shortName: 'n', description: '挨拶する相手の名前')]
        string $name,
        #[Option(shortName: 'l', description: '言語 (en, ja, fr, es)')]
        string $lang = 'en'
    ): static {
        $greeting = match ($lang) {
            'ja' => 'こんにちは',
            'fr' => 'Bonjour',
            'es' => '¡Hola',
            default => 'Hello',
        };
        
        $this->body = [
            'greeting' => "{$greeting}, {$name}",
            'lang' => $lang
        ];

        return $this;
    }
}

ステップ4: Webリソースとしての動作確認

ブラウザで以下のURLにアクセスして動作確認します:

http://127.0.0.1:8080/greeting?name=World&lang=fr

以下のようなJSONレスポンスが表示されるはずです:

{
   "greeting": "Bonjour, World",
   "lang": "fr",
   "_links": {
      "self": {
         "href": "/greeting?name=World&lang=fr"
      }
   }
}

ステップ5: CLIコマンドの生成

vendor/bin/bear-cli-gen MyVendor.Greet

これにより以下のファイルが生成されます:

  • bin/cli/greet:実行可能なCLIコマンド

ステップ6: コマンドのテスト

生成されたコマンドをテストします:

# 実行権限を付与
chmod +x bin/cli/greet

# ヘルプの表示
./bin/cli/greet --help

# 基本的な挨拶
./bin/cli/greet -n "World"
# 出力: Hello, World

# 日本語で挨拶
./bin/cli/greet -n "世界" -l ja
# 出力: こんにちは, 世界

# JSON形式で出力
./bin/cli/greet -n "World" -l ja --format json
# 出力: {"greeting": "こんにちは, World", "lang": "ja"}

ステップ7: ローカルでのHomebrewフォーミュラのテスト

7.1 フォーミュラの生成

フォーミュラを生成するには、Gitリポジトリが初期化されている必要があります:

# Gitリポジトリの初期化(まだの場合)
git init
git add .
git commit -m "Initial commit"

フォーミュラを生成します:

vendor/bin/bear-cli-gen MyVendor.Greet

これにより以下のファイルが生成されます:

  • bin/cli/greet:実行可能なCLIコマンド
  • var/homebrew/greet.rb:Homebrewフォーミュラ(Gitリポジトリが設定されている場合)

7.2 ローカルでのHomebrewインストールテスト

生成されたフォーミュラをローカルでテストできます:

# フォーミュラを使ってローカルインストール
brew install --formula ./var/homebrew/greet.rb

# インストールされたコマンドのテスト
greet -n "Homebrew" -l ja
# 出力: こんにちは, Homebrew

# アンインストール
brew uninstall greet

オプション: 公開配布について

実際にCLIツールを他の人に配布したい場合は、以下の流れでHomebrewパッケージとして公開できます:

  1. アプリケーションをGitHubにプッシュ
  2. 生成されたフォーミュラ(var/homebrew/greet.rb)をhomebrew-プレフィックス付きのGitHubリポジトリで公開
  3. ユーザーはbrew tap your-vendor/greet && brew install greetでインストール可能

詳細な公開手順については、Homebrew公式ドキュメントを参照してください。

注意: フォーミュラの生成には以下の条件が必要です:

  • アプリケーションのGitリポジトリが初期化されている
  • ローカルテストの場合はGitHubリモートリポジトリは不要

これらの条件が満たされていない場合、フォーミュラ生成はスキップされ、その理由が表示されます。

まとめ

このチュートリアルでは、単なるCLIツール作成を超えた、BEAR.Sundayの本質的な価値を体験しました:

リソース指向アーキテクチャの真価

同じリソース、複数の境界

  • Greetingリソースは一度書くだけで、Web API、CLI、Homebrewパッケージとして機能
  • ビジネスロジックの重複なし、保守も一箇所で完結

境界横断フレームワーク

BEAR.Sundayは境界のフレームワークとして機能し、以下の境界を透過的に扱います:

  • プロトコル境界: HTTP ↔ コマンドライン
  • インターフェース境界: Web ↔ CLI ↔ パッケージ配布
  • 環境境界: 開発環境 ↔ 本番環境 ↔ ユーザー環境

設計思想の実現

// 1つのリソース
class Greeting extends ResourceObject {
    public function onGet(string $name, string $lang = 'en'): static
    {
        // ビジネスロジックは一箇所に
    }
}

# Web API として
curl "http://localhost/greeting?name=World&lang=ja"

# CLI として  
./bin/cli/greet -n "World" -l ja

# Homebrewパッケージとして
brew install your-vendor/greet && greet -n "World" -l ja

長期的な保守性と生産性

  • DRY原則の徹底: ドメインロジックがインターフェイスと結合していません
  • 統一されたテスト: 1つのリソースをテストすれば全境界をカバーします
  • 一貫したAPI設計: Web APIとCLIで同じパラメーター構造
  • 将来の拡張性: 新しい境界(gRPC、GraphQLなど)も同じリソースで対応可能
  • PHPバージョンの独立: 使い続ける自由があります

現代的な配布システムとの統合

BEAR.Sundayのリソースは、現代的なパッケージシステムとも自然に統合できます。HomebrewのようなパッケージマネージャーやComposerのエコシステムを活用することで、ユーザーは実行環境を意識することなく、統一されたインターフェースでツールを利用できます。

BEAR.Sundayの「Because Everything is a Resource」は、単なるスローガンではなく、境界を越えた一貫性と保守性を実現する設計哲学です。このチュートリアルで体験したように、リソース指向アーキテクチャは境界のないソフトウェアを実現し、開発体験だけでなく利用体験にも新しい地平をもたらします


パッケージ

アプリケーションは独立したcomposerパッケージです。

フレームワークは依存としてcomposer installでインストールしますが、他のアプリケーションも依存パッケージとして使うことができます。

アプリケーション・パッケージ

構造

BEAR.Sundayアプリケーションのファイルレイアウトはphp-pds/skeletonに準拠しています。

bin/

実行可能なコマンドを設置します。

BEARのリソースはコンソール入力とWebの双方からアクセスできます。 使用するスクリプトによってコンテキストが変わります。

php bin/app.php options '/todos' # APIアクセス(appリソース)
php bin/page.php get '/todos?id=1' # Webアクセス(pageリソース)
php -S 127.0.0.1 bin/app.php # PHPサーバー

コンテキストが変わるとアプリケーションの振る舞いが変わります。 ユーザーは独自のコンテキストを作成することができます。

src/

アプリケーション固有のクラスファイルを設置します。

public/

Web公開フォルダです。

var/

logtmpフォルダは書き込み可能にします。var/wwwはWebドキュメントの公開エリアです。 confなど可変のファイルを設置します。

実行シーケンス

  1. コンソール入力(bin/app.phppage.php)またはWebサーバーのエントリーファイル(public/index.php)がbootstrap.phpを実行します。
  2. bootstrap.phpでは実行コンテキストに応じたルートオブジェクト$appを作成します。
  3. $appに含まれるルーターは外部のHTTPまたはCLIリクエストをアプリケーション内部のリソースリクエストに変換します。
  4. リソースリクエストが実行され、結果がクライアントに転送されます。

フレームワーク・パッケージ

フレームワークは以下のパッケージから構成されます。

ray/aop

Scrutinizer Quality Score codecov Type Coverage Continuous Integration

Javaの AOPアライアンス に準拠したAOPフレームワークです。

ray/di

Scrutinizer Quality Score codecov Type Coverage Continuous Integration

google/guice スタイルのDIフレームワークです。ray/aopを含みます。

bear/resource

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

bear/sunday

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

フレームワークのインターフェイスパッケージです。bear/resourceを含みます。

bear/package

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

bear/sundayの実装パッケージです。bear/sundayを含みます。

ライブラリ・パッケージ

必要なライブラリ・パッケージをcomposerでインストールします。

Category Composer package Library
ルーター    
  bear/aura-router-module Aura.Router v2
データベース    
  ray/media-query  
  ray/aura-sql-module Aura.Sql v2
  ray/dbal-module Doctrine DBAL
  ray/cake-database-module CakePHP v3 database
  ray/doctrine-orm-module Doctrine ORM
ストレージ    
  bear/query-repository 読み書きリポジトリの分離(デフォルト)
  bear/query-module DBやWeb APIなどの外部アクセスの分離
Web    
  madapaja/twig-module Twigテンプレートエンジン
  ray/web-form-module Webフォーム & バリデーション
  ray/aura-web-module Aura.Web
  ray/aura-session-module Aura.Session
  ray/symfony-session-module Symfony Session
バリデーション    
  ray/validate-module Aura.Filter
  satomif/extra-aura-filter-module Aura.Filter
認証    
  ray/oauth-module OAuth
  kuma-guy/jwt-auth-module JSON Web Token
  ray/role-module Zend Acl  Zend Acl
  bear/acl-resource ACLベースのエンベドリソース
ハイパーメディア    
  kuma-guy/siren-module Siren
開発    
  ray/test-double テストダブル
非同期ハイパフォーマンス    
  MyVendor.Swoole Swoole

ベンダー・パッケージ

特定のパッケージやツールの組み合わせをモジュールだけのパッケージにして再利用し、同様のプロジェクトのモジュールを共通化することができます。1

Semver

すべてのパッケージはセマンティックバージョニングに従います。マイナーバージョンアップでは後方互換性が破壊されることはありません。



アプリケーション

実行シーケンス

アプリケーションは、コンパイル、リクエスト、レスポンスの順で実行されます。

0. コンパイル

コンテキストに応じたDIとAOPの設定により、アプリケーションの実行に必要なルートオブジェクト$appが生成されます。$approutertransferなどの、アプリケーション実行に必要なサービスオブジェクトで構成されます15$appはシリアライズされ、各リクエストで再利用されます。

  • router - 外部入力をリソースリクエストに変換
  • resource - リソースクライアント
  • transfer - 出力

1. リクエスト

リクエストに基づき、リソースオブジェクトが作成されます。

リクエストに応じてonGetonPostなどに応答するメソッドを持つリソースオブジェクトは、自身のリソースの状態としてcodeまたはbodyプロパティを設定します。

リソースオブジェクトのメソッドは、リソースの状態を変更するためだけのものであり、表現そのもの(HTMLやJSONなど)には関心を持ちません。

メソッドの前後では、ログや認証などの、メソッドに束縛されたアプリケーションロジックがAOPにより実行されます。

2. レスポンス

リソースに注入されたレンダラーが、JSONやHTMLなどのリソースの状態表現を作成し、クライアントに転送します。 (REpresentational State Transfer = REST)

bootスクリプト

public/bin/などのエントリーポイントに設置され、アプリケーションを実行します。 スクリプトでは、アプリケーション実行コンテキストを指定して実行します。

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('app', $GLOBALS, $_SERVER));

デフォルトではWebサーバースクリプトとして動作します。

php -S 127.0.0.1:8080 public/index.php

cliコンテキストを付加すると、コンソールアプリケーションのスクリプトとなります。

exit((new Bootstrap())('cli-app', $GLOBALS, $_SERVER));
php bin/app.php get /user/1

コンテキスト

コンテキストは、特定の目的のためのDIとAOPの束縛のセットです。コードは同じでも束縛が変わることで、アプリケーションは異なる振る舞いをします。 コンテキストには、フレームワークが用意しているbuilt-inコンテキストと、アプリケーションが作成するカスタムコンテキストがあります。

built-inコンテキスト

  • app - ベースアプリケーション
  • api - APIアプリケーション
  • cli - コンソールアプリケーション
  • hal - HALアプリケーション
  • prod - プロダクション

appの場合、リソースはJSONでレンダリングされます。 apiは、デフォルトのリソースのスキーマをpageからappに変更します。Webのルートアクセス(GET /)は、page://self/からapp://self/へのアクセスとなります。 cliを指定するとコンソールアプリケーションとなります。 prodは、キャッシュの設定などをプロダクション用に最適化します。

コンテキスト名は、それぞれのモジュールに対応します。例えば、appはAppModule、cliはCliModuleに対応します。 コンテキストは組み合わせて使用することができます。例えば、prod-hal-api-appは、プロダクション用HALのAPIアプリケーションとして動作します。

カスタムコンテキスト

アプリケーションのsrc/Module/に設置します。built-inコンテキストと同名の場合、カスタムコンテキストが優先されます。カスタムコンテキストからbuilt-inコンテキストを呼び出すことで、一部の束縛を上書きすることができます。

コンテキスト無知

コンテキストの値は、ルートオブジェクトの作成のみに使用され、その後に消滅します。アプリケーションから参照可能なグローバルな”モード”は存在せず、アプリケーションは現在実行されているコンテキストを知ることはできません。外部の値を参照して振る舞いを変えるのではなく、インターフェイスのみに依存16し、コンテキストによる束縛の変更で振る舞いを変更します。



モジュール

モジュールは、DIとAOPの束縛のセットです。BEAR.Sundayでは、一般的な設定ファイルやConfigクラス、実行モードなどは存在しません。各コンポーネントが必要とする値は、依存性の注入により提供されます。モジュールがこの依存性の束縛を担当します。

アプリケーションの起点となるモジュールはAppModule(src/Module/AppModule.php)です。 AppModule内で、他の必要なモジュールをinstallします。

モジュールが必要とする値(ランタイムではなく、コンパイルタイムで必要な値)は、手動のコンストラクタインジェクションにより束縛を行います。

class AppModule extends AbstractAppModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        // 追加モジュール
        $this->install(new AuraSqlModule('mysql:host=localhost;dbname=test', getenv('db_username'), getenv('db_password')));
        $this->install(new TwigModule());
        // package標準のモジュール
        $this->install(new PackageModule());
    }
}

DIの束縛

以下に代表的な束縛パターンを示します:

// クラスの束縛
$this->bind($interface)->to($class);

// プロバイダー(ファクトリー)の束縛
$this->bind($interface)->toProvider($provider);

// インスタンス束縛
$this->bind($interface)->toInstance($instance);

// 名前付き束縛
$this->bind($interface)->annotatedWith($annotation)->to($class);

// シングルトン
$this->bind($interface)->to($class)->in(Scope::SINGLETON);

// コンストラクタ束縛
$this->bind($interface)->toConstructor($class, $named);

詳細についてはDIをご参照ください。

AOPの設定

AOPは、クラスとメソッドをMatcherで”検索”し、マッチするメソッドにインターセプターを束縛します。

// 例1:メソッド名による束縛
$this->bindInterceptor(
    $this->matcher->any(),                   // どのクラスの
    $this->matcher->startsWith('delete'),    // "delete"で始まるメソッド名のメソッドには
    [Logger::class]                          // Loggerインターセプターを束縛
);

// 例2:クラスとアノテーションによる束縛
$this->bindInterceptor(
    $this->matcher->SubclassesOf(AdminPage::class),  // AdminPageの継承または実装クラスの
    $this->matcher->annotatedWith(Auth::class),      // @Authアノテーションがアノテートされているメソッドには
    [AdminAuthentication::class]                     // AdminAuthenticationインターセプターを束縛
);

詳細についてはAOPをご参照ください。

束縛の優先順位

参照: Ray.Di 束縛

同一モジュール内での優先順位

同じモジュール内では、先に束縛された方が優先されます。以下の例では、Foo1が優先されます:

$this->bind(FooInterface::class)->to(Foo1::class);
$this->bind(FooInterface::class)->to(Foo2::class);

モジュールインストールでの優先順位

先にインストールされたモジュールが優先されます。以下の例では、Foo1Moduleが優先されます:

$this->install(new Foo1Module);
$this->install(new Foo2Module);

後からのモジュールを優先させたい場合は、overrideを使用します。以下の例では、Foo2Moduleが優先されます:

$this->install(new Foo1Module);
$this->override(new Foo2Module);

コンテキスト文字列での優先順位

コンテキストモジュールは逆順(右から左)で処理されます。例えば、prod-hal-api-appの場合:

インストール順序: AppModule → ApiModule → HalModule → ProdModule

後からインストールされたモジュールが先のモジュールの束縛を上書きできます。つまり:

  • HalModuleAppModuleより優先
  • ProdModuleHalModuleより優先

組み込みコンテキスト(HalModuleなど)の束縛を上書きするカスタムコンテキストモジュールを作成する場合、コンテキスト文字列でそのコンテキストの左側に配置します。例えば、HalModuleRenderInterface束縛を上書きするには:

// コンテキスト: "prod-mycontext-hal-api-app"
// インストール順序: AppModule → ApiModule → HalModule → MycontextModule → ProdModule

DI

依存性の注入(Dependency Injection)とは、基本的にオブジェクトが必要とするオブジェクト(依存)を、オブジェクト自身に構築させるのではなく、オブジェクトに提供することです。

依存性の注入では、オブジェクトはそのコンストラクタで依存性を受け取ります。オブジェクトを構築するには、まずそのオブジェクトの依存関係を構築しますが、それぞれの依存を構築するためにはそのまた依存が必要、という繰り返しになります。つまり、オブジェクトを構築するにはオブジェクトグラフを構築する必要があるのです。

オブジェクトグラフとは?
オブジェクト指向のアプリケーションは相互に関係のある複雑なオブジェクト網を持ちます。オブジェクトはあるオブジェクトから所有されているか、他のオブジェクト(またはそのリファレンス)を含んでいるか、そのどちらかでお互いに接続されています。このオブジェクト網をオブジェクトグラフと呼びます。- Wikipedia (en)

オブジェクトグラフを手作業で構築すると、労力がかかり、ミスが発生しやすく、テストが困難になります。その代わりに、Dependency Injector(Ray.Di)がオブジェクトグラフを構築します。

Ray.DiはBEAR.Sundayで使用されているDIフレームワークで、Google Guiceに大きく影響を受けています。詳しくはRay.Diのマニュアルをご覧ください。


AOP

アスペクト指向プログラミングは、横断的関心事の問題を解決します。対象メソッドの前後に、任意の処理をインターセプターで織り込むことができます。 対象となるメソッドはビジネスロジックなどの本質的関心事のみに関心を払い、インターセプターはログや検証などの横断的関心事に関心を払います。

BEAR.SundayはAOP Allianceに準拠したアスペクト指向プログラミングをサポートします。

インターセプター

インターセプターのinvokeメソッドでは$invocationメソッド実行オブジェクトを受け取り、メソッドの前後に処理を加えます。これはインターセプト元メソッドを実行するためのオブジェクトです。前後にログやトランザクションなどの横断的処理を記述します。

use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class MyInterceptor implements MethodInterceptor
{
    public function invoke(MethodInvocation $invocation)
    {
        // メソッド実行前の処理
        // ...

        // メソッド実行
        $result = $invocation->proceed();

        // メソッド実行後の処理
        // ...

        return $result;
    }
}

束縛

モジュールで対象となるクラスとメソッドをMatcherで”検索”して、マッチするメソッドにインターセプターを束縛します。

$this->bindInterceptor(
    $this->matcher->any(),                   // どのクラスでも
    $this->matcher->startsWith('delete'),    // "delete"で始まるメソッド名のメソッドには
    [Logger::class]                          // Loggerインターセプターを束縛
);

$this->bindInterceptor(
    $this->matcher->subclassesOf(AdminPage::class),  // AdminPageの継承または実装クラスの
    $this->matcher->annotatedWith(Auth::class),      // @Authアノテーションがアノテートされているメソッドには
    [AdminAuthentication::class]                     // AdminAuthenticationインターセプターを束縛
);

Matcherでは以下のような指定も可能です:

インターセプターに渡されるMethodInvocationでは、対象のメソッド実行に関連するオブジェクトやメソッド、引数にアクセスすることができます。

リフレクションのメソッドでアノテーションを取得することができます。

$method = $invocation->getMethod();
$class = $invocation->getMethod()->getDeclaringClass();
  • $method->getAnnotations() - メソッドアノテーションの取得
  • $method->getAnnotation($name)
  • $class->getAnnotations() - クラスアノテーションの取得
  • $class->getAnnotation($name)

カスタムマッチャー

独自のカスタムマッチャーを作成するには、AbstractMatchermatchesClassmatchesMethodを実装したクラスを作成します。

containsマッチャーを作成するには、2つのメソッドを持つクラスを提供する必要があります。 1つはクラスのマッチを行うmatchesClassメソッド、もう1つはメソッドのマッチを行うmatchesMethodメソッドです。いずれもマッチしたかどうかをboolで返します。

use Ray\Aop\AbstractMatcher;

/**
 * 特定の文字列が含まれているか
 */
class ContainsMatcher extends AbstractMatcher
{
    /**
     * {@inheritdoc}
     */
    public function matchesClass(\ReflectionClass $class, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($class->name, $contains) !== false);
    }

    /**
     * {@inheritdoc}
     */
    public function matchesMethod(\ReflectionMethod $method, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($method->name, $contains) !== false);
    }
}

モジュール

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        $this->bindInterceptor(
            $this->matcher->any(),
            new ContainsMatcher('user'), // 'user'がメソッド名に含まれているか
            [UserLogger::class]
        );
    }
};

リソース

BEAR.SundayアプリケーションはRESTfulなリソースの集合です。

サービスとしてのオブジェクト

ResourceObjectはHTTPのメソッドがPHPのメソッドにマップされたリソースのサービスのためのオブジェクト(Object-as-a-service)です。ステートレスリクエストから、リソースの状態がリソース表現として生成され、クライアントに転送されます。(Representational State Transfer

以下は、ResourceObjectの例です。

class Index extends ResourceObject
{
    public $code = 200;
    public $headers = [];

    public function onGet(int $a, int $b): static
    {
        $this->body = [
            'sum' => $a + $b  // $_GET['a'] + $_GET['b']
        ];

        return $this;
    }
}
class Todo extends ResourceObject
{
    public function onPost(string $id, string $todo): static
    {
        $this->code = 201; // ステータスコード
        $this->headers = [ // ヘッダー
            'Location' => '/todo/new_id'
        ];

        return $this;
    }
}

PHPのリソースクラスはWebのURIと同じようなpage://self/indexなどのURIを持ち、HTTPのメソッドに準じたonGetonPostなどのonメソッドを持ちます。onメソッドで与えられたパラメーターから自身のリソース状態codeheadersbodyを決定し、$thisを返します。

URI

URIはPHPのクラスにマップされています。アプリケーションではクラス名の代わりにURIを使ってリソースにアクセスします。

URI Class
page://self/ Koriym\Todo\Resource\Page\Index
page://self/index Koriym\Todo\Resource\Page\Index
app://self/blog/posts?id=3 Koriym\Todo\Resource\App\Blog\Posts
  • indexは省略可能です。

スキーマ

pageは外部公開するパブリックなリソース、appは外部からアクセスのできないプライベートなリソースです。Webやコンソールなどの外部からのリソースリクエストを受け取ったpageリソースは、appリソースをリクエストしてリソース状態を決定します。17

メソッド

リソースはHTTPのメソッドに対応した6つのメソッドでアクセスすることができます。18

GET

特定のリソースの表現をリクエストします。このメソッドはリソースの状態を変更することのない安全なメソッドです。

POST

POSTメソッドは、リクエストに含まれる表現の処理を要求します。例えば、対象のURIに新しいリソースを追加することや、既存のリソースに表現を追加することなどです。PUTと違ってリクエストには冪等性がなく、連続した複数回の実行は同じ結果になりません。

PUT

リクエストしたURIでリソースをリクエストのペイロードで置き換えます。対象のリソースが存在しない場合には作成します。 POSTと違って冪等性があります。

DELETE

特定のリソースを削除します。冪等性があります。

PATCH

リソースを部分的に変更します。冪等性は保証されません。19

OPTIONS

リソースのリクエストに必要なパラメーターとレスポンスに関する情報を取得します。GETと同じように安全です。20

メソッドの特性一覧

メソッド 安全性 冪等性 キャッシュ
GET あり あり 可能
POST なし なし 不可
PUT なし あり 不可
PATCH なし なし 不可
DELETE なし あり 不可
OPTIONS あり あり 不可

パラメーター

レスポンスメソッドの引数には、変数名に対応したリクエストの値が渡されます。

class Index extends ResourceObject
{
    // $_GET['id']が$idに
    public function onGet(int $id): static
    {
    }

    // $_POST['name']が$nameに
    public function onPost(string $name): static
    {
    }

その他のメソッドや、Cookieなどの外部変数をパラメーターに渡す方法はリソースパラメーターをご覧ください。

レンダリングと転送

ResourceObjectのリクエストメソッドではリソースの表現について関心を持ちません。インジェクトされたレンダラーがリソースの表現を生成し、レスポンダーが出力します。詳しくはレンダリングと転送をご覧ください。

クライアント

リソースクライアントを使用して他のリソースをリクエストします。以下のリクエストはapp://self/blog/postsリソースに?id=1というクエリーでリクエストを実行します。

use BEAR\Sunday\Inject\ResourceInject;

class Index extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    ){}

    public function onGet(): static
    {
        $this->body = [
            'posts' => $this->resource->get('app://self/blog/posts', ['id' => 1])
        ];
    }
}

この他にも以下の歴史的表記があります。

// PHP 5.x and up
$posts = $this->resource->get->uri('app://self/posts')->withQuery(['id' => 1])->eager->request();
// PHP 7.x and up
$posts = $this->resource->get->uri('app://self/posts')(['id' => 1]);
// getは省略可
$posts = $this->resource->uri('app://self/posts')(['id' => 1]);

遅延評価

これまでの例はリクエストをすぐに行うeagerリクエストですが、リクエスト結果ではなくリクエストを生成し、実行を遅延することもできます。

$request = $this->resource->get('app://self/posts'); // callable
$posts = $request(['id' => 1]);

このリクエストをテンプレートやリソースに埋め込むと、遅延評価されます。つまり評価されない時はリクエストは行われず、実行コストがかかりません。

$this->body = [
    'lazy' => $this->resource->get('app://self/posts')->withQuery(['id' => 3])->request()
];

キャッシュ

通常のTTLキャッシュと共に、RESTのクライアントキャッシュや、CDNを含めた高度な部分キャッシュ(ドーナッツキャッシュ)をサポートします。詳しくはキャッシュをご覧ください。また、従来の@Cacheableアノテーションに関しては以前のリソース(v1)ドキュメントをご覧ください。

リンク

重要なREST制約の1つにリソースのリンクがあります。ResourceObjectは内部リンク、外部リンクの双方をサポートします。詳しくはリソースリンクをご覧ください。

BEAR.Resource

BEAR.Sundayのリソースオブジェクトの機能は独立したパッケージで単体使用もできます。BEAR.ResourceREADMEもご覧ください。



リソースパラメーター

基本

ResourceObjectが必要とするHTTPリクエストやCookieなどのWebランタイムの値は、メソッドの引数に直接渡されます。HTTPリクエストではonGetonPostメソッドの引数にはそれぞれ$_GET$_POSTが変数名に応じて渡されます。

例えば下記の$id$_GET['id']が渡されます。入力がHTTPの場合、文字列として渡された引数は指定した型にキャストされます。

class Index extends ResourceObject
{
    public function onGet(int $id): static
    {
        // ....

パラメーターの型

スカラーパラメーター

HTTPで渡されるパラメーターは全て文字列ですが、intなど文字列以外の型を指定するとキャストされます。

配列パラメーター

パラメーターはネストされたデータ 3 でも構いません。JSONやネストされたクエリ文字列で送信されたデータは配列で受け取ることができます。

class Index extends ResourceObject
{
    public function onPost(array $user): static
    {
        $name = $user['name']; // bear

クラスパラメーター

パラメータ専用のInputクラスで受け取ることもできます。

class Index extends ResourceObject
{
    public function onPost(User $user): static
    {
        $name = $user->name; // bear

Inputクラスは事前にパラメーターをpublicプロパティにしたものを定義しておきます。

<?php
namespace Vendor\App\Input;

final class User
{
    public int $id;
    public string $name;
}

この時、コンストラクタがあるとコールされます。21

<?php
namespace Vendor\App\Input;

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name
    ) {
    }
}

ネームスペースは任意です。Inputクラスでは入力データをまとめたり検証したりするメソッドを実装することができます。

Ray.InputQueryとの統合

#[Input]属性を使って、Ray.InputQueryライブラリによる型安全な入力オブジェクトの生成を利用できます。

use Ray\InputQuery\Attribute\Input;

class Index extends ResourceObject
{
    public function onPost(#[Input] ArticleInput $article): static
    {
        $this->body = [
            'title' => $article->title,
            'author' => $article->author->name
        ];
        return $this;
    }
}

#[Input]属性付きのパラメーターには、フラットなクエリデータから自動的に構造化されたオブジェクトが生成されて渡されます。

use Ray\InputQuery\Attribute\Input;

final class ArticleInput
{
    public function __construct(
        #[Input] public readonly string $title,
        #[Input] public readonly AuthorInput $author
    ) {}
}

final class AuthorInput  
{
    public function __construct(
        #[Input] public readonly string $name,
        #[Input] public readonly string $email
    ) {}
}

この場合、title=Hello&authorName=John&authorEmail=john@example.comのようなフラットなデータからネストしたオブジェクト構造が自動生成されます。

配列データも扱うことができます。

シンプルな配列

final class TagsInput
{
    public function __construct(
        #[Input] public readonly string $title,
        #[Input] public readonly array $tags
    ) {}
}
class Index extends ResourceObject
{
    public function onPost(#[Input] TagsInput $input): static
    {
        // tags[]=php&tags[]=web&title=Hello の場合
        // $input->tags = ['php', 'web']
        // $input->title = 'Hello'

オブジェクト配列

itemパラメーターを使用して、配列の各要素を指定したInputクラスのオブジェクトとして生成できます。

use Ray\InputQuery\Attribute\Input;

final class UserInput
{
    public function __construct(
        #[Input] public readonly string $id,
        #[Input] public readonly string $name
    ) {}
}

class Index extends ResourceObject
{
    public function onPost(
        #[Input(item: UserInput::class)] array $users
    ): static {
        foreach ($users as $user) {
            echo $user->name; // 各要素はUserInputインスタンス
        }
    }
}

この場合、以下のような形式のデータから配列を生成します:

// users[0][id]=1&users[0][name]=John&users[1][id]=2&users[1][name]=Jane
$data = [
    'users' => [
        ['id' => '1', 'name' => 'John'],
        ['id' => '2', 'name' => 'Jane']
    ]
];
  • パラメーターに#[Input]属性がある場合:Ray.InputQueryでオブジェクト生成
  • パラメーターに#[Input]属性がない場合:従来通りの依存性注入

ファイルアップロード

#[InputFile]属性を使って、HTMLフォームとPHPコードが直接マッピングされた型安全なファイルアップロード処理を実装できます。フォームのname属性がそのままメソッドの引数名に対応し、コードがそのまま仕様となり、可読性も向上します。

単一ファイルアップロード

HTMLフォーム:

<form method="post" enctype="multipart/form-data" action="/image-upload">
    <input type="file" name="image" accept="image/*" required>
    <input type="text" name="title" placeholder="画像のタイトル">
    <button type="submit">アップロード</button>
</form>

対応するリソースメソッド:

use Ray\InputQuery\Attribute\InputFile;
use Koriym\FileUpload\FileUpload;
use Koriym\FileUpload\ErrorFileUpload;

class ImageUpload extends ResourceObject
{
    public function onPost(
        #[InputFile(
            maxSize: 1024 * 1024, // 1MB
            allowedTypes: ['image/jpeg', 'image/png', 'image/svg+xml'],
            allowedExtensions: ['jpg', 'jpeg', 'png', 'svg'],
            required: false  // ファイルアップロードをオプショナルにする
        )]
        FileUpload|ErrorFileUpload|null $image = null, // nullの場合はファイル未指定
        string $title = 'Default Title'
    ): static {
        if ($image === null) {
            // ファイルが指定されていない場合の処理
            $this->body = ['title' => $title, 'image' => null];
            return $this;
        }
        
        if ($image instanceof ErrorFileUpload) {
            // バリデーションエラーの場合の処理
            $this->code = 400;
            $this->body = [
                'error' => true,
                'message' => $image->message
            ];
            return $this;
        }

        // 正常なファイルアップロードの処理 - ファイルを保存先ディレクトリに移動
        $uploadDir = '/var/www/uploads/';
        $originalName = basename($image->name);
        $extension = pathinfo($originalName, PATHINFO_EXTENSION);
        $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', pathinfo($originalName, PATHINFO_FILENAME));
        $filename = bin2hex(random_bytes(8)) . '_' . uniqid() . '_' . $safeName . '.' . $extension;
        $image->move($uploadDir . $filename);

        $this->body = [
            'success' => true,
            'filename' => $image->name,
            'savedAs' => $filename,
            'size' => $image->size,
            'type' => $image->type,
            'title' => $title
        ];
        return $this;
    }
}

複数ファイルアップロード

HTMLフォーム:

<form method="post" enctype="multipart/form-data" action="/gallery-upload">
    <input type="file" name="images[]" multiple accept="image/*" required>
    <input type="text" name="galleryName" placeholder="ギャラリー名">
    <button type="submit">アップロード</button>
</form>

対応するリソースメソッド:

class GalleryUpload extends ResourceObject
{
    /**
     * @param array<FileUpload|ErrorFileUpload> $images
     */
    public function onPost(
        #[InputFile(
            maxSize: 2 * 1024 * 1024, // 2MB
            allowedTypes: ['image/jpeg', 'image/png', 'image/svg+xml']
        )]
        array $images, // 配列で複数ファイルを受け取る
        string $galleryName = 'Default Gallery'
    ): static {
        $uploadDir = '/var/www/uploads/gallery/';
        $results = [];
        $hasError = false;

        foreach ($images as $index => $image) {
            if ($image instanceof ErrorFileUpload) {
                $hasError = true;
                $results[] = [
                    'index' => $index,
                    'error' => true,
                    'message' => $image->message
                ];
                continue;
            }

            // ファイルを保存
            $originalName = basename($image->name);
            $extension = pathinfo($originalName, PATHINFO_EXTENSION);
            $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', pathinfo($originalName, PATHINFO_FILENAME));
            $filename = bin2hex(random_bytes(8)) . '_' . uniqid() . '_' . $safeName . '.' . $extension;
            $image->move($uploadDir . $filename);

            $results[] = [
                'index' => $index,
                'success' => true,
                'filename' => $image->name,
                'savedAs' => $filename,
                'size' => $image->size,
                'type' => $image->type
            ];
        }

        $this->code = $hasError ? 207 : 200; // 207 Multi-Status
        $this->body = [
            'galleryName' => $galleryName,
            'files' => $results,
            'total' => count($images),
            'hasErrors' => $hasError
        ];
        return $this;
    }
}

ファイルアップロードのテスト

ファイルアップロード機能は簡単にテストできます:

use Koriym\FileUpload\FileUpload;
use Koriym\FileUpload\ErrorFileUpload;

class FileUploadTest extends TestCase
{
    public function testSuccessfulFileUpload(): void
    {
        // 実際のファイルからFileUploadオブジェクトを作成
        $fileUpload = FileUpload::fromFile(__DIR__ . '/fixtures/test.jpg');
        
        $resource = $this->getResource();
        $result = $resource->post('app://self/image-upload', [
            'image' => $fileUpload,
            'title' => 'Test Image'
        ]);
        
        $this->assertSame(200, $result->code);
        $this->assertTrue($result->body['success']);
        $this->assertSame('test.jpg', $result->body['filename']);
    }
    
    public function testFileUploadValidationError(): void
    {
        // バリデーションエラーをシミュレート
        $errorFileUpload = new ErrorFileUpload([
            'name' => 'large.jpg',
            'type' => 'image/jpeg',
            'size' => 5 * 1024 * 1024, // 5MB - サイズ制限超過
            'tmp_name' => '/tmp/test',
            'error' => UPLOAD_ERR_OK
        ], 'File size exceeds maximum allowed size');
        
        $resource = $this->getResource();
        $result = $resource->post('app://self/image-upload', [
            'image' => $errorFileUpload
        ]);
        
        $this->assertSame(400, $result->code);
        $this->assertTrue($result->body['error']);
        $this->assertStringContainsString('exceeds maximum allowed size', $result->body['message']);
    }
    
    public function testMultipleFileUpload(): void
    {
        // 複数ファイルのテスト
        $file1 = FileUpload::fromFile(__DIR__ . '/fixtures/image1.jpg');
        $file2 = FileUpload::fromFile(__DIR__ . '/fixtures/image2.png');
        
        $resource = $this->getResource();
        $result = $resource->post('app://self/gallery-upload', [
            'images' => [$file1, $file2],
            'galleryName' => 'Test Gallery'
        ]);
        
        $this->assertSame(200, $result->code);
        $this->assertSame(2, $result->body['total']);
        $this->assertCount(2, $result->body['files']);
    }
}

#[InputFile]属性により、HTMLフォームのinput要素とPHPメソッドの引数が直接対応し、型安全で直感的なファイルアップロード処理が実現できます。配列対応により複数ファイルアップロードも簡単に実装でき、テストも容易に行えます。 詳細はRay.InputQueryのドキュメントを参照してください。

列挙型パラメーター

PHP8.1の列挙型を指定して取り得る値を制限することができます。

enum IceCreamId: int
{
    case VANILLA = 1;
    case PISTACHIO = 2;
}
class Index extends ResourceObject
{
    public function onGet(IceCreamId $iceCreamId): static
    {
        $id = $iceCreamId->value; // 1 or 2

上記の場合、1か2以外が渡されるとParameterInvalidEnumExceptionが発生します。

Webコンテキスト束縛

$_GET$_COOKIEなどのPHPのスーパーグローバルの値をメソッド内で取得するのではなく、メソッドの引数に束縛することができます。

use Ray\WebContextParam\Annotation\QueryParam;

class News extends ResourceObject
{
    public function foo(
        #[QueryParam('id')] string $id
    ): static {
        // $id = $_GET['id'];

その他$_ENV$_POST$_SERVERの値を束縛することができます。

use Ray\WebContextParam\Annotation\QueryParam;
use Ray\WebContextParam\Annotation\CookieParam;
use Ray\WebContextParam\Annotation\EnvParam;
use Ray\WebContextParam\Annotation\FormParam;
use Ray\WebContextParam\Annotation\ServerParam;

class News extends ResourceObject
{
    public function onGet(
        #[QueryParam('id')] string $userId,            // $_GET['id']
        #[CookieParam('id')] string $tokenId = "0000", // $_COOKIE['id'] or "0000" when unset
        #[EnvParam('app_mode')] string $app_mode,      // $_ENV['app_mode']
        #[FormParam('token')] string $token,           // $_POST['token']
        #[ServerParam('SERVER_NAME')] string $server   // $_SERVER['SERVER_NAME']
    ): static {

クライアントが値を指定した時は指定した値が優先され、束縛した値は無効になります。テストの時に便利です。

リソース束縛

#[ResourceParam]アノテーションを使えば他のリソースリクエストの結果をメソッドの引数に束縛できます。

use BEAR\Resource\Annotation\ResourceParam;

class News extends ResourceObject
{
    public function onGet(
        #[ResourceParam('app://self//login#nickname')] string $name
    ): static {

この例ではメソッドが呼ばれるとloginリソースにgetリクエストを行い、$body['nickname']$nameで受け取ります。

コンテントネゴシエーション

HTTPリクエストのcontent-typeヘッダーがサポートされています。application/jsonx-www-form-urlencodedメディアタイプを判別してパラメーターに値が渡されます。22


リソースリンク

リソースは他のリソースをリンクすることができます。リンクには外部のリソースをリンクする外部リンク23と、リソース自身に他のリソースを埋め込む内部リンク24の2種類があります。

外部リンク

リンクをリンクの名前のrel(リレーション)とhrefで指定します。hrefには正規のURIの他にRFC6570 URIテンプレートを指定することができます。

    #[Link(rel: 'profile', href: '/profile{?id}')]
    public function onGet($id): static
    {
        $this->body = [
            'id' => 10
        ];
        return $this;
    }

上記の例ではhrefで表されていて、$body['id']{?id}にアサインされます。HALフォーマットでの出力は以下のようになります。

{
    "id": 10,
    "_links": {
        "self": {
            "href": "/test"
        },
        "profile": {
            "href": "/profile?id=10"
        }
    }
}

内部リンク

リソースは別のリソースを埋め込むことができます。#[Embed]srcでリソースを指定します。内部リンクされたリソースも他のリソースを内部リンクしているかもしれません。その場合また内部リンクのリソースが必要で、それが再帰的に繰り返されリソースグラフが得られます。

クライアントはリソースを何度もフェッチすることなく目的とするリソース群を一度に取得できます。25 例えば顧客リソースと商品リソースをそれぞれ呼び出す代わりに、注文リソースで両者を埋め込みます。

use BEAR\Resource\Annotation\Embed;

class News extends ResourceObject
{
    #[Embed(rel: 'sports', src: '/news/sports')]
    #[Embed(rel: 'weather', src: '/news/weather')]
    public function onGet(): static

埋め込まれるのはリソースリクエストです。レンダリングの時に実行されますが、その前にaddQuery()メソッドで引数を加えたりwithQuery()で引数を置き換えることができます。srcにはURI templateが利用でき、リクエストメソッドの引数がバインドされます(外部リンクと違って$bodyではありません)。

use BEAR\Resource\Annotation\Embed;

class News extends ResourceObject
{
    #[Embed(rel: 'website', src: '/website{?id}')]
    public function onGet(string $id): static
    {
        // ...
        $this->body['website']->addQuery(['title' => $title]); // 引数追加

セルフリンク

#[Embed]でリレーションを_selfとしてリンクすると、リンク先のリソース状態を自身のリソース状態にコピーします。

namespace MyVendor\Weekday\Resource\Page;

class Weekday extends ResourceObject
{
    #[Embed(rel: '_self', src: 'app://self/weekday{?year,month,day}')]
    public function onGet(string $id): static
    {

この例ではPageリソースがAppリソースのweekdayリソースの状態を自身にコピーしています。

HALでの内部リンク

HALレンダラーでは_embeddedとして扱われます。

リンクリクエスト

クライアントはハイパーリンクで接続されているリソースをリンクすることができます。

$blog = $this
    ->resource
    ->get
    ->uri('app://self/user')
    ->withQuery(['id' => 1])
    ->linkSelf("blog")
    ->eager
    ->request()
    ->body;

リンクは3種類あります。$relをキーにして元のリソースのbodyにリンク先のリソースが埋め込まれます。

  • linkSelf($rel) - リンク先と入れ替わります。
  • linkNew($rel) - リンク先のリソースがリンク元のリソースに追加されます
  • linkCrawl($rel) - リンクをクロールしてリソースグラフを作成します。

クロール

クロールはリスト(配列)になっているリソースを順番にリンクを辿り、複雑なリソースグラフを構成することができます。クローラーがWebページをクロールするように、リソースクライアントはハイパーリンクをクロールしてリソースグラフを生成します。

クロール例

author, post, meta, tag, tag/nameがそれぞれ関連づけられているリソースグラフを考えてみます。このリソースグラフに post-tree という名前を付け、それぞれのリソースの#[Link]アトリビュートでハイパーリファレンス href を指定します。

最初に起点となるauthorリソースにはpostリソースへのハイパーリンクがあります。1:nの関係です。

#[Link(crawl: "post-tree", rel: "post", href: "app://self/post?author_id={id}")]
public function onGet($id = null)

postリソースにはmetaリソースとtagリソースのハイパーリンクがあります。1:nの関係です。

#[Link(crawl: "post-tree", rel: "meta", href: "app://self/meta?post_id={id}")]
#[Link(crawl: "post-tree", rel: "tag", href: "app://self/tag?post_id={id}")]
public function onGet($author_id)
{

tagリソースはIDだけでそのIDに対応するtag/nameリソースへのハイパーリンクがあります。1:1の関係です。

#[Link(crawl: "post-tree", rel: "tag_name", href: "app://self/tag/name?tag_id={tag_id}")]
public function onGet($post_id)

それぞれが接続されました。クロール名を指定してリクエストします。

$graph = $resource
    ->get
    ->uri('app://self/marshal/author')
    ->linkCrawl('post-tree')
    ->eager
    ->request();

リソースクライアントは#[Link]アトリビュートに指定されたクロール名を発見するとそのrel名でリソースを接続してリソースグラフを作成します。

var_export($graph->body);
array (
    0 =>
    array (
        'name' => 'Athos',
        'post' =>
        array (
            0 =>
            array (
                'author_id' => '1',
                'body' => 'Anna post #1',
                'meta' =>
                array (
                    0 =>
                    array (
                        'data' => 'meta 1',
                    ),
                ),
                'tag' =>
                array (
                    0 =>
                    array (
                        'tag_name' =>
                        array (
                            0 =>
                            array (
                                'name' => 'zim',
                            ),
                        ),
                    ),
                    // ...

DataLoader Beta

bear/resource:1.x-devで利用可能

リソースをクロールする際、各子リソースが個別のクエリを発行するためN+1問題が発生します。DataLoaderは複数のリソースリクエストを1つの効率的なクエリにバッチ処理することでこの問題を解決します。

N+1問題

リクエスト: GET /author/1 with linkCrawl('post-tree')

[クエリ 1] SELECT * FROM author WHERE id = 1
  └─ 著者は3つの投稿を持っている

[クエリ 2] SELECT * FROM post WHERE author_id = 1
  └─ 3つの投稿を返す (id: 10, 11, 12)

[クエリ 3] SELECT * FROM meta WHERE post_id = 10  ← N+1の始まり
[クエリ 4] SELECT * FROM meta WHERE post_id = 11
[クエリ 5] SELECT * FROM meta WHERE post_id = 12

合計: 5クエリ (データ量に比例して増加!)

DataLoader使用時

[クエリ 1] SELECT * FROM author WHERE id = 1
[クエリ 2] SELECT * FROM post WHERE author_id = 1
[クエリ 3] SELECT * FROM meta WHERE post_id IN (10, 11, 12)  ← バッチ化!

合計: 3クエリ (データ量に関係なく一定)

使用方法

#[Link]アトリビュートにdataLoaderパラメータを追加します:

#[Link(crawl: 'post-tree', rel: 'meta', href: 'app://self/meta{?post_id}', dataLoader: MetaDataLoader::class)]
public function onGet($author_id)
{

DataLoaderの実装

DataLoaderInterfaceを実装してクエリをバッチ処理します:

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Resource\DataLoader\DataLoaderInterface;

class MetaDataLoader implements DataLoaderInterface
{
    public function __construct(
        private ExtendedPdoInterface $pdo
    ){}

    /**
     * @param list<array<string, mixed>> $queries
     * @return list<array<string, mixed>>
     */
    public function __invoke(array $queries): array
    {
        $postIds = array_column($queries, 'post_id');

        // バッチクエリ: SELECT * FROM meta WHERE post_id IN (...)
        return $this->pdo->fetchAll(
            'SELECT * FROM meta WHERE post_id IN (:post_ids)',
            ['post_ids' => $postIds]
        );
    }
}

ここでは説明のためにSQLを直接記述していますが、Ray.MediaQueryを使った実装も可能です。

キーの推論

結果のマッチングに使用するキーは、URIテンプレートから自動的に推論されます:

URIテンプレート 推論されるキー
{?post_id} post_id
post_id={id} post_id
{?post_id,locale} post_id, locale

返される行には、適切な分配のためにキーカラムが含まれている必要があります。

複数キー

複数のキーパラメータの場合、クエリですべてのキーを使用します:

// URIテンプレート: app://self/translation{?post_id,locale}
// $queries: [['post_id' => '1', 'locale' => 'en'], ['post_id' => '1', 'locale' => 'ja']]

public function __invoke(array $queries): array
{
    // 両方のキーを使用してクエリを構築
    $sql = "SELECT * FROM translation WHERE (post_id, locale) IN (...)";
    // ...
}

レンダリングと転送

Resource object internal structure

ResourceObjectのリクエストメソッドではリソースの表現について関心を持ちません。コンテキストに応じて注入されたレンダラーがリソースの表現を生成します。同じアプリケーションがコンテキストを変えるだけでHTMLで出力されたり、JSONで出力されたりします。

遅延評価

レンダリングはリソースが文字列評価された時に行われます。

$weekday = $api->resource->get('app://self/weekday', ['year' => 2000, 'month'=> 1, 'day'=> 1]);
var_dump($weekday->body);
//array(1) {
//    ["weekday"]=>
//    string(3) "Sat"
//}

echo $weekday;
//{
//    "weekday": "Sat",
//    "_links": {
//        "self": {
//            "href": "/weekday/2000/1/1"
//        }
//    }
//}

レンダラー

それぞれのResourceObjectはコンテキストによって指定されたその表現のためのレンダラーが注入されています。リソース特有のレンダリングを行う時はrendererプロパティを注入またはセットします。

例)デフォルトで用意されているJSON表現のレンダラーをスクラッチで書くと:

class Index extends ResourceObject
{
    #[Inject]
    public function setRenderer(RenderInterface $renderer)
    {
        $this->renderer = new class implements RenderInterface {
            public function render(ResourceObject $ro)
            {
                $ro->headers['content-type'] = 'application/json;';
                $ro->view = json_encode($ro->body);
                return $ro->view;
            }
        };
    }
}

転送

ルートオブジェクト$appにインジェクトされたリソース表現をクライアント(コンソールやWebクライアント)に転送します。通常、出力はheader関数やechoで行われますが、巨大なデータなどにはストリーム転送が有効です。

リソース特有の転送を行う時はtransferメソッドをオーバーライドします。

public function transfer(TransferInterface $responder, array $server)
{
    $responder($this, $server);
}

リソースの自律性

リソースはリクエストによって自身のリソース状態を変更し、それを表現にして転送する機能を各クラスが持っています。


ルーター

ルーターはWebやコンソールなどの外部コンテキストのリソースリクエストを、BEAR.Sunday内部のリソースリクエストに変換します。

$request = $app->router->match($GLOBALS, $_SERVER);
echo (string) $request;
// get page://self/user?name=bear

Webルーター

デフォルトのWebルーターではHTTPリクエストのパス($_SERVER['REQUEST_URI'])に対応したリソースクラスにアクセスされます。例えば/indexのリクエストは{Vendor名}\{Project名}\Resource\Page\IndexクラスのHTTPメソッドに応じたPHPメソッドにアクセスされます。

Webルーターは規約ベースのルーターです。設定やスクリプトは必要ありません。

namespace MyVendor\MyProject\Resource\Page;

// page://self/index
class Index extends ResourceObject
{
    public function onGet(): static // GETリクエスト
    {
    }
}

CLIルーター

cliコンテキストではコンソールからの引数が外部入力になります。

php bin/page.php get /

BEAR.SundayアプリケーションはWebとCLIの双方で動作します。

複数の単語を使ったURI

ハイフンを使い複数の単語を使ったURIのパスはキャメルケースのクラス名を使います。例えば/wild-animalのリクエストはWildAnimalクラスにアクセスされます。

パラメーター

HTTPメソッドに対応して実行されるPHPメソッドの名前と渡される値は以下の通りです。

HTTPメソッド PHPメソッド 渡される値
GET onGet $_GET
POST onPost $_POST または 標準入力
PUT onPut ※標準入力
PATCH onPatch ※標準入力
DELETE onDelete ※標準入力

リクエストのメディアタイプは以下の2つが利用できます:

  • application/x-www-form-urlencoded // param1=one&param2=two
  • application/json // {“param1”: “one”, “param2”: “one”} (POSTの時は標準入力の値が使われます)

PHPマニュアルのPUT メソッドのサポートもご覧ください。

メソッドオーバーライド

HTTP PUT トラフィックやHTTP DELETE トラフィックを許可しないファイアウォールがあります。この制約に対応するため、次の2つの方法でこれらの要求を送ることができます:

  • X-HTTP-Method-Override - POSTリクエストのヘッダーフィールドを使用してPUTリクエストやDELETEリクエストを送る。
  • _method - URI パラメーターを使用する。例)POST /users?…&_method=PUT

Auraルーター

リクエストのパスをパラメーターとして受け取る場合はAura Routerを使用します。

composer require bear/aura-router-module ^2.0

ルータースクリプトのパスを指定してAuraRouterModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\Package\Provide\Router\AuraRouterModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
    }
}

キャッシュされているDIファイルを消去します。

rm -rf var/tmp/*

ルータースクリプト

ルータースクリプトではグローバルで渡されたMapオブジェクトに対してルートを設定します。ルーティングにメソッドを指定する必要はありません。1つ目の引数はルート名としてパス、2つ目の引数に名前付きトークンのプレイスフォルダーを含んだパスを指定します。

var/conf/aura.route.php

<?php
/* @var \Aura\Router\Map $map */
$map->route('/blog', '/blog/{id}');
$map->route('/blog/comment', '/blog/{id}/comment');
$map->route('/user', '/user/{name}')->tokens(['name' => '[a-z]+']);
  • 最初の行では/blog/bearとアクセスがあるとpage://self/blog?id=bearとしてアクセスされます。(BlogクラスのonGet($id)メソッドに$id=bearの値でコールされます。)
  • /blog/{id}/commentBlog\Commentクラスにルートされます。
  • token()はパラメーターを正規表現で制限するときに使用します。

優先ルーター

Auraルーターでルートされない場合は、Webルーターが使われます。つまりパスでパラメーターを渡すURIだけにルータースクリプトを用意すればOKです。

パラメーター

パスからパラメーターを取得するためにAuraルーターは様々な方法が用意されています。

カスタムマッチング

下のスクリプトは{date}が適切なフォーマットの時だけルートします。

$map->route('/calendar/from', '/calendar/from/{date}')
    ->tokens([
        'date' => function ($date, $route, $request) {
            try {
                new \DateTime($date);
                return true;
            } catch(\Exception $e) {
                return false;
            }
        }
    ]);

オプション

オプションのパラメーターを指定するためにはパスに{/attribute1,attribute2,attribute3}の表記を加えます。

例)

$map->route('archive', '/archive{/year,month,day}')
    ->tokens([
        'year' => '\d{4}',
        'month' => '\d{2}',
        'day' => '\d{2}',
    ]);

プレイスホルダーの内側に最初のスラッシュがあるのに注意してください。そうすると下のパスは全て’archive’にルートされパラメーターの値が付加されます。

  • /archive : ['year' => null, 'month' => null, 'day' => null]
  • /archive/1979 : ['year' => '1979', 'month' => null, 'day' => null]
  • /archive/1979/11 : ['year' => '1979', 'month' => '11', 'day' => null]
  • /archive/1979/11/07 : ['year' => '1979', 'month' => '11', 'day' => '07']

オプションパラメーターは並ぶ順にオプションです。つまり”month”なしで”day”を指定することはできません。

ワイルドカード

任意の長さのパスの末尾パラメーターとして格納したいときにはwildcard()メソッドを使います。

$map->route('wild', '/wild')
    ->wildcard('card');

スラッシュで区切られたパスの値が配列になりwildcard()で指定したパラメーターに格納されます。

  • /wild : ['card' => []]
  • /wild/foo : ['card' => ['foo']]
  • /wild/foo/bar : ['card' => ['foo', 'bar']]
  • /wild/foo/bar/baz : ['card' => ['foo', 'bar', 'baz']]

その他の高度なルートに関してはAura Routerのdefining-routesをご覧ください。

リバースルーティング

ルートの名前とパラメーターの値からURIを生成することができます。

use BEAR\Sunday\Extension\Router\RouterInterface;

class Index extends ResourceObject
{
    /**
     * @var RouterInterface
     */
    private $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    public function onGet(): static
    {
        $userLink = $this->router->generate('/user', ['name' => 'bear']);
        // '/user/bear'

リクエストメソッド

リクエストメソッドを指定する必要はありません。

リクエストヘッダー

通常リクエストヘッダーはAura.Routerに渡されていませんが、RequestHeaderModuleをインストールするとAura.Routerでヘッダーを使ったマッチングが可能になります。

$this->install(new RequestHeaderModule());

独自のルーター

コンポーネント:


プロダクション

BEAR.Sundayの既定のprod束縛をベースに、アプリケーション側で各デプロイ環境に応じたモジュールを追加・上書きしてカスタマイズします。

既定のProdModule

既定のprod束縛では、以下のインターフェイスが束縛されています。

  • エラーページ生成ファクトリー
  • PSRロガーインターフェース
  • ローカルキャッシュ
  • 分散キャッシュ

詳細はBEAR.PackageのProdModule.phpを参照してください。

アプリケーションのProdModule

既定のProdModuleに対してアプリケーションのProdModulesrc/Module/ProdModule.phpに設置してカスタマイズします。特にエラーページと分散キャッシュは重要です。

<?php
namespace MyVendor\Todo\Module;

use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\QueryRepository\CacheVersionModule;
use BEAR\Resource\Module\OptionsMethodModule;
use BEAR\Package\AbstractAppModule;

class ProdModule extends AbstractModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->install(new PackageProdModule);       // デフォルトのprod設定
        $this->override(new OptionsMethodModule);    // OPTIONSメソッドをプロダクションでも有効に
        $this->install(new CacheVersionModule('1')); // リソースキャッシュのバージョン指定
        
        // 独自のエラーページ
        $this->bind(ErrorPageFactoryInterface::class)->to(MyErrorPageFactory::class);
    }
}

キャッシュ

キャッシュには、ローカルキャッシュと複数のWebサーバー間で共有する分散キャッシュの2種類があります。どちらのキャッシュもデフォルトはPhpFileCacheです。

ローカルキャッシュ

ローカルキャッシュはデプロイ後に変更されないアノテーションなどのキャッシュに使われ、分散キャッシュはリソース状態の保存に使われます。

分散キャッシュ

2つ以上のWebサーバーでサービスを提供するには分散キャッシュの構成が必要です。代表的なキャッシュエンジンであるmemcachedRedis向けのモジュールが用意されています。

Memcached

<?php
namespace BEAR\HelloWorld\Module;

use BEAR\QueryRepository\StorageMemcachedModule;
use BEAR\Resource\Module\ProdLoggerModule;
use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\Package\AbstractAppModule;
use Ray\Di\Scope;

class ProdModule extends AbstractModule
{
    protected function configure()
    {
        // memcache
        // {host}:{port}:{weight},...
        $memcachedServers = 'mem1.domain.com:11211:33,mem2.domain.com:11211:67';
        $this->install(new StorageMemcachedModule($memcachedServers));
        
        // Prodロガーのインストール
        $this->install(new ProdLoggerModule);
        
        // デフォルトのProdModuleのインストール
        $this->install(new PackageProdModule);
    }
}

Redis

// redis
$redisServer = 'localhost:6379'; // {host}:{port}
$this->install(new StorageRedisModule($redisServer));

リソースの状態保存は単にTTLによる時間更新のキャッシュとの他に、TTL時間では消えない永続的なストレージとして(CQRS)の運用も可能です。その場合にはRedisで永続処理を行うか、Cassandraなどの他KVSのストレージアダプターを独自で用意する必要があります。

キャッシュ時間の指定

デフォルトのTTLを変更する場合StorageExpiryModuleをインストールします。

// Cache time
$short = 60;
$medium = 3600;
$long = 24 * 3600;
$this->install(new StorageExpiryModule($short, $medium, $long));

キャッシュバージョンの指定

リソースのスキーマが変わり、互換性が失われる時にはキャッシュバージョンを変更します。特にTTL時間で消えないCQRS運用の場合に重要です。

$this->install(new CacheVersionModule($cacheVersion));

ディプロイの度にリソースキャッシュを破棄するためには$cacheVersionに時刻や乱数の値を割り当てると変更が不要で便利です。

ログ

ProdLoggerModuleはプロダクション用のリソース実行ログモジュールです。インストールするとGET以外のリクエストをPsr\Log\LoggerInterfaceにバインドされているロガーでログします。

特定のリソースや特定の状態でログしたい場合は、カスタムのログをBEAR\Resource\LoggerInterfaceにバインドします。

use BEAR\Resource\LoggerInterface;
use Ray\Di\AbstractModule;

final class MyProdLoggerModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->bind(LoggerInterface::class)->to(MyProdLogger::class);
    }
}

LoggerInterface__invokeメソッドでリソースのURIとリソース状態がResourceObjectオブジェクトとして渡されるのでその内容で必要な部分をログします。作成には既存の実装 ProdLoggerを参考にしてください。

デプロイ

⚠️ 上書き更新を避ける

サーバーにディプロイする場合

  • 駆動中のプロジェクトフォルダをrsyncなどで上書きするのはキャッシュやオンデマンドで生成されるファイルの不一致や、高負荷のサイトではキャパシティを超えるリスクがあります。安全のために別のディレクトリでセットアップを行い、そのセットアップが成功すれば切り替えるようにします。
  • DeployerBEAR.Sundayレシピを利用することができます。

クラウドにディプロイする時には

  • コンパイルが成功すると0、依存関係の問題を見つけるとコンパイラはexitコード1を出力します。それを利用してCIにコンパイルを組み込むことを推奨します。

コンパイル

推奨セットアップを行う際にvendor/bin/bear.compileスクリプトを使ってプロジェクトをウォームアップすることができます。コンパイルスクリプトはDI/AOP用の動的に作成されるファイルやアノテーションなどの静的なキャッシュファイルを全て事前に作成し、最適化されたautoload.phpファイルとpreload.phpを出力します。

  • コンパイルをすれば全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出る可能性が極めて低くなります。
  • .envには含まれた内容はPHPファイルに取り込まれるのでコンパイル後に.envを消去可能です。コンテントネゴシエーションを行う場合など(例:api-app, html-app)1つのアプリケーションで複数コンテキストのコンパイルを行うときにはファイルの退避が必要です。
mv autoload.php api.autoload.php

composer.jsonを編集してcomposer compileの内容を変更します。

autoload.php

{project_path}/autoload.phpに最適化されたautoload.phpファイルが出力されます。composer dump-autoload --optimizeで出力されるvendor/autoload.phpよりずっと高速です。

注意:preload.phpを利用する場合、ほとんどの利用クラスが読み込まれた状態で起動するのでコンパイルされたautoload.phpは不要です。composerが生成するvendor/autoload.phpをご利用ください。

preload.php

{project_path}/preload.phpに最適化されたpreload.phpファイルが出力されます。preloadを有効にするためにはphp.iniでopcache.preloadopcache.preload_userを指定する必要があります。

PHP 7.4でサポートされた機能ですが、7.4初期のバージョンでは不安定です。7.4.4以上の最新版を使いましょう。

例)

opcache.preload=/path/to/project/preload.php
opcache.preload_user=www-data

Note: パフォーマンスベンチマークはbenchmarkを参考にしてください。(2020年)

.compile.php

実環境ではないと生成ができないクラス(例えば認証が成功しないとインジェクトが完了しないResourceObject)がある場合には、コンパイル時にのみ読み込まれるダミークラス読み込みをルートの.compile.phpに記述することによってコンパイルをすることができます。

.compile.php

例) 例えばコンストラクタで認証が得られない場合に例外を出してしまうAuthProviderがあったとしたら以下のように空のクラスを作っておいて、.compile.phpに読み込ませます。

/tests/Null/AuthProvider.php

<?php
class AuthProvider 
{  // newをするだけのdummyなので実装は不要
}

.compile.php

<?php
require __DIR__ . '/tests/Null/AuthProvider.php'; // 常に生成可能なNullオブジェクト
$_SERVER[__REQUIRED_KEY__] = 'fake'; // 特定の環境変数がないとエラーになる場合

こうする事で例外を避けてコンパイルを行うことができます。他にもSymfonyのキャッシュコンポーネントはコンストラクタでキャッシュエンジンに接続を行うので、コンパイル時にはこのようにダミーのアダプターを読み込むようにしておくと良いでしょう。

tests/Null/RedisAdapter.php

namespace Ray\PsrCacheModule;

use Ray\Di\ProviderInterface;
use Serializable;
use Symfony\Component\Cache\Adapter\RedisAdapter as OriginAdapter;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;

class RedisAdapter extends OriginAdapter implements Serializable
{
    use SerializableTrait;

    public function __construct(ProviderInterface $redisProvider, string $namespace = '', int $defaultLifetime = 0, ?MarshallerInterface $marshaller = null)
    {
        // do nothing
    }
}

module.dot

コンパイルをすると”dotファイル”が出力されるのでgraphvizで画像ファイルに変換するか、GraphvizOnlineを利用すればオブジェクトグラフを表示することができます。スケルトンのオブジェクトグラフもご覧ください。

dot -T svg module.dot > module.svg

ブートストラップのパフォーマンスチューニング

immutable_cacheは、不変の値を共有メモリにキャッシュするためのPECLパッケージです。APCuをベースにしていますが、PHPのオブジェクトや配列などの不変の値を共有メモリに保存するため、APCuよりも高速です。また、APCuでもimmutable_cacheでも、PECLのIgbinaryをインストールすることでメモリ使用量が減り、さらなる高速化が期待できます。

現在、専用のキャッシュアダプターなどは用意されていません。ImmutableBootstrapを参考に、専用のBootstrapを作成し呼び出してください。初期化コストを最小限に抑え、最大のパフォーマンスを得ることができます。

php.ini

// エクステンション
extension="apcu.so"
extension="immutable_cache.so"
extension="igbinary.so"

// シリアライザーの指定
apc.serializer=igbinary
immutable_cache.serializer=igbinary

インポート

BEARのアプリケーションは、マイクロサービスにすることなく複数のBEARアプリケーションを協調して1つのシステムにすることができます。また、他のアプリケーションからBEARのリソースを利用するのも容易です。

composer インストール

利用するBEARアプリケーションをcomposerパッケージにしてインストールします。

composer.json

{
  "require": {
    "bear/package": "^1.13",
    "my-vendor/weekday": "dev-master"
  },
  "repositories": [
    {
      "type": "vcs",
      "url": "https://github.com/bearsunday/tutorial1.git"
    }
  ]
}

bear/package ^1.13が必要です。

モジュールインストール

インポートするホスト名とアプリケーション名(namespace)、コンテキストを指定してImportAppModuleで他のアプリケーションをインストールします。

AppModule.php

+use BEAR\Package\Module\ImportAppModule;
+use BEAR\Package\Module\Import\ImportApp;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
+        $this->install(new ImportAppModule([
+            new ImportApp('foo', 'MyVendor\Weekday', 'prod-app')
+        ]));
        $this->install(new PackageModule());
    }
}

ImportAppModuleBEAR\ResourceではなくBEAR\Packageのものであることに注意してください。

リクエスト

インポートしたリソースは指定したホスト名を指定して利用します。

class Index extends ResourceObject
{
    use ResourceInject;

    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $weekday = $this->resource->get('app://foo/weekday?year=2022&month=1&day=1');
        $this->body = [
            'greeting' => 'Hello ' . $name,
            'weekday' => $weekday
        ];
        
        return $this;
    }
}

#[Embed]#[Link]も同様に利用できます。

他のシステムから

他のフレームワークやCMSからBEARのリソースを利用するのも容易です。同じようにパッケージとしてインストールして、Injector::getInstanceでrequireしたアプリケーションのリソースクライアントを取得してリクエストします。

use BEAR\Package\Injector;
use BEAR\Resource\ResourceInterface;

$resource = Injector::getInstance(
    'MyVendor\Weekday',
    'prod-api-app',
    dirname(__DIR__) . '/vendor/my-vendor/weekday'
)->getInstance(ResourceInterface::class);

$weekday = $resource->get('/weekday', ['year' => '2022', 'month' => '1', 'day' => 1]);
echo $weekday->body['weekday'] . PHP_EOL;

環境変数

環境変数はグローバルです。アプリケーション間でコンフリクトしないようにプリフィックスを付与するなどして注意する必要があります。インポートするアプリケーションは.envファイルを使うのではなく、プロダクションと同じようにシェルの環境変数を取得します。

システム境界

大きなアプリケーションを小さな複数のアプリケーションの集合体として構築できる点はマイクロサービスと同じですが、インフラストラクチャのオーバーヘッドの増加などのマイクロサービスのデメリットがありません。またモジュラーモノリスよりもコンポーネントの独立性や境界が明確です。

このページのコードは bearsunday/example-import-app にあります。

多言語フレームワーク

BEAR.Thriftを使うと、Apache Thriftを使って他の言語や異なるバージョンのPHPやBEARアプリケーションからリソースにアクセスできます。Apache Thriftは、異なる言語間での効率的な通信を可能にするフレームワークです。


データベース

データベースの利用のために、問題解決方法の異なった以下のモジュールが用意されています。いずれもPDOをベースにしたSQLのための独立ライブラリです。

静的なSQLはファイルにすると26、管理や他のSQLツールでの検証などの使い勝手もよくなります。Aura.SqlQueryは動的にクエリーを組み立てることができますが、その他は基本的に静的なSQLの実行のためのライブラリです。また、Ray.MediaQueryではSQLの一部をビルダーで組み立てたものに入れ替えることもできます。

モジュール

必要なライブラリに応じたモジュールをインストールします。

Ray.AuraSqlModuleはAura.SqlとAura.SqlQueryを含みます。

Ray.MediaQueryはユーザーが用意したインターフェイスとSQLから、SQL実行オブジェクトを生成しインジェクトする27高機能なDBアクセスフレームワークです。

その他

DBALはDoctrine、CakeDBはCakePHPのDBライブラリです。Ray.QueryModuleはRay.MediaQueryの以前のライブラリでSQLを無名関数に変換します。

CQRSリードモデル

BEAR.ProjectionはSQLベースのプロジェクションを型付きバリューオブジェクトにマップします。プロジェクションはquery://スキームでリソースとして公開され、#[Embed]と組み合わせて並列実行できます。

#[Embed(rel: 'profile', src: 'query://self/user_profile{?id}')]
#[Embed(rel: 'orders', src: 'query://self/user_orders{?id}')]
public function onGet(string $id): static

Ray.MediaQuery

Ray.MediaQueryはデータベースクエリーのインターフェイスから、クエリー実行オブジェクトを生成しインジェクトします。

  • ドメイン層とインフラ層の境界を明確にします。
  • ボイラープレートコードを削減します。
  • 外部メディアの実体には無関係なので、後からストレージを変更することができます。並列開発やスタブ作成が容易です。

インストール

composer require ray/media-query

Note: Web APIを同様にインターフェイスから扱うには ray/web-query を参照してください。

利用方法

データベースアクセスするインターフェイスを定義します。

インターフェイス定義

#[DbQuery]アトリビュートでSQLのIDを指定します。

use Ray\MediaQuery\Annotation\DbQuery;

interface TodoAddInterface
{
    #[DbQuery('todo_add')]
    public function add(string $title): void;
}

モジュール設定

MediaQuerySqlModuleでSQLディレクトリとインターフェイスディレクトリを指定します。

use Ray\AuraSqlModule\AuraSqlModule;
use Ray\MediaQuery\MediaQuerySqlModule;

protected function configure(): void
{
    $this->install(
        new MediaQuerySqlModule(
            interfaceDir: '/path/to/query/interfaces',
            sqlDir: '/path/to/sql'
        )
    );
    $this->install(new AuraSqlModule(
        'mysql:host=localhost;dbname=test',
        'username',
        'password'
    ));
}

MediaQuerySqlModuleはAuraSqlModuleのインストールが必要です。

注入

インターフェイスからオブジェクトが直接生成され、インジェクトされます。実装クラスのコーディングが不要です。

class Todo
{
    public function __construct(
        private TodoAddInterface $todoAdd
    ) {}

    public function add(string $title): void
    {
        $this->todoAdd->add($title);
    }
}

DbQuery

SQL実行がメソッドにマップされ、IDで指定されたSQLをメソッドの引数でバインドして実行します。例えばIDがtodo_itemの指定ではtodo_item.sqlSQL文に['id' => $id]をバインドして実行します。

  • $sqlDirディレクトリにSQLファイルを用意します。
  • SQLファイルには複数のSQL文が記述できます。最後の行のSELECTが返り値になります。

基本形は Entity(1行をhydrate済みエンティティで受け取る)と Entity リスト(複数行をエンティティの配列で受け取る)です。連想配列・独自コレクション・ページネーション・DML 系の戻り値型はこれらの応用として段階的に紹介します。

Entity(1行)

メソッドの戻り値の型としてエンティティクラスを指定すると、SQL実行結果が自動的にそのインスタンスに変換(hydrate)されます。

interface TodoItemInterface
{
    #[DbQuery('todo_item')]
    public function getItem(string $id): Todo;
}
Constructor Property Promotion(推奨)

コンストラクタプロパティプロモーションを使うと型安全でイミュータブルなエンティティを作成できます。

final class Todo
{
    public function __construct(
        public readonly string $id,
        public readonly string $title
    ) {}
}

値はSQLの列の順序でコンストラクタ引数に位置バインドされます。カラム名(例: user_name)とプロパティ名(例: $userName)は一致している必要はありません。SELECT句の列の順番とコンストラクタ引数の順番を合わせてください。

行が見つからない可能性があるときは戻り値型に Entity|null を指定します。該当行がなければ null が返ります。

Note: コンストラクタを定義しないエンティティを使う場合、Ray.MediaQuery は PDO の FETCH_CLASS にフォールバックし、列名→プロパティ名でマッピングします(snake_case 変換は行いません)。SELECT 句の列順に依存しないため、列数が多い読み取り専用 DTO や PHP 8.4 の readonly class で有用です。

Entity リスト(複数行)

戻り値の型を array に宣言すると複数行を受け取れます。各行をエンティティに hydrate するには、@return list<Entity> の docblock を付けるか、#[DbQuery]factory: パラメーターでファクトリを指定します。

interface TodoListInterface
{
    /** @return list<Todo> */
    #[DbQuery('todo_list')]
    public function list(): array;

    #[DbQuery('todo_list', factory: TodoFactory::class)]
    public function listByFactory(): array;
}

@return list<Entity>factory: も付けない場合、各行は連想配列のまま返ります(単行版は次のtype: ‘row’を参照)。

type: ‘row’(連想配列)

戻り値の型が array のとき、デフォルトでは複数行の rowlist([['id' => '1', 'title' => 'run'], ...])として返ります。集計結果のような単一行(例: ['total' => 10, 'active' => 5])をそのまま連想配列で受け取りたい場合は type: 'row' を指定します。指定しないと、その1行は $result[0] に入ります。

interface TodoItemInterface
{
    #[DbQuery('todo_stats', type: 'row')]
    public function getStats(string $id): array;  // ['total' => 10, 'active' => 5]
}

AffectedRows(UPDATE / DELETE の影響行数)

UPDATE / DELETEの影響行数を、ただの int ではなく型付きの値で受け取るには、戻り値型にAffectedRowsを指定します。

use Ray\MediaQuery\Result\AffectedRows;

interface TodoRepositoryInterface
{
    #[DbQuery('todo_delete')]
    public function delete(string $id): AffectedRows;
}

$affected = $todoRepo->delete($id);
$affected->count;        // int — 影響を受けた行数
$affected->isAffected(); // bool — count > 0 のときtrue

SQLファイルに複数のステートメントが含まれる場合、AffectedRows最後に実行されたステートメントの結果を表します。

実行可能な例: TodoAffectedInterfaceDbQueryAffectedRowsTest

InsertedRow(INSERT の解決済み値とID)

INSERTでフレームワークが解決した値(UUID、タイムスタンプ、DateTime → SQL文字列、ToScalarInterface によるスカラー化など)と、ドライバーが採番した lastInsertId をまとめて受け取るには、戻り値型に InsertedRow を指定します。同じ SQL ID でも、戻り値型を変えるだけでフレームワークのふるまい(実行のみ/影響行数/採番ID 等)が切り替わります。

use Ray\MediaQuery\Result\InsertedRow;

interface TodoAddInterface
{
    #[DbQuery('todo_add')]
    public function add(string $title): void;

    #[DbQuery('todo_add')]
    public function addReturning(string $title): InsertedRow;
}

$inserted = $todoAdd->addReturning('ドキュメント作成');
$inserted->values;  // array<string, mixed> — ドライバーにバインドされた解決済み値
$inserted->id;      // ?string — auto-increment ID(採番されない場合はnull)

$inserted->idはドライバーがfalse / '' / '0'を返した場合、nullに正規化されます。

PostQueryInterface(独自の型付き結果)

SELECTの結果を array<Article> ではなく、published() / titles() のようなドメインメソッドを持つ独自のコレクションでラップしたいことがあります。PostQueryInterfaceを実装したクラスを戻り値型に指定すると、フレームワークはクエリ実行後の状態を PostQueryContext にまとめて静的ファクトリ fromContext() に渡し、インスタンスの組み立てはクラス側で自由に決められます。

interface PostQueryInterface
{
    public static function fromContext(PostQueryContext $context): static;
}

PostQueryContextは次の4つの readonly プロパティを持ちます:

プロパティ 用途
$statement PDOStatement 実行済みステートメント。rowCount()やカラムメタデータ等を参照可能。
$pdo ExtendedPdoInterface 接続。lastInsertId() や追加読み取りに使う。
$values array<string, mixed> ParamConverter / ParamInjector 解決後の値(UUID、タイムスタンプ、バリューオブジェクトのスカラー化等)。
$rows array<mixed> SELECT時の取得行。@return Wrapper<Entity> または factory: でエンティティが解決されると hydrate 済みエンティティ、未指定時は連想配列。DML 時は常に []
use Ray\MediaQuery\Result\PostQueryContext;
use Ray\MediaQuery\Result\PostQueryInterface;

/** @implements IteratorAggregate<int, Article> */
final class Articles implements PostQueryInterface, IteratorAggregate, Countable
{
    /** @param list<Article> $rows */
    public function __construct(public readonly array $rows) {}

    public static function fromContext(PostQueryContext $context): static
    {
        /** @var list<Article> $rows */
        $rows = $context->rows;
        return new static($rows);
    }

    public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); }
    public function count(): int { return count($this->rows); }
}

interface ArticleRepositoryInterface
{
    #[DbQuery('article_list', factory: ArticleFactory::class)]
    public function list(): Articles;
}

各行のhydrationは Entity リストと同じく、generic な @return YourWrapper<Entity> docblock または factory: で指示します。継承ではなくコンポジションで表現することで、Laravel Collection、Doctrine ArrayCollection、独自実装などを自由に内部に保持できます。

Ray.MediaQuery の実行可能な例:

  • ArticlesPostQueryContext::$rows をラップするコレクション
  • ArticlesInterface — 連想配列、docblock による hydrate、factory: による hydrate の宣言例

なお、AffectedRows / InsertedRow も同じ PostQueryInterface の実装です。DML 後に独自の集計や監査ログを伴う結果型が欲しい場合は、同じ仕組みで自作できます。

戻り値型 早見表

  1行 複数行(rowlist)
エンティティ Entity / Entity|null array + @return list<Entity> または factory:
連想配列 array + #[DbQuery(type: 'row')] array(docblock / factory: なし)

応用的な戻り値型:

  • MyCollPostQueryInterface 実装)— 独自の型付きコレクションラッパー
  • PagesInterface + #[Pager] — ページネーション
  • AffectedRows — DML の影響行数
  • InsertedRow — DML の採番ID + 解決済み値
  • void — DML の実行のみ

パラメーター

日付時刻

パラメーターにバリューオブジェクトを渡すことができます。例えば、DateTimeInterfaceオブジェクトをこのように指定できます。

interface TaskAddInterface
{
    #[DbQuery('task_add')]
    public function __invoke(string $title, DateTimeInterface $createdAt = null): void;
}

値はSQL実行時に日付フォーマットされた文字列に変換されます。

INSERT INTO task (title, created_at) VALUES (:title, :createdAt); # 2021-2-14 00:00:00

値を渡さないとバインドされている現在時刻がインジェクションされます。SQL内部でNOW()とハードコーディングする事や、毎回現在時刻を渡す手間を省きます。

テスト時刻

テストの時には以下のようにDateTimeInterfaceの束縛を1つの時刻にすることもできます。

$this->bind(DateTimeInterface::class)->to(UnixEpochTime::class);

バリューオブジェクト(VO)

DateTime以外のバリューオブジェクトが渡されるとToScalarInterfaceを実装したtoScalar()メソッド、もしくは__toString()メソッドの返り値が引数になります。

interface MemoAddInterface
{
    #[DbQuery('memo_add')]
    public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
    public function __construct(
        private readonly LoginUser $user
    ) {}
    
    public function toScalar(): int
    {
        return $this->user->id;
    }
}
INSERT INTO memo (user_id, memo) VALUES (:user_id, :memo);

パラメーターインジェクション

バリューオブジェクトの引数のデフォルトの値のnullがSQLやWebリクエストで使われることはないことに注意してください。値が渡されないと、nullの代わりにパラメーターの型でインジェクトされたバリューオブジェクトのスカラー値が使われます。

public function __invoke(Uuid $uuid = null): void; // UUIDが生成され渡される

ページネーション

#[Pager]アトリビュートでSELECTクエリーをページングできます。

use Ray\MediaQuery\Annotation\DbQuery;
use Ray\MediaQuery\Annotation\Pager;
use Ray\MediaQuery\PagesInterface;

interface TodoList
{
    #[DbQuery('todo_list'), Pager(perPage: 10, template: '/{?page}')]
    public function __invoke(): PagesInterface;
}

count()で件数が取得でき、ページ番号で配列アクセスをするとページオブジェクトが取得できます。PagesInterfaceはSQL遅延実行オブジェクトです。

$pages = ($todoList)();
$cnt = count($pages);    // count()をした時にカウントSQLが生成されクエリーが行われます。
$page = $pages[2];       // 配列アクセスをした時にそのページのDBクエリーが行われます。

// $page->data           // sliced data
// $page->current;       // 現在のページ番号
// $page->total          // 総件数
// $page->hasNext        // 次ページの有無
// $page->hasPrevious    // 前ページの有無
// $page->maxPerPage;    // 1ページあたりの最大件数
// (string) $page        // ページャーHTML

SqlQuery

SqlQueryはSQLファイルのIDを指定してSQLを実行します。実装クラスを用意して詳細な実装を行う時に使用します。

class TodoItem implements TodoItemInterface
{
    public function __construct(
        private SqlQueryInterface $sqlQuery
    ) {}

    public function __invoke(string $id): array
    {
        return $this->sqlQuery->getRow('todo_item', ['id' => $id]);
    }
}

get* メソッド

SELECT結果を取得するためには取得する結果に応じたget*を使います。

$sqlQuery->getRow($queryId, $params);        // 結果が単数行
$sqlQuery->getRowList($queryId, $params);    // 結果が複数行
$statement = $sqlQuery->getStatement();       // PDO Statementを取得
$pages = $sqlQuery->getPages();              // ページャーを取得

Ray.MediaQueryはRay.AuraSqlModuleを含んでいます。さらに低レイヤーの操作が必要な時はAura.SqlのQuery BuilderやPDOを拡張したAura.SqlのExtended PDOをお使いください。doctrine/dbalも利用できます。

パラメーターインジェクションと同様、DateTimeInterfaceオブジェクトを渡すと日付フォーマットされた文字列に変換されます。

$sqlQuery->exec('memo_add', [
    'memo' => 'run',
    'created_at' => new DateTime()
]);

他のオブジェクトが渡されるとtoScalar()または__toString()の値に変換されます。

Ray.InputQueryとの連携

BEAR.ResourceでRay.InputQueryを利用している場合、InputクラスをMediaQueryのパラメーターとして直接渡すことができます。

use Ray\InputQuery\Attribute\Input;

final class UserCreateInput
{
    public function __construct(
        #[Input] public readonly string $name,
        #[Input] public readonly string $email,
        #[Input] public readonly int $age
    ) {}
}
interface UserCreateInterface
{
    #[DbQuery('user_create')]
    public function add(UserCreateInput $input): void;
}

InputオブジェクトのプロパティがSQLパラメータに自動展開されます。

-- user_create.sql
INSERT INTO users (name, email, age) VALUES (:name, :email, :age);

この連携により、ResourceObjectからMediaQueryまで一貫して型安全なデータフローを実現できます。

プロファイラー

メディアアクセスはロガーで記録されます。標準ではテストに使うメモリロガーがバインドされています。

public function testAdd(): void
{
    $this->sqlQuery->exec('todo_add', $todoRun);
    $this->assertStringContainsString(
        'query: todo_add({"id":"1","title":"run"})',
        (string) $this->log
    );
}

独自のMediaQueryLoggerInterfaceを実装して、各メディアクエリーのベンチマークを行ったり、インジェクトしたPSRロガーでログをすることもできます。

PerformSqlInterface

PerformSqlInterfaceを実装することで、SQL実行部分を完全にカスタマイズできます。デフォルトの実行処理を独自の実装に入れ替えることで、より高度なログ機能、パフォーマンス監視、セキュリティ制御などを実現できます。

use Exception;
use Ray\MediaQuery\PerformSqlInterface;

final class CustomPerformSql implements PerformSqlInterface
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    #[Override]
    public function perform(ExtendedPdoInterface $pdo, string $sqlId, string $sql, array $values): PDOStatement
    {
        $startTime = microtime(true);
        
        // カスタムログ出力
        $this->logger->info("Executing SQL: {$sqlId}", [
            'sql' => $sql,
            'params' => $values
        ]);
        
        try {
            /** @var array<string, mixed> $values */
            $statement = $pdo->perform($sql, $values);
            
            // 実行時間のログ
            $executionTime = microtime(true) - $startTime;
            $this->logger->info("SQL executed successfully", [
                'sqlId' => $sqlId,
                'execution_time' => $executionTime
            ]);
            
            return $statement;
        } catch (Exception $e) {
            $this->logger->error("SQL execution failed: {$sqlId}", [
                'error' => $e->getMessage(),
                'sql' => $sql
            ]);
            throw $e;
        }
    }
}

カスタム実装を使用するには、DIコンテナで束縛します:

use Ray\MediaQuery\PerformSqlInterface;

protected function configure(): void
{
    $this->bind(PerformSqlInterface::class)->to(CustomPerformSql::class);
}

SQLテンプレート

SQLの実行時にクエリーIDを含むカスタムログを出力して、スローログ分析時にどのクエリーが実行されたかを特定しやすくすることができます。

MediaQuerySqlTemplateModuleを使用して、SQLログのフォーマットをカスタマイズできます。

use Ray\MediaQuery\MediaQuerySqlTemplateModule;

protected function configure(): void
{
    $this->install(new MediaQuerySqlTemplateModule("-- App: .sql\n"));
}

利用可能なテンプレート変数:

  • {{ id }}: クエリーID
  • {{ sql }}: 実際のSQL文

デフォルトテンプレート:-- {{ id }}.sql\n{{ sql }}

この機能により、実行されるSQLにクエリーIDがコメントとして含まれ、データベースのスローログを分析する際に、どのアプリケーションのどのクエリーが実行されたかを容易に特定できます。

-- App: todo_item.sql
SELECT * FROM todo WHERE id = :id

コマンドラインインターフェイス (CLI)

BEAR.Sundayのリソース指向アーキテクチャ(ROA)は、アプリケーションのあらゆる機能をURIでアドレス可能なリソースとして表現します。このアプローチにより、Webに限らず様々な方法でリソースにアクセスできます。

$ php bin/page.php '/greeting?name=World&lang=ja'
{
    "greeting": "こんにちは, World",
    "lang": "ja"
}

BEAR.Cliは、このようなリソースをネイティブなCLIコマンドに変換し、Homebrewで配布可能にするツールです:

$ greet -n "World" -l ja
こんにちは, World

追加のコードを書くことなく、既存のアプリケーションリソースを標準的なCLIツールとして再利用できます。Homebrewを通じた配布により、PHPやBEAR.Sundayで動作していることを知ることなく、一般的なコマンドラインツールと同じように利用できます。

インストール

Composerでインストールします。

composer require bear/cli

基本的な使い方

リソースへのCLI属性の追加

リソースクラスにCLI属性を追加して、コマンドラインインターフェースを定義します。

use BEAR\Cli\Attribute\Cli;
use BEAR\Cli\Attribute\Option;

class Greeting extends ResourceObject
{
    #[Cli(
        name: 'greet',
        description: 'Say hello in multiple languages',
        output: 'greeting'
    )]
    public function onGet(
        #[Option(shortName: 'n', description: 'Name to greet')]
        string $name,
        #[Option(shortName: 'l', description: 'Language (en, ja, fr, es)')]
        string $lang = 'en'
    ): static {
        $greeting = match ($lang) {
            'ja' => 'こんにちは',
            'fr' => 'Bonjour',
            'es' => '¡Hola',
            default => 'Hello',
        };
        $this->body = [
            'greeting' => "{$greeting}, {$name}",
            'lang' => $lang
        ];

        return $this;
    }
}

CLIコマンドとフォーミュラの生成

リソースをコマンドにするには、以下のようにアプリケーション名(ベンダー名とプロジェクト名)を指定してコマンドを実行します:

$ vendor/bin/bear-cli-gen 'MyVendor\MyProject'
# 生成されたファイル:
#   bin/cli/greet         # CLIコマンド
#   var/homebrew/greet.rb # Homebrewフォーミュラ

Homebrewフォーミュラが生成されるのはGitHubでリポジトリが設定されている場合のみです。

コマンドの使用方法

生成されたコマンドは以下のような標準的なCLI機能を提供します:

ヘルプの表示

$ greet --help
Say hello in multiple languages

Usage: greet [options]

Options:
  --name, -n     Name to greet (required)
  --lang, -l     Language (en, ja, fr, es) (default: en)
  --help, -h     Show this help message
  --version, -v  Show version information
  --format       Output format (text|json) (default: text)

バージョン情報の表示

$ greet --version
greet version 0.1.0

基本的な使用例

# 基本的な挨拶
$ greet -n "World"
Hello, World

# 言語を指定
$ greet -n "World" -l ja
こんにちは, World

# 短いオプション
$ greet -n "World" -l fr
Bonjour, World

# 長いオプション
$ greet --name "World" --lang es
¡Hola, World

JSON出力

$ greet -n "World" -l ja --format json
{
    "greeting": "こんにちは, World",
    "lang": "ja"
}

出力の挙動

CLIコマンドの出力は以下の仕様に基づきます:

  • デフォルト出力: 指定されたフィールドの値のみを表示
  • --format=json オプション: APIエンドポイントと同様に、フルJSONレスポンスを表示
  • エラーメッセージ: 標準エラー出力(stderr)に表示
  • HTTPステータスコードのマッピング: 終了コードにHTTPステータスコードをマップ(0: 成功、1: クライアントエラー、2: サーバーエラー)

配布方法

BEAR.Cliで作成したコマンドは、Homebrewを通じて配布できます。 フォーミュラの生成にはアプリケーションがGitHubで公開されていることが必要です。

フォーミュラのファイル名および中のクラス名はリポジトリの名前に基づいています。例えばGHリポジトリがkoriym/greetの場合、Greetクラスを含むvar/homebrew/greet.rbが生成されます。この時greetが公開するタップ名になりますが変更したい場合はフォーミュラのクラス名とファイル名を変更してください。

1. ローカルフォーミュラによる配布

開発版をテストする場合:

$ brew install --formula ./var/homebrew/greet.rb

2. Homebrewタップによる配布

公開リポジトリを使用して広く配布する方法です:

$ brew tap your-vendor/greet
$ brew install greet

この方法は特に以下の場合に適しています:

  • オープンソースプロジェクト
  • 継続的なアップデートの提供

開発版のテスト

$ brew install --HEAD ./var/homebrew/greet.rb
$ greet --version
greet version 0.1.0

安定版のリリース

  1. タグを作成:
    $ git tag -a v0.1.0 -m "Initial stable release"
    $ git push origin v0.1.0
    
  2. フォーミュラを更新:
     class Greet < Formula
    +  desc "Your CLI tool description"
    +  homepage "https://github.com/your-vendor/greet"
    +  url "https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz"
    +  sha256 "..." # 以下のコマンドで取得したハッシュ値を記述
    +  version "0.1.0"
    head "https://github.com/your-vendor/greet.git", branch: "main"
       
    depends_on "php@8.1"
    depends_on "composer"
     end
    

    フォーミュラには必要に応じてデータベースなどの依存関係を追加できます。ただし、データベースのセットアップなどの環境構築は bin/setup スクリプトで行うことを推奨します。

  3. SHA256ハッシュの取得:
    # GitHubからtarballをダウンロードしてハッシュを計算
    $ curl -sL https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz | shasum -a 256
    
  4. Homebrewタップの作成: GitHub CLI(gh)またはgithub.com/newでリポジトリを作成してください。公開リポジトリ名はhomebrew-で始める必要があります。たとえばhomebrew-greetです:
$ gh auth login
$ gh repo create your-vendor/homebrew-greet --public --clone
# または、Webインターフェースを使用してリポジトリを作成してcloneしてください
$ cd homebrew-greet
  1. フォーミュラの配置と公開:
    $ cp /path/to/project/var/homebrew/greet.rb .
    $ git add greet.rb
    $ git commit -m "Add formula for greet command"
    $ git push
    
  2. インストールと配布: エンドユーザーは以下のコマンドだけでツールを使い始めることができます。PHP環境や依存パッケージのインストールは自動的に行われるため、ユーザーが環境構築について心配する必要はありません:
    $ brew tap your-vendor/greet    # homebrew-プレフィックスは省略可能
    $ brew install your-vendor/greet
    # すぐに使用可能
    $ greet --version
    greet version 0.1.0
    

フォーミュラのカスタマイズ

必要に応じて、brew edit コマンドでフォーミュラを編集できます:

$ brew edit your-vendor/greet
class Greet < Formula
  desc "Your CLI tool description"
  homepage "https://github.com/your-vendor/greet"
  url "https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz"
  sha256 "..." # tgzのSHA256
  version "0.1.0"
  
  depends_on "php@8.4"  # PHPバージョンの指定
  depends_on "composer"

  # アプリケーションが必要とする場合は追加
  # depends_on "mysql"
  # depends_on "redis"
end

クリーンアーキテクチャ

BEAR.Cliは、リソース指向アーキテクチャ(ROA)とクリーンアーキテクチャの強みを実証しています。クリーンアーキテクチャが目指す「UIは詳細である」という原則に従い、同じリソースに対してWebインターフェースだけでなく、CLIという新しいアダプターを追加できます。

さらに、BEAR.Cliはコマンドの作成だけでなく、Homebrewによる配布や更新もサポートしています。これにより、エンドユーザーはコマンド一つでツールを使い始めることができ、PHPやBEAR.Sundayの存在を意識せず、ネイティブなUNIXコマンドのように扱えます。

また、CLIツールはアプリケーションリポジトリから独立してバージョン管理および更新が可能です。そのため、APIの進化に影響されず、コマンドラインツールとしての安定性と継続的なアップデートを保つことができます。これは、リソース指向アーキテクチャとクリーンアーキテクチャの組み合わせにより実現した、APIの新しい提供形態です。


HTML

BEAR.Sundayでは、複数のテンプレートエンジンを活用してHTML表示を実現できます。

テンプレートエンジンの選択

対応テンプレートエンジン

  • Qiq(v1.0以降)
  • Twig(v1およびv2)

特徴比較

機能 Qiq Twig
エスケープ方式 明示的 暗黙的
構文 PHP準拠 独自構文
コードベース 軽量 豊富な機能
IDE対応 優れている 一般的

構文比較

PHP:

<?= $var ?>
<?= htmlspecialchars($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8') ?>
<?= htmlspecialchars(helper($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8')) ?>
<?php foreach ($users as $user): ?>
    * <?= $user->name; ?>
<?php endforeach; ?>

Twig:

{{ var | raw }}
{{ var }}
{{ var | helper }}
{% for user in users %}
  * {{ user.name }}
{% endfor %}

Qiq:

{{% var }}
{{h $var }}
{{h helper($var) }}
{{ foreach($users => $user) }}
  * {{h $user->name }}
{{ endforeach }}

{{ var }} // 表示されない

Or

<?php /** @var Template $this */ ?>
<?= $this->h($var) ?>

レンダラー

RenderInterfaceにバインドされResourceObjectにインジェクトされるレンダラーがリソースの表現を生成します。リソース自身はその表現に関して無関心です。

リソース単位でインジェクトされるため、複数のテンプレートエンジンを同時に使用することも可能です。

開発用のハローUI

開発時にハロー(Halo, 後光) 28 と呼ばれる開発用のUIをレンダリングされたリソースの周囲に表示できます。

ハローは以下の情報を提供します:

  • リソースの状態
  • 表現
  • 適用されたインターセプター
  • PHPStormでリソースクラスやテンプレートを開くためのリンク

ハローがリソース状態を表示

  • ハローホーム(ボーターとツール表示)
  • リソース状態
  • リソース表現
  • プロファイル

demoでハローのモックを試すことができます。

パフォーマンスモニタリング

ハローには以下のパフォーマンス情報が表示されます:

  • リソースの実行時間
  • メモリ使用量
  • プロファイラへのリンク

ハローがパフォーマンスを表示

インストール

プロファイリングにはxhprofのインストールが必要です:

pecl install xhprof
# php.iniファイルに'extension=xhprof.so'を追加

コールグラフを可視化するには、graphvizのインストールが必要です:

# macOS
brew install graphviz

# Windows
# graphvizのWebサイトからインストーラをダウンロードしてインストール

# Linux (Ubuntu)
sudo apt-get install graphviz

アプリケーションではDevコンテキストモジュールなどを作成してHaloModuleをインストールします:

class DevModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new HaloModule($this));
    }
}

例)コールグラフデモ


バリデーション

  • BEAR.SundayのバリデーションはJSONスキーマで行います。
  • Webフォームによるバリデーションはフォームをご覧ください。

JSONスキーマによるバリデーション

概要

JSON Schemaを使用して、リソースAPIの入出力仕様を定義し検証することができます。 これにより、APIの仕様を人間とマシンの両方が理解できる形式で管理できます。またApiDocとしてAPIドキュメントを出力することもできます。

セットアップ

モジュールの設定

バリデーションの適用範囲に応じて、以下のいずれかの方法で設定します:

  • すべての環境でバリデーションを行う場合:AppModuleに設定
  • 開発環境のみでバリデーションを行う場合:DevModuleに設定
use BEAR\Resource\Module\JsonSchemaModule;
use BEAR\Package\AbstractAppModule;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        $this->install(
            new JsonSchemaModule(
                $appDir . '/var/json_schema',  // スキーマ定義用
                $appDir . '/var/json_validate' // バリデーション用
            )
        );
    }
}

2. 必要なディレクトリの作成

mkdir -p var/json_schema
mkdir -p var/json_validate

基本的な使用方法

1. リソースクラスの定義

use BEAR\Resource\Annotation\JsonSchema;

class User extends ResourceObject
{
    #[JsonSchema('user.json')]
    public function onGet(): static
    {
        $this->body = [
            'firstName' => 'mucha',
            'lastName' => 'alfons',
            'age' => 12
        ];
        return $this;
    }
}

2. JSONスキーマの定義

var/json_schema/user.json:

{
    "type": "object",
    "properties": {
        "firstName": {
            "type": "string",
            "maxLength": 30,
            "pattern": "[a-z\\d~+-]+"
        },
        "lastName": {
            "type": "string",
            "maxLength": 30,
            "pattern": "[a-z\\d~+-]+"
        }
    },
    "required": ["firstName", "lastName"]
}

高度な使用方法

インデックスキーの指定

レスポンスボディにインデックスキーがある場合、keyパラメータで指定します:

class User extends ResourceObject
{
    #[JsonSchema(key: 'user', schema: 'user.json')]
    public function onGet(): static
    {
        $this->body = [
            'user' => [
                'firstName' => 'mucha',
                'lastName' => 'alfons',
                'age' => 12
            ]
        ];
        
        return $this;
    }
}

引数のバリデーション

メソッドの引数をバリデーションする場合、paramsパラメータでスキーマを指定します:

class Todo extends ResourceObject
{
    #[JsonSchema(
        key: 'user',
        schema: 'user.json',
        params: 'todo.post.json'
    )]
    public function onPost(string $title)
    {
        // メソッドの処理
    }
}

var/json_validate/todo.post.json:

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "title": "/todo POST request validation",
    "properties": {
        "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
        }
    }
}

target

ResourceObjectのbodyに対してでなく、リソースオブジェクトの表現(レンダリングされた結果)に対してスキーマバリデーションを適用にするにはtarget='view'オプションを指定します。 HALフォーマットで_linkのスキーマが記述できます。

#[JsonSchema(schema: 'user.json', target: 'view')]

スキーマ作成支援ツール

JSONスキーマの作成には以下のツールが便利です:


フォーム

Aura.InputAura.Filterを使ったWebフォーム機能は、関連する処理を単一のクラスに集約するため、テストや変更が容易です。1つのフォームクラスをWebフォームの表示とバリデーションの両方に使用できます。

インストール

composerでray/web-form-moduleをインストールします:

composer require ray/web-form-module

アプリケーションモジュールsrc/Module/AppModule.phpWebFormModuleをインストールします:

use BEAR\Package\AbstractAppModule;
use Ray\WebFormModule\WebFormModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new WebFormModule());
    }
}

フォームクラス

フォーム要素の登録とバリデーションルールを定義するフォームクラスを作成し、#[FormValidation]アトリビュートで特定のメソッドに束縛します。バリデーションが成功したときだけ、そのメソッドが実行されます。

use Ray\WebFormModule\AbstractForm;
use Ray\WebFormModule\SetAntiCsrfTrait;

class MyForm extends AbstractForm
{
    use SetAntiCsrfTrait;

    public function init()
    {
        // フォームフィールドの登録
        $this->setField('name', 'text')
             ->setAttribs(['id' => 'name']);

        // バリデーションルールとエラーメッセージの設定
        $this->filter->validate('name')->is('alnum');
        $this->filter->useFieldMessage('name', '名前は英数字のみ使用できます。');
    }
}

init()メソッドでフォームの入力要素を登録し、バリデーションフィルターやサニタイズルールを適用します。詳しいルールは以下を参照してください:

メソッドの引数を連想配列にしたものをバリデーションします。入力値を加工したい場合はSubmitInterface::submit()を実装して値を返します。

#[FormValidation]アトリビュート

#[FormValidation]アトリビュートを付けたメソッドは、実行前にformプロパティのフォームオブジェクトでバリデーションされます。バリデーションが失敗すると、メソッド名にValidationFailedサフィックスを付けたメソッドが呼び出されます:

use BEAR\Resource\ResourceObject;
use Ray\Di\Di\Named;
use Ray\WebFormModule\Annotation\FormValidation;
use Ray\WebFormModule\FormInterface;

class MyPage extends ResourceObject
{
    public function __construct(
        #[Named('contact_form')] private FormInterface $contactForm,
    ) {
    }

    #[FormValidation(form: 'contactForm')]
    public function onPost(string $name, int $age): static
    {
        // バリデーション成功時の処理
        return $this;
    }

    public function onPostValidationFailed(string $name, int $age): static
    {
        // バリデーション失敗時の処理
        return $this;
    }
}

#[FormValidation]formプロパティでフォームプロパティ名を、onFailureプロパティで失敗時に呼び出すメソッド名を明示できます:

#[FormValidation(form: 'contactForm', onFailure: 'badRequestAction')]
public function onPost(string $name, int $age): static
{
    return $this;
}

失敗時メソッドにはサブミットされた引数がそのまま渡されます。

ビュー

フォームのinput要素やエラーメッセージを取得するには要素名を指定します:

$form->input('name');  // 例:<input id="name" type="text" name="name" size="20" maxlength="20" />
$form->error('name');  // 例:名前は英数字のみ使用できます。

Twigテンプレートでも同様です:

{{ form.input('name') }}
{{ form.error('name') }}

フォームクラスがToStringInterfaceを実装していれば、フォーム全体を文字列として出力できます:

echo $form;  // フォーム全体のHTMLを描画

CSRF

CSRF(クロスサイトリクエストフォージェリ)保護はopt-inです。フォームにSetAntiCsrfTraitを使うとAntiCsrfInterfaceが組み込まれますが、トークンの検証は#[CsrfProtection]アトリビュートを付けたメソッドでのみ実行されます。アトリビュートが無いメソッドでは、フォームがAntiCsrfオブジェクトを持っていてもCSRF検証は行われません。

use BEAR\Resource\ResourceObject;
use Ray\WebFormModule\AbstractForm;
use Ray\WebFormModule\Annotation\CsrfProtection;
use Ray\WebFormModule\Annotation\FormValidation;
use Ray\WebFormModule\SetAntiCsrfTrait;

class MyForm extends AbstractForm
{
    use SetAntiCsrfTrait;
}

class MyPage extends ResourceObject
{
    #[FormValidation(form: 'contactForm')]
    #[CsrfProtection]
    public function onPost(string $name, int $age): static
    {
        // CSRFトークンが正しい場合のみ実行される
        return $this;
    }
}

セキュリティレベルを高めるには、ユーザー認証を組み込んだカスタムCsrfクラスを作成してフォームクラスにセットします。詳しくはAura.InputのApplying CSRF Protectionsを参照してください。

#[InputValidation]

#[FormValidation]の代わりに#[InputValidation]を使うと、バリデーション失敗時にRay\WebFormModule\Exception\ValidationExceptionが投げられます。HTML表現を使用しないのでWeb APIに便利です。

キャッチした例外のerrorプロパティをechoすると、application/vnd.error+jsonメディアタイプの表現が出力されます:

http_response_code(400);
echo $e->error;

// 出力例:
// {
//     "message": "Validation failed",
//     "path": "/path/to/error",
//     "validation_messages": {
//         "name": [
//             "名前は英数字のみ使用できます。"
//         ]
//     }
// }

#[VndError]アトリビュートでvnd.error+jsonに追加情報を付与できます:

#[FormValidation(form: 'contactForm')]
#[VndError(
    message: 'foo validation failed',
    logref: 'a1000',
    path: '/path/to/error',
    href: ['_self' => '/path/to/error', 'help' => '/path/to/help']
)]
public function onPost(): static
{
    return $this;
}

FormVndErrorModule

Ray\WebFormModule\FormVndErrorModuleをインストールすると、#[FormValidation]を付けたメソッドも#[InputValidation]と同様に例外を投げるようになります。Pageリソースをそのまま API として利用できます:

use Ray\Di\AbstractModule;
use Ray\WebFormModule\WebFormModule;
use Ray\WebFormModule\FormVndErrorModule;

class FooModule extends AbstractModule
{
    protected function configure()
    {
        $this->install(new WebFormModule());
        $this->override(new FormVndErrorModule());
    }
}

0.x からの移行

1.0 では Doctrine Annotations から PHP 8 Attributes へ移行し、CSRF 保護が #[CsrfProtection] による opt-in に変わるなどの破壊的変更があります。移行手順はRay.WebFormModule READMECHANGELOGを参照してください。

デモ

MyVendor.ContactFormで、確認画面付きフォームや複数フォームを1ページに設置した例などを試すことができます。


コンテントネゴシエーション

HTTPにおいてコンテントネゴシエーション (content negotiation) は、同じ URL に対してさまざまなバージョンのリソースを提供するために使用する仕組みです。 BEAR.Sundayではその内のメディアタイプのAcceptと言語のAccept-Languageのサーバーサイドのコンテントネゴシエーションをサポートします。アプリケーション単位またはリソース単位で指定することができます。

インストール

composerでBEAR.Acceptをインストールします。

composer require bear/accept ^0.1

次にAccept*リクエストヘッダーに応じたコンテキストを/var/locale/available.phpに保存します。

<?php
return [
    'Accept' => [
        'text/hal+json' => 'hal-app',
        'application/json' => 'app',
        'cli' => 'cli-hal-app'
    ],
    'Accept-Language' => [ // Accept-Languageキーを小文字で指定
        'ja-jp' => 'ja',
        'ja' => 'ja',
        'en-us' => 'en',
        'en' => 'en'
    ]
];

Acceptキー配列はメディアタイプをキーにしてコンテキストが値にした配列を指定します。cliはコンソールアクセスでのコンテキストでwebアクセスで使われることはありません。

Accept-Languageキー配列は言語をキーにしてコンテキストキーを値した配列を指定します。

アプリケーション

アプリケーション全体でコンテントネゴシエーションを有効にするためにpublic/index.phpを変更します。

<?php
use BEAR\Accept\Accept;

require dirname(__DIR__) . '/vendor/autoload.php';

$accept = new Accept(require dirname(__DIR__) . '/var/locale/available.php');
list($context, $vary) = $accept($_SERVER);
require dirname(__DIR__) . '/bootstrap/bootstrap.php';

上記の設定で例えば以下のAccept*ヘッダーのアクセスのコンテキストはprod-hal-ja-appになります:

Accept: application/hal+json
Accept-Language: ja-JP

この時JaModuleで日本語テキストのための束縛が必要です。詳しくはデモアプリケーションMyVendor.Localeをごらんください。

リソース

リソース単位でコンテントネゴシエーションを行う場合はAcceptModuleモジュールをインストールして#[Produces]アトリビュートを使います。

モジュール

protected function configure()
{
    // ...
    $available = $appDir . '/var/locale/available.php';
    $this->install(new AcceptModule($available));
}

#[Produces]アトリビュート

use BEAR\Accept\Attribute\Produces;

#[Produces(['application/hal+json', 'text/csv'])]
public function onGet()

利用可能なメディアタイプを左から優先順位で指定します。対応したコンテキストのレンダラーがAOPでセットされ表現が変わります。 アプリケーション単位でのネゴシエーションの時と違って、Varyヘッダーを手動で付加する必要はありません。

curlを使ったアクセス

-HオプションでAccept*ヘッダーを指定します:

curl -H 'Accept-Language: en' http://127.0.0.1:8080/
curl -i -H 'Accept-Language: en' -H 'Accept: application/hal+json' http://127.0.0.1:8080/
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 11 Aug 2017 08:32:33 +0200
Connection: close
X-Powered-By: PHP/7.1.4
Vary: Accept, Accept-Language
content-type: application/hal+json

{
    "greeting": "Hello BEAR.Sunday",
    "_links": {
        "self": {
            "href": "/index"
        }
    }
}

ハイパーメディアAPI

HAL

BEAR.SundayはHALハイパーメディア(application/hal+json)APIをサポートしています。HALのリソースモデルは以下の要素で構成されます:

  • リンク
  • 埋め込みリソース
  • 状態

HALは、従来のリソースの状態のみを表すJSONに、リンクの_linksと他リソースを埋め込む_embeddedを加えたものです。HALはAPIを探索可能にし、そのAPIドキュメントをAPI自体から発見することができます。

以下は有効なHALの例です。自身(self)のURIへのリンクを持っています:

{
    "_links": {
        "self": { "href": "/user" }
    }
}

リンクにはrel(relation)があり、どのような関係でリンクされているかを表します。HTMLの<link>タグや<a>タグで使われるrelと同様です:

{
    "_links": {
        "next": { "href": "/page=2" }
    }
}

HALについてさらに詳しくはhttp://stateless.co/hal_specification.htmlをご覧ください。

リソースクラス

アノテーションを使用してリンクを貼ったり、他のリソースを埋め込んだりすることができます。

リンクが静的なものは#[Link]属性で表し、動的なものはbody['_links']に代入します。宣言的に記述できる#[Link]属性の使用を推奨します:

#[Link(rel="user", href="/user")]
#[Link(rel="latest-post", href="/latest-post", title="latest post entry")]
public function onGet()

または:

public function onGet() 
{
    // 権限のある場合のみリンクを貼る
    if ($hasCommentPrivilege) {
        $this->body += [
            '_links' => [
                'comment' => [
                    'href' => '/comments/{post-id}',
                    'templated' => true
                ]
            ]
        ];
    }
}

#[Embed]

他のリソースを静的に埋め込むには#[Embed]アトリビュートを使い、動的に埋め込むにはbodyにリクエストを代入します:

#[Embed(rel="todos", src="/todos{?status}")]
#[Embed(rel="me", src="/me")]
public function onGet(string $status): static

または:

$this->body['_embedded']['todos'] = $this->resource->uri('app://self/todos');

APIドキュメント

Curiesが設定されたAPIサーバーをAPIドキュメントサーバーとして使用できます。これにより、APIドキュメントの作成の手間や、実際のAPIとの整合性の問題、検証やメンテナンスといった課題を解決できます。

サービスを提供するには、bear/api-docをインストールしてBEAR\ApiDoc\ApiDocページクラスを継承して設置します:

composer require bear/api-doc
<?php
namespace MyVendor\MyProject\Resource\Page\Rels;

use BEAR\ApiDoc\ApiDoc;

class Index extends ApiDoc
{
}

JSON Schemaのフォルダをwebに公開します:

ln -s var/json_schema public/schemas

DocblockコメントとJSON Schemaを使ってAPIドキュメントが自動生成されます。ページクラスは独自のレンダラーを持ち、$contextの影響を受けずに人のためのドキュメント(text/html)をサービスします。

$contextの影響を受けないため、AppPageどちらにも設置可能です。CURIEsがルートに設定されていれば、API自体がハイパーメディアではない通常のJSONの場合でも利用可能です。

リアルタイムに生成されるドキュメントは、常にプロパティ情報やバリデーション制約が正確に反映されます。

デモ

git clone https://github.com/koriym/Polidog.Todo.git
cd Polidog.Todo/
composer install
composer setup
composer doc

docs/index.mdにAPI docが作成されます。

ブラウズ可能

HALで記述されたAPIセットはヘッドレスのRESTアプリケーションとして機能します。WebベースのHAL BrowserやコンソールのcURLコマンドで、Webサイトと同じようにルートからリンクを辿って、すべてのリソースにアクセスできます:

HAL Layout Beta

HALリソースをReact/Vueコンポーネントとしてレンダリングするライブラリ:

Siren

Sirenハイパーメディア(application/vnd.siren+json)をサポートしたSirenモジュールも利用可能です。


PSR-7

PSR-7 HTTP message interface1を使って、サーバーサイドリクエストの情報を取得したり、BEAR.SundayアプリケーションをPSR-7ミドルウェアとして実行したりすることができます。

HTTPリクエスト

PHPには$_SERVER$_COOKIEなどのスーパーグローバルがありますが、それらの代わりにPSR-7 HTTP message interfaceを使ってサーバーサイドリクエストの情報($_COOKIE$_GET$_POST$_FILES$_SERVER)を受け取ることができます。

ServerRequest(サーバーリクエスト全般)

class Index extends ResourceObject
{
    public function __construct(ServerRequestInterface $serverRequest)
    {
        // クッキーの取得
        $cookie = $serverRequest->getCookieParams(); // $_COOKIE
    }
}

アップロードファイル

use Psr\Http\Message\UploadedFileInterface;
use Ray\HttpMessage\Annotation\UploadFiles;

class Index extends ResourceObject
{
    /**
     * @UploadFiles
     */
    public function __construct(array $files)
    {
        // ファイル名の取得
        $file = $files['my-form']['details']['avatar'][0];
        /* @var UploadedFileInterface $file */
        $name = $file->getClientFilename(); // my-avatar3.png
    }
}

URI

use Psr\Http\Message\UriInterface;

class Index extends ResourceObject
{
    public function __construct(UriInterface $uri)
    {
        // ホスト名の取得
        $host = $uri->getHost();
    }
}

PSR-7ミドルウェア

既存のBEAR.Sundayアプリケーションは、特別な変更なしにPSR-7ミドルウェアとして動作させることができます。

以下のコマンドでbear/middlewareを追加して、ミドルウェアとして動作させるためのbootstrapスクリプトに置き換えます:

composer require bear/middleware
cp vendor/bear/middleware/bootstrap/bootstrap.php bootstrap/bootstrap.php

次にスクリプトの__PACKAGE__\__VENDOR__をアプリケーションの名前に変更すれば完了です:

php -S 127.0.0.1:8080 -t public

ストリーム

ミドルウェアに対応したBEAR.Sundayのリソースはストリームの出力に対応しています。HTTP出力はStreamTransferが標準です。詳しくはストリーム出力をご覧ください。

新規プロジェクト

新規でPSR-7のプロジェクトを始めることもできます:

composer create-project bear/project my-awesome-project
cd my-awesome-project/
php -S 127.0.0.1:8080 -t public

PSR-7ミドルウェア



JavaScript SSR

ビューのレンダリングをTwigなどのPHPのテンプレートエンジンが行う代わりに、サーバーサイドのJavaScriptが実行します。PHP側は認証・認可・初期状態・APIの提供を行い、JavaScriptがUIをレンダリングします。既存のプロジェクトの構造で、アトリビュートが付与されたリソースのみに適用されるため、導入が容易です。

背景と適用場面

このモジュールは、PHPアプリケーション内でJavaScriptによるサーバーサイドレンダリング(SSR)を実現するために開発されました。

現在では、Next.js、Nuxt、Remixなど、JavaScriptエコシステム側でSSRを完結させるフレームワークが成熟しています。新規プロジェクトでJavaScript中心のUIを構築する場合、これらのフレームワークが第一選択となるでしょう。

本モジュールが適しているのは以下のようなケースです:

  • 大部分のページは既存のテンプレートエンジンでレンダリングし、高度なインタラクティブ性が求められる一部のページのみJS UIを使いたい
  • 既存のBEAR.Sundayプロジェクトに、特定のページのみReactやVue.jsのUIを追加したい
  • フロントエンドとバックエンドを分離せず、単一のPHPアプリケーションとして運用したい
  • PHPチームとJSチームがstate/metasを契約として並行開発したい

#[Ssr]アトリビュートを付与したリソースのみがJS UIでレンダリングされるため、従来のテンプレートエンジンとの共存が容易です。

前提条件

  • PHP 8.2以上
  • Node.js
  • V8Js(開発時はオプション)

注:V8Jsがインストールされていない場合、Node.jsでJavaScriptが実行されます。

JavaScript

インストール

プロジェクトにbear/ssr-moduleをインストールします:

# 新規プロジェクトの場合
# composer create-project bear/skeleton MyVendor.MyProject; cd MyVendor.MyProject
composer require bear/ssr-module

UIスケルトンアプリケーションkoriym/js-ui-skeletonをインストールします:

composer require koriym/js-ui-skeleton 1.x-dev
cp -r vendor/koriym/js-ui-skeleton/ui .
cp -r vendor/koriym/js-ui-skeleton/package.json .
npm install

UIアプリケーションの実行

まずはデモアプリケーションを動かしてみましょう。表示されたWebページからレンダリング方法を選択して、JavaScriptアプリケーションを実行します:

npm run ui

このアプリケーションの入力はui/dev/config/の設定ファイルで行います:

<?php
$app = 'index';                   // index.bundle.jsを指定
$state = [                        // アプリケーションステート
    'hello' => ['name' => 'World']
];
$metas = [                        // SSRでのみ必要な値
    'title' => 'page-title'
];

return [$app, $state, $metas];

設定ファイルをコピーして、入力値を変更してみましょう:

cp ui/dev/config/index.php ui/dev/config/myapp.php

ブラウザをリロードして新しい設定を試します。このように、JavaScriptや本体のPHPアプリケーションを変更せずに、UIのデータを変更して動作を確認することができます。

このセクションで編集したPHPの設定ファイルは、npm run uiで実行する時のみに使用されます。PHP側が必要とするのは、バンドルされて出力されたJavaScriptファイルのみです。

UIアプリケーションの作成

PHPから渡された引数を使ってレンダリングした文字列を返すrender関数を作成します:

const render = (state, metas) => (
    __AWESOME_UI__ // SSR対応のライブラリやJSのテンプレートエンジンを使って文字列を返す
);

stateはドキュメントルートに必要な値、metasはそれ以外の値(例えば<head>で使う値など)です。renderという関数名は固定です。

ここでは名前を受け取って挨拶を返す関数を作成します:

const render = state => (
    `Hello ${state.name}`
);

ui/src/page/hello/server.jsとして保存して、webpackのエントリーポイントをui/entry.jsに登録します:

module.exports = {
    hello: 'src/page/hello/server'
};

これでhello.bundle.jsというバンドルされたファイルが出力されるようになりました。

このhelloアプリケーションをテスト実行するためのファイルをui/dev/config/myapp.phpに作成します:

<?php
$app = 'hello';
$state = ['name' => 'World'];
$metas = [];

return [$app, $state, $metas];

以上です!ブラウザをリロードして試してください。

render関数内では、ReactやVue.jsなどのUIフレームワークを使ってリッチなUIを作成できます。

通常のアプリケーションでは、依存を最小限にするためにserver.jsエントリーファイルは以下のようにrenderモジュールを読み込むようにします:

import render from './render';
global.render = render;

ここまでPHP側の作業はありません。SSRのアプリケーション開発は、PHP開発と独立して行うことができます。

PHP

モジュールインストール

AppModuleにSsrModuleモジュールをインストールします:

<?php
use BEAR\SsrModule\SsrModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $build = dirname(__DIR__, 2) . '/var/www/build';
        $this->install(new SsrModule($build));
    }
}

$buildフォルダはJavaScriptファイルがあるディレクトリです(ui/ui.config.jsで指定するwebpackの出力先)。

#[Ssr]アトリビュート

リソースをSSRするメソッドに#[Ssr]アトリビュートを付与します。appにJavaScriptアプリケーション名を指定する必要があります:

<?php
namespace MyVendor\MyRedux\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\SsrModule\Annotation\Ssr;

class Index extends ResourceObject
{
    #[Ssr(app: 'index_ssr')]
    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $this->body = [
            'hello' => ['name' => $name]
        ];
        return $this;
    }
}

$this->bodyrender関数の第1引数として渡されます。

CSRとSSRの値を区別して渡したい場合は、statemetasでbodyのキーを指定します:

#[Ssr(app: 'index_ssr', state: ['name', 'age'], metas: ['title'])]
public function onGet(): static
{
    $this->body = [
        'name' => 'World',
        'age' => 4.6E8,
        'title' => 'Age of the World'
    ];
    return $this;
}

実際にstatemetasをどのように渡してSSRを実現するかは、ui/src/page/index/serverのサンプルアプリケーションをご覧ください。

影響を受けるのはアトリビュートを付与したメソッドだけで、APIやHTMLのレンダリングの設定はそのままです。

PHPアプリケーションの実行設定

ui/ui.config.jsを編集して、publicにWeb公開ディレクトリを、buildにwebpackのビルド先を指定します。buildSsrModuleのインストール時に指定したディレクトリと同じにします:

const path = require('path');

module.exports = {
    public: path.join(__dirname, '../var/www'),
    build: path.join(__dirname, '../var/www/build')
};

PHPアプリケーションの実行

npm run dev

ライブアップデートで実行します。PHPファイルの変更があれば自動でリロードされ、Reactのコンポーネントに変更があれば、リロードなしでコンポーネントがアップデートされます。

ライブアップデートなしで実行する場合はnpm run startを実行します。

linttestなどの他のコマンドについては、コマンドをご覧ください。

パフォーマンス

V8のスナップショットをAPCuに保存する機能を使って、パフォーマンスの向上が可能です。ProdModuleApcSsrModuleをインストールしてください。V8Jsが必要です:

$bundleSrcBasePath = dirname(__DIR__, 2) . '/var/www/build';
$this->install(new ApcSsrModule($bundleSrcBasePath));

$bundleSrcBasePathはJavaScriptバンドルファイルがあるディレクトリのパスです。


ストリーム出力

通常、リソースはレンダラーでレンダリングされて1つの文字列になり、最終的にechoで出力されます。しかし、この方法ではPHPのメモリ制限を超えるサイズのコンテンツは出力できません。StreamRendererを使用することでHTTP出力をストリーム化でき、メモリ消費を低く抑えることができます。このストリーム出力は、既存のレンダラーと共存することも可能です。

トランスファーとレンダラーの変更

ストリーム出力用のレンダラーとレスポンダーをインジェクトするために、ページにStreamTransferInjectトレイトをuseします。

以下のダウンロードページの例では、$bodyをストリームのリソース変数としているため、インジェクトされたレンダラーは無視され、リソースが直接ストリーム出力されます:

use BEAR\Streamer\StreamTransferInject;

class Download extends ResourceObject
{
    use StreamTransferInject;

    public $headers = [
        'Content-Type' => 'image/jpeg',
        'Content-Disposition' => 'attachment; filename="image.jpg"'
    ];

    public function onGet(): static
    {
        $fp = fopen(__DIR__ . '/BEAR.jpg', 'r');
        $this->body = $fp;

        return $this;
    }
}

レンダラーとの共存

ストリーム出力は従来のレンダラーと共存できます。通常、TwigレンダラーやJSONレンダラーは文字列を生成しますが、その一部にストリームをアサインすると、全体がストリームとして出力されます。

以下は、Twigテンプレートに文字列とresource変数をアサインして、インライン画像のページを生成する例です。

テンプレート:

<!DOCTYPE html>
<html lang="en">
<body>
<p>Hello, {{ name }}</p>
<img src="data:image/jpg;base64,{{ image }}">
</body>
</html>

nameには通常通り文字列をアサインし、imageには画像ファイルのファイルポインタリソースをbase64-encodeフィルターを通してアサインします:

class Image extends ResourceObject
{
    use StreamTransferInject;

    public function onGet(string $name = 'inline image'): static
    {
        $fp = fopen(__DIR__ . '/image.jpg', 'r');
        stream_filter_append($fp, 'convert.base64-encode'); // 画像をbase64形式に変換
        $this->body = [
            'name' => $name,
            'image' => $fp
        ];

        return $this;
    }
}

ストリーミングの帯域幅やタイミングをコントロールしたり、クラウドにアップロードしたりするなど、ストリーミングをさらに制御する場合は、StreamResponderを参考にして作成し、束縛します。

ストリーム出力のデモはMyVendor.Streamで確認できます。


Cache

There are only two hard things in Computer Science: cache invalidation and naming things.

– Phil Karlton

概要

優れたキャッシュシステムは、ユーザー体験の質を本質的に向上させ、資源利用コストと環境負荷を下げます。BEAR.Sundayは従来のTTLによる単純なキャッシュに加えて、以下のキャッシュ機能をサポートしています:

  • イベント駆動のキャッシュ無効化
  • キャッシュの依存解決
  • ドーナッツキャッシュとドーナッツの穴キャッシュ
  • CDNコントロール
  • 条件付きリクエスト

分散キャッシュフレームワーク

REST制約に従った分散キャッシュシステムは、計算資源だけでなくネットワーク資源も節約します。PHPが直接扱うRedisやAPCなどのサーバーサイドキャッシュ、コンテンツ配信ネットワーク(CDN)として知られる共有キャッシュ、WebブラウザやAPIクライアントでキャッシュされるクライアントサイドキャッシュ、BEAR.SundayはこれらのキャッシュとモダンCDNを統合したキャッシングフレームワークを提供します。

distributed cache

タグベースでのキャッシュ無効化

dependency graph 2021-10-19 21 38 02

コンテンツキャッシュには依存性の問題があります。コンテンツAがコンテンツBに依存し、BがCに依存している場合、Cが更新されるとCのキャッシュとETagだけでなく、Cに依存するBのキャッシュとETag、Bに依存するAのキャッシュとETagも更新されなければなりません。

BEAR.Sundayはそれぞれのリソースが依存リソースのURIをタグとして保持することで、この問題を解決します。#[Embed]で埋め込まれたリソースに変更があると、関係する全てのリソースのキャッシュとETagが無効化され、次のリクエストのためにキャッシュの再生成が行われます。

ドーナッツキャッシュ

donut caching

ドーナッツキャッシュは、キャッシュの最適化のための部分キャッシュ技術の1つです。コンテンツをキャッシュ可能な箇所とそうでない箇所に分けて合成します。

例えば「Welcome to $name」というキャッシュできないリソースが含まれるコンテンツを考えてみてください。キャッシュできない(do-not cache)部分と、その他のキャッシュ可能な部分を合成して出力します。

image

この場合、コンテンツ全体としては動的なので、ドーナッツ全体はキャッシュされません。そのため、ETagも出力されません。

ドーナッツの穴キャッシュ

image

ドーナッツの穴部分がキャッシュ可能な場合も、ドーナッツキャッシュと同じように扱えます。上記の例では、1時間に一度変更される天気予報のリソースがキャッシュされ、ニュースリソースに含まれます。

この場合、ドーナッツ全体(ニュース)としてのコンテンツは静的なので、全体もキャッシュされ、ETagも付与されます。このとき、キャッシュの依存性が発生します。ドーナッツの穴部分のコンテンツが更新された時に、キャッシュされたドーナッツ全体も再生成される必要があります。

この依存解決は自動で行われます。計算資源を最小化するため、ドーナッツ部分の計算は再利用されます。穴の部分(天気リソース)が更新されると、全体のコンテンツのキャッシュとETagも自動で更新されます。

リカーシブ・ドーナッツ

recursive donut 2021-10-19 21 27 06

ドーナッツ構造は再帰的に適用されます。例えば、AがBを含み、BがCを含むコンテンツの場合、Cが変更されたときに、変更されたCの部分を除いて、AのキャッシュとBのキャッシュは再利用されます。AとBのキャッシュ、ETagは再生成されますが、A、Bのコンテンツ取得のためのDBアクセスやビューのレンダリングは行われません。

最適化された構造の部分キャッシュが、最小のコストでコンテンツ再生成を行います。クライアントはコンテンツのキャッシュ構造について知る必要がありません。

イベントドリブン型コンテンツ

従来、CDNはアプリケーションロジックを必要とするコンテンツは「動的」であり、したがってCDNではキャッシュできないと考えられてきました。しかし、FastlyやAkamaiなどの一部のCDNは、即時または数秒以内でのタグベースでのキャッシュ無効化が可能になり、この考え方は過去のものとなりつつあります

BEAR.Sundayの依存解決は、サーバーサイドだけでなく共有キャッシュでも行われます。AOPが変更を検知し、共有キャッシュにPURGEリクエストを行うことで、サーバーサイドと同じように共有キャッシュ上の関連キャッシュの無効化が行われます。

条件付きリクエスト

conditional request

コンテンツの変更はAOPで管理され、コンテンツのエンティティタグ(ETag)は自動で更新されます。ETagを使ったHTTPの条件付きリクエストは計算資源の利用を最小化するだけでなく、304 Not Modifiedを返すだけの応答はネットワーク資源の利用も最小化します。

利用法

キャッシュ対象のクラスにドーナッツキャッシュの場合(埋め込みコンテンツがキャッシュ不可能な場合)は#[DonutCache]、それ以外の場合は#[CacheableResponse]とアトリビュートを付与します:

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public $headers = [
        RequestHeader::CACHE_CONTROL => CacheControl::NO_CACHE
    ];

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(int $id = 0): static
    {
        $this->body['article'] = 'hello world';
        return $this;
    }

    public function onDelete(int $id = 0): static
    {
        return $this;
    }
}

キャッシュ対象メソッドを選択したい場合は、クラスにアトリビュートを指定しないで、メソッドに指定します。その場合は、キャッシュ変更メソッドに#[RefreshCache]というアトリビュートを付与します:

class Todo extends ResourceObject
{
    #[CacheableResponse]
    public function onPut(int $id = 0, string $todo): static
    {
    }

    #[RefreshCache]
    public function onDelete(int $id = 0): static
    {
    }
}

どちらかの方法でアトリビュートを付与すると、概要で紹介した全ての機能が適用されます。イベントドリブン型コンテンツを想定してデフォルトでは時間(TTL)によるキャッシュの無効化は行われません。#[DonutCache]の場合はコンテンツ全体はキャッシュされず、#[CacheableResponse]の場合はされることに注意してください。

TTL

TTLの指定はDonutRepositoryInterface::put()で行います。ttlはドーナツの穴以外のキャッシュ時間、sMaxAgeはCDNのキャッシュ時間です:

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public function __construct(private DonutRepositoryInterface $repository)
    {
    }

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(): static
    {
        // process ...
        $this->repository->put($this, ttl: 10, sMaxAge: 100);
        return $this;
    }
}

TTLの既定値

イベントドリブン型コンテンツでは、コンテンツが変更されたらキャッシュにすぐに反映されなければなりません。そのため、既定値のTTLはCDNのモジュールのインストールによって変わります。

CDNがタグベースでのキャッシュ無効化をサポートしていれば、TTLは無期限(1年間)です。サポートのない場合は10秒です。キャッシュ反映時間は、Fastlyなら即時、Akamaiなら数秒、それ以外なら10秒が期待される時間です。

カスタマイズするにはCdnCacheControlHeaderを参考にCdnCacheControlHeaderSetterInterfaceを実装して束縛します。

キャッシュ無効化

手動でキャッシュを無効化するにはDonutRepositoryInterfaceのメソッドを用います。指定されたキャッシュだけでなく、そのETag、依存にしている他のリソースのキャッシュとそのETagが、サーバーサイドおよび可能な場合はCDN上のキャッシュも共に無効化されます:

interface DonutRepositoryInterface
{
    public function purge(AbstractUri $uri): void;
    public function invalidateTags(array $tags): void;
}

URIによる無効化

// example
$this->repository->purge(new Uri('app://self/blog/comment'));

タグによる無効化

$this->repository->invalidateTags(['template_a', 'campaign_b']);

CDNでタグの無効化

CDNでタグベースでのキャッシュ無効化を有効にするためには、PurgerInterfaceを実装して束縛する必要があります:

use BEAR\QueryRepository\PurgerInterface;

interface PurgerInterface
{
    public function __invoke(string $tag): void;
}

依存タグの指定

PURGE用のキーを指定するためにはSURROGATE_KEYヘッダーで指定します。複数文字列の場合はスペースをセパレータとして使用します:

use BEAR\QueryRepository\Header;

class Foo
{
    public $headers = [
        Header::SURROGATE_KEY => 'template_a campaign_b'
    ];
}

template_aまたはcampaign_bのタグによるキャッシュの無効化が行われた場合、FooのキャッシュとFooのETagはサーバーサイド、CDN共に無効になります。

リソースの依存

UriTagInterfaceを使ってURIを依存タグ文字列に変換します:

public function __construct(private UriTagInterface $uriTag)
{
}
$this->headers[Header::SURROGATE_KEY] = ($this->uriTag)(new Uri('app://self/foo'));

app://self/fooに変更があった場合、このキャッシュはサーバーサイド、CDN共に無効化されます。

連想配列をリソースの依存に

// bodyの内容
[
    ['id' => '1', 'name' => 'a'],
    ['id' => '2', 'name' => 'b'],
]

上記のようなbody連想配列から、依存するURIタグリストを生成する場合はfromAssoc()メソッドでURIテンプレートを指定します:

$this->headers[Header::SURROGATE_KEY] = $this->uriTag->fromAssoc(
    uriTemplate: 'app://self/item{?id}',
    assoc: $this->body
);

上記の場合、app://self/item?id=1およびapp://self/item?id=2に変更があった場合に、このキャッシュはサーバーサイド、CDN共に無効化されます。

設定

Redis Marshaller

Redisキャッシュアダプターでは、データの圧縮とシリアライズ方式を設定できます。

Marshallerは、PHPのオブジェクトや配列をRedisに保存する際のシリアライズと、取り出す際のデシリアライズを行います。

use BEAR\QueryRepository\StorageRedisDsnModule;

$this->install(
    new StorageRedisDsnModule(
        dsn: 'redis://localhost:6379',
        marshallingOptions: [
            'enabled' => true,
            'type' => 'deflate',      // 'default' または 'deflate'
            'use_igbinary' => true    // ext-igbinary が必要
        ]
    )
);

Marshallerの種類:

  • default: PHPの標準シリアライズを使用(use_igbinaryを有効にするとより効率的なバイナリ形式を使用)
  • deflate: データを圧縮してから保存(zlibを使用)

Redisのメモリ使用量を削減したい場合はdeflateを使用してください。CPU使用率とのトレードオフになります。

CDN特定

特定CDN対応のモジュールをインストールすると、ベンダー固有のヘッダーが出力されます:

$this->install(new FastlyModule());
$this->install(new AkamaiModule());

マルチCDN

CDNを多段構成にして、役割に応じたTTLを設定することもできます。例えば以下の図では、上流に多機能なCDNを配置して、下流にはコンベンショナルなCDNを配置しています。コンテンツの無効化などは上流のCDNに対して行い、下流のCDNはそれを利用するようにします。

multi cdn diagram

レスポンスヘッダー

CDNのキャッシュコントロールについてはBEAR.Sundayが自動で行い、CDN用のヘッダーを出力します。クライアントのキャッシュコントロールはコンテンツに応じてResourceObject$headerに記述します。

セキュリティやメンテナンスの観点から、このセクションは重要です。全てのResourceObjectCache-Controlを指定するようにしましょう。

キャッシュ不可

キャッシュができないコンテンツは必ず指定しましょう:

ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE

条件付きリクエスト

サーバーにコンテンツ変更がないかを確認してから、キャッシュを利用します。サーバーサイドのコンテンツの変更は検知され反映されます:

ResponseHeader::CACHE_CONTROL => CacheControl::NO_CACHE

クライアントキャッシュ時間の指定

クライアントでキャッシュされます。最も効率的なキャッシュですが、サーバーサイドでコンテンツが変更されても指定した時間には反映されません。またブラウザのリロード動作ではこのキャッシュは利用されません。<a>タグで遷移、またはURL入力した場合にキャッシュが利用されます:

ResponseHeader::CACHE_CONTROL => 'max-age=60'

レスポンス速度を重視する場合には、SWRの指定も検討しましょう:

ResponseHeader::CACHE_CONTROL => 'max-age=30 stale-while-revalidate=10'

この場合、max-ageの30秒を超えた時にオリジンサーバーからフレッシュなレスポンス取得が完了するまで、SWRで指定された最大10秒間はそれまでの古いキャッシュ(stale)レスポンスを返します。つまりキャッシュが更新されるのは最後のキャッシュ更新から30秒から40秒間の間のいずれかになりますが、どのリクエストもキャッシュからの応答になり高速です。

RFC7234対応クライアント

APIでクライアントキャッシュを利用する場合には、RFC7234対応APIクライアントを利用します:

プライベートキャッシュ

他のクライアントと共有しない場合にはprivateを指定します。クライアントサイドのみキャッシュが保存されます。この場合、サーバーサイドでキャッシュを指定しないでください。

ResponseHeader::CACHE_CONTROL => 'private, max-age=30'

キャッシュ設計

API(またはコンテンツ)は情報API(Information API)と計算API(Computation API)の2つに分類できます。計算APIは再現が難しく真に動的でキャッシュに不適なコンテンツです。一方の情報APIはDBから読み出され、PHPで加工されたとしても本質的には静的なコンテンツのAPIです。

適切なキャッシュを適用するためにコンテンツを分析します:

  • 情報APIか計算APIか
  • 依存関係は何か
  • 内包関係は何か
  • 無効化はイベントがトリガーか、それともTTLか
  • イベントはアプリケーションが検知可能か、監視が必要か
  • TTLは予測可能か不可能か

キャッシュ設計をアプリケーション設計プロセスの一部として捉え、仕様に含めることも検討しましょう。ライフサイクルを通してプロジェクトの安全性にも寄与するはずです。

アダプティブTTL

コンテンツの生存期間が予測可能で、その期間にイベントによる更新が行われない場合は、それをクライアントやCDNに正しく伝えます。

例えば株価のAPIを扱う場合、現在が金曜日の夜だとすると月曜の取引開始時間までは情報更新が行われないことが分かっています。その時間までの秒数を計算してTTLとして指定し、取引時間の時には適切なTTLを指定します。クライアントは更新がないと分かっているリソースにリクエストする必要はありません。

#[Cacheable]

従来の#[Cacheable]によるTTLキャッシュもサポートされます。

例)サーバーサイドで30秒キャッシュ、クライアントでも30秒キャッシュ。サーバーサイドで指定しているので、クライアントサイドでも同じ秒数でキャッシュされます:

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expirySecond: 30)]
class CachedResource extends ResourceObject
{

例)指定した有効期限($body['expiry_at']の日付)まで、サーバー、クライアント共にキャッシュ:

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expiryAt: 'expiry_at')]
class CachedResource extends ResourceObject
{

その他はHTTPキャッシュページをご覧ください。

結論

Webのコンテンツには情報(データ)型のものと計算(プロセス)型のものがあります。前者は本質的には静的ですが、コンテンツの変更や依存性の管理の問題で完全に静的コンテンツとして扱うのが難しく、コンテンツの変更が発生していないのにTTLによるキャッシュの無効化が行われていました。

BEAR.Sundayのキャッシングフレームワークは、情報型のコンテンツを可能な限り静的に扱い、キャッシュの力を最大化します。

用語


高性能サーバー

BEAR.Sundayアプリケーションは、リクエストごとのブートストラップオーバーヘッドを排除する高性能PHPサーバー上で実行できます。このガイドでは、Swoole、RoadRunner、FrankenPHPの3つのサーバーオプションについて説明します。

概要

従来のPHP-FPMでは、各リクエストごとにアプリケーション全体をブートストラップします:

Request -> Boot Framework -> Route -> Execute -> Response -> Shutdown
Request -> Boot Framework -> Route -> Execute -> Response -> Shutdown
Request -> Boot Framework -> Route -> Execute -> Response -> Shutdown

永続的なワーカーモードでは、アプリケーションは一度だけブートします:

Boot Framework (once)
    |
Request -> Route -> Execute -> Response
Request -> Route -> Execute -> Response
Request -> Route -> Execute -> Response

これによりブートオーバーヘッドが排除され、レイテンシが大幅に低下し、スループットが向上します。

BEAR.Sundayのステートレスなリソース設計とイミュータブルなアーキテクチャは、永続ワーカー環境と相性が良く、グローバル状態の問題なくワーカーモードに移行できます。

サーバー比較

機能 Swoole RoadRunner FrankenPHP
言語 C + PHP Go + PHP Go + PHP
ワーカーモード あり あり あり
HTTP/2 あり あり あり
HTTP/3 なし なし あり
WebSocket ネイティブ ネイティブ Caddy経由
コルーチン あり なし なし
ホットリロード 手動 あり あり
メモリ制限 共有 ワーカー単位 ワーカー単位

Dockerクイックスタート

bear-sunday-serversリポジトリは、3つのサーバーすべてのDocker設定を提供しています。

git clone https://github.com/bearsunday/bear-sunday-servers.git
cd bear-sunday-servers

# Swoole (port 8081)
cd swoole && docker compose up -d && curl http://localhost:8081/

# RoadRunner (port 8082)
cd roadrunner && docker compose up -d && curl http://localhost:8082/

# FrankenPHP (port 8080)
cd frankenphp && docker compose up -d && curl http://localhost:8080/

Swoole

Swooleは、イベント駆動の非同期I/Oを提供するコルーチンベースのPHP拡張です。

特徴

  • イベント駆動: 非同期I/O処理
  • コルーチン: スレッドなしの並行リクエスト処理
  • リクエスト分離: コルーチンコンテキストによるリクエスト分離
  • 高性能: リクエストごとのブートオーバーヘッドを排除
  • メモリ効率: ワーカー間でメモリを共有

インストール

Swoole拡張(ext-swoole ^6.1)

pecl install swoole

またはソースからコンパイル:

git clone https://github.com/swoole/swoole-src.git && \
cd swoole-src && \
phpize && \
./configure && \
make && make install

php.iniextension=swoole.soを追加してください。

BEAR.Swooleパッケージ

composer require bear/swoole

ブートストラップスクリプト

bin/swoole.phpを作成:

<?php

declare(strict_types=1);

require dirname(__DIR__) . '/autoload.php';

$bootstrap = dirname(__DIR__) . '/vendor/bear/swoole/bootstrap.php';

$context = getenv('BEAR_CONTEXT') ?: 'prod-hal-app';
$ip = getenv('SWOOLE_IP') ?: '0.0.0.0';
$port = (int) (getenv('SWOOLE_PORT') ?: 8080);

exit((require $bootstrap)(
    $context,
    'MyVendor\MyProject',
    $ip,
    $port
));

実行

php bin/swoole.php
Swoole http server is started at http://127.0.0.1:8080

環境変数

変数 デフォルト 説明
BEAR_CONTEXT prod-hal-app BEAR.Sundayコンテキスト
SWOOLE_IP 0.0.0.0 サーバーバインドアドレス
SWOOLE_PORT 8080 サーバーポート

開発時の注意

XdebugはSwooleのコルーチンと完全に互換性がありません。デバッグには:

  • var_dump() / error_log()を使用
  • またはSwooleを無効にしてPHPのビルトインサーバー + Xdebugを使用

Swooleは自動ホットリロードをサポートしていません。コード変更後は再起動が必要です:

# Dockerの場合
docker compose restart

# Dockerなしの場合
pkill -f swoole.php && php bin/swoole.php

並列実行

Swooleサーバーには2つの独立した関心事があります:

  • サーバー: アプリケーションをどう実行するか(このページ)
  • 並列実行: 埋め込みリソースをどう並行処理するか

BEAR.Asyncを使用すると、#[Embed]リソースがSwooleコルーチンで自動的に並列実行されます。詳細は並列リソース実行を参照してください。


RoadRunner

RoadRunnerは、PSR-7 PHPワーカーを持つ高性能Goアプリケーションサーバーです。

特徴

  • Goアプリケーションサーバー: 高性能プロセスマネージャー
  • PSR-7ワーカー: 標準HTTPメッセージインターフェース
  • ビルトインメトリクス: Prometheus互換エンドポイント
  • ホットリロード: ファイル変更時の自動ワーカー再起動

インストール

RoadRunnerバイナリ

リリースページからダウンロード、またはDockerを使用。

PHP依存関係

composer require spiral/roadrunner-http nyholm/psr7

設定

.rr.yamlを作成(bin/worker.phpの実装例を参照):

version: "3"

server:
  command: "php bin/worker.php"
  relay: pipes

http:
  address: "0.0.0.0:8082"
  pool:
    num_workers: 4
    max_jobs: 1000
    allocate_timeout: 60s
    destroy_timeout: 60s

logs:
  mode: production
  level: info
  output: stdout

status:
  address: "0.0.0.0:2112"

実行

./rr serve -c .rr.yaml

環境変数

変数 デフォルト 説明
BEAR_CONTEXT prod-hal-app BEAR.Sundayコンテキスト
MAX_REQUESTS 1000 ワーカー再起動までのリクエスト数

メトリクス

Prometheusメトリクスはhttp://localhost:2112/metricsで利用可能です。


FrankenPHP

FrankenPHPは、ワーカーモードをサポートするCaddyベースのモダンPHPアプリケーションサーバーです。

特徴

  • ワーカーモード: リクエストごとのアプリケーションブートコストを排除
  • HTTP/2 & HTTP/3: Caddyによる自動HTTPS
  • 本番環境対応: OPcache JIT、マルチステージビルド
  • 開発環境対応: Xdebug、ホットリロード

インストール

FrankenPHPは通常Dockerで使用します。スタンドアロンインストールについては、FrankenPHPドキュメントを参照してください。

Dockerで実行

docker run -v $PWD:/app -p 8080:8080 dunglas/frankenphp

環境変数

変数 デフォルト 説明
BEAR_CONTEXT prod-hal-app BEAR.Sundayコンテキスト
MAX_REQUESTS 1000 ワーカー再起動までのリクエスト数
SERVER_NAME :8080 リッスンアドレス
FRANKENPHP_NUM_WORKERS 4 ワーカープロセス数

メモリ管理

  • ワーカーはMAX_REQUESTS後に自動的に再起動してメモリリークを防止
  • 各リクエスト後にgc_collect_cycles()を実行
  • 無制限リクエストにはMAX_REQUESTS=0を設定(開発時のみ)

本番デプロイ

本番デプロイには、bear-sunday-serversの各サーバーディレクトリに以下が含まれています:

  • Dockerfile - 最適化された本番ビルド
  • docker-compose.prod.yml - 本番設定
  • ヘルスチェックエンドポイント
  • OPcache最適化

本番デプロイの例:

cd swoole  # または roadrunner, frankenphp
docker compose -f docker-compose.prod.yml up -d

関連

参考リンク


並列リソース実行 Alpha

BEAR.Asyncはこれまで逐次取得されていた#[Embed]埋め込みリソースを透過的に並列実行します。リソースのコードに手を入れることなく、並列実行用の起動スクリプトを用意するだけで、埋め込みリソースは自動的に並列取得に切り替わります。

概要

標準のBEAR.Sundayでは#[Embed]リソースは順次取得されますが、BEAR.Asyncでランタイム環境を選択すると並列に取得されます。

[順次実行]                       [並列実行]
Request                          Request
    │                                │
    ├── Embed 1 ──── 50ms            ├── Embed 1 ──┬── 50ms
    ├── Embed 2 ──── 50ms            ├── Embed 2 ──┤
    ├── Embed 3 ──── 50ms            ├── Embed 3 ──┤
    └── Embed 4 ──── 50ms            └── Embed 4 ──┘
    │                                │
Response (200ms)                 Response (50ms)

インストール

composer require bear/async

ランタイム環境

サーバー構成に応じて適切なランタイム環境を選択します。

用途 エントリポイント ランタイム設定
PHP-FPM / Apache(埋め込みリソースあり) bin/async.php ライブラリのbootstrap.phpAppModuleに並列ランタイムを重ねる
Swoole HTTPサーバー bin/swoole.php AsyncSwooleModuleAppModuleにインストール

並列実行(ext-parallel)

PHP-FPM / Apache上で動作する一般的なWebアプリケーション向けのランタイム環境です。ext-parallelのスレッドプールを使って#[Embed]を並列実行します。

bin/app.phpの隣にbin/async.phpを追加します。このエントリポイントはライブラリのbootstrap.phpに処理を委譲し、通常のAppModuleの上にext-parallelランタイムを重ねます。

bin/async.php → vendor/bear/async/bootstrap.php → AppModule + 並列ランタイム
<?php // bin/async.php

declare(strict_types=1);

require dirname(__DIR__) . '/autoload.php';

$bootstrap = dirname(__DIR__) . '/vendor/bear/async/bootstrap.php';
if (! file_exists($bootstrap)) {
    throw new LogicException('"bear/async" is not installed.');
}

$defaultContext = PHP_SAPI === 'cli' ? 'cli-hal-api-app' : 'hal-api-app';
$context = getenv('APP_CONTEXT') ?: $defaultContext;

exit((require $bootstrap)(
    $context,
    'MyVendor\MyApp',
    dirname(__DIR__),
    $GLOBALS,
    $_SERVER,
));

ワーカープールのサイズ(デフォルトはCPUコア数)を変更したい場合は、第6引数として明示的に指定します。

exit((require $bootstrap)($context, 'MyVendor\MyApp', dirname(__DIR__), $GLOBALS, $_SERVER, 8));

ext-parallelの制約

ワーカーは別スレッドで動作し、それぞれ独立したZendメモリ空間を持ちます。並列実行する埋め込みリソースは、順序に依存しない読み取り専用(冪等なGET)リソースにしてください。各ワーカーは独自のDIコンテナを持つため、リクエストローカルな可変状態や「同一インスタンスである」という前提はスレッド境界を越えて引き継がれません。

スレッド境界をまたぐ引数と戻り値はコピー可能でなければなりません。具体的にはスカラー値・null・それらをネストした配列です。オブジェクトやクロージャ、リソースを渡した場合は即座にエラーになります。並列実行される埋め込みリソースに適用するインターセプターは冪等に保ち、リクエストローカルな共有状態を書き換えないでください。

Swoole実行(ext-swoole)

すでにSwoole HTTPサーバー上で稼働しており、高い並行性能を求めるアプリケーション向けのランタイム環境です。

ext-parallelはワーカー(別スレッド)で動作するため別エントリポイントから選択しますが、ext-swooleは同一サーバープロセス内で動作するため、アプリケーションモジュールとしてインストールします。

use BEAR\Async\Module\AsyncSwooleModule;
use BEAR\Async\Module\PdoPoolEnvModule;

class AppModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new AsyncSwooleModule());
        $this->install(new PdoPoolEnvModule('PDO_DSN', 'PDO_USER', 'PDO_PASSWORD'));
    }
}

Swooleではコルーチン同士がメモリを共有するため、PdoPoolEnvModuleによる接続プールが必要です。読み取り中心で埋め込みリソースを多用する構成では、外部から到達するHTTPリクエスト数だけでなく、1リクエスト内で同時に実行される埋め込みの数も加味してプールサイズを見積もります。キュー待ちを避けたい場合は PDO_POOL_SIZE >= embed_count * request_concurrency を目安にし、DBへの同時接続数を抑えたい場合はあえて小さめに設定します。

技術ノート(プール接続の取得方式): プールからの接続取得はコルーチン単位で管理されます。同じコルーチン内でPDOExtendedPdoの両方が注入された場合でも、両者は同一の接続を共有し、コルーチン終了時にCoroutine::defer()で一度だけプールへ返却されます。これにより、1つの処理が意図せず2本の接続を握ることを防ぎます。さらに#[Embed]で埋め込まれたリクエストは遅延評価されるため、埋め込みリソースを#[Embed]で宣言した時点ではプールから接続を確保せず、各リクエストが実際に実行される時点まで取得を遅らせます。

技術ノート(PDOProxyの扱い): Swooleはコルーチン対応のためにPDOを独自にPDOProxyでラップしますが、BEAR.Asyncはこのラップを内部で吸収して通常のPDOとして扱えるようにします。何らかの理由で元のPDOを取り出せない場合は、リフレクション失敗をそのまま伝播させず、PDOプロキシ抽出専用のドメイン例外として扱います。

Swooleのコルーチンと有効化されたXdebugを併用すると安全に動作しません。Swoole用のエントリポイントはXdebugを読み込まないPHPで実行するか、ローカル確認時にはXDEBUG_MODE=offを設定してください。

使用方法

ランタイム環境を選択すると、既存の#[Embed]リソースは自動的に並列実行されます。

class Dashboard extends ResourceObject
{
    #[Embed(rel: 'user', src: '/user{?id}')]
    #[Embed(rel: 'notifications', src: '/notifications{?user_id}')]
    #[Embed(rel: 'stats', src: '/stats{?user_id}')]
    public function onGet(string $id): static
    {
        $this->body['id'] = $id;
        return $this;
    }
}

開発環境ではbin/app.phpで同期実行してデバッグし、本番環境ではbin/async.phpから起動して並列実行に切り替えます。

なぜコード変更なしで動くのか

BEAR.Sundayでは、情報がリソースとして URI で構造化されています。#[Embed]はそのリソースの実行結果ではなく、リソースリクエストそのものを埋め込み、リソース間の関係を宣言します。実行戦略 — 逐次・ext-parallelワーカー・Swooleコルーチン — を選ぶのは Linker の役割で、リソースクラスは自分が同期で呼ばれたか並列で呼ばれたかを知る必要がありません。

通常モードではレンダリング時にこれらのリクエストが1つずつ逐次解決されますが、並列実行モードでは、最初の埋め込みリクエストが解決される時点で残りの埋め込みリクエストもまとめて並列に実行されます。BEAR.Asyncの非同期リクエストはBEAR.Resourceの通常リクエストと同じ型として扱えるため、HALレンダラなど周辺の仕組みはこの差を意識せずシリアライズに統合できます。

非同期プログラミングでしばしば言われる「関数の色」問題 — 非同期関数を呼ぶ関数は自身も非同期でなければならず、コード全体が非同期に汚染される問題 — も、リソースという境界がこれを遮断します。同期と並列でコードは同じ、変わるのは実行戦略だけです。

これはBEAR.Async固有ではなく、BEAR.Sunday全体の性質です。MVCフレームワークが「どう実行するか」を手続きで書く箇所を、BEAR.Sundayはリソース間の関係を宣言として表します。宣言は実行戦略から独立しているため、戦略の差し替えはコードに影響しません。

デモとベンチマーク

BEAR.AsyncリポジトリにはSync・ext-parallel・Swooleの動作を比較できる、Dockerベースのデモとベンチマークスクリプトが含まれています。詳細はデモガイドベンチマーク結果を参照してください。

動作要件

各ランタイム環境は対応するPHP拡張を必要とします。

ランタイム環境 必要なもの アプリケーション側の変更
ext-parallel ZTS PHP + ext-parallel bin/async.phpを追加
ext-swoole ext-swoole AsyncSwooleModuleをインストール、bin/swoole.phpを使用

SQLバッチ実行

mysqliのネイティブ非同期サポートを使用した並列SQLクエリ実行も提供します。

use BEAR\Async\Module\MysqliEnvModule;

$this->install(new MysqliEnvModule(
    'MYSQLI_HOST',
    'MYSQLI_USER',
    'MYSQLI_PASSWORD',
    'MYSQLI_DATABASE',
));
use BEAR\Async\SqlBatch;
use BEAR\Async\SqlBatchExecutorInterface;

class MyService
{
    public function __construct(
        private SqlBatchExecutorInterface $executor,
    ) {}

    public function getData(int $userId): array
    {
        $results = (new SqlBatch($this->executor, [
            'user' => ['SELECT * FROM users WHERE id = :id', ['id' => $userId]],
            'posts' => ['SELECT * FROM posts WHERE user_id = :user_id', ['user_id' => $userId]],
            'comments' => ['SELECT * FROM comments WHERE user_id = :user_id', ['user_id' => $userId]],
        ]))();

        return [
            'user' => $results['user'][0] ?? null,
            'posts' => $results['posts'],
            'comments' => $results['comments'],
        ];
    }
}

参考リンク


コーディングガイド

プロジェクト

vendorは会社の名前やチームの名前または個人の名前(excite, koriym等)を指定して、packageにはアプリケーション(サービス)の名前(blog, news等)を指定します。 プロジェクトはアプリケーション単位で作成し、Web APIとHTMLを別ホストでサービスする場合でも1つのプロジェクトにします。

スタイル

PSR1, PSR2, PSR4に準拠します。

<?php
namespace Koriym\Blog\Resource\App;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\Code;
use BEAR\Resource\ResourceObject;

#[CacheableResponse]
class Entry extends ResourceObject
{
    public function __construct(
        private readonly ExtendPdoInterface $pdo,
        private readonly ResourceInterface $resource
    ) {}

    #[Embed(rel: "author", src: "/author{?author_id}")]
    public function onGet(string $author_id, string $slug): static
    {
        // ...
        return $this;
    }

    #[Link(rel: "next_action1", href: "/next_action1")]
    public function onPost(
        string $title,
        string $body,
        string $uid,
        string $slug
    ): static {
        // ...
        $this->code = Code::CREATED;
        return $this;
    }
}

リソースのdocBlockコメントはオプションです。リソースURIや引数名だけで説明不十分な時にメソッドの要約(一行)、説明(複数行可)、@paramsを付加します。

/**
 * A summary informing the user what the associated element does.
 *
 * A *description*, that can span multiple lines, to go _in-depth_ into the details of this element
 * and to provide some background information or textual references.
 *
 * @param string $arg1 *description*
 * @param string $arg2 *description*
*/

リソース

リソースについてのベストプラクティスはリソースのベストプラクティスをご覧ください。

コード

適切なステータスコードを返します。テストが容易になり、botやクローラーにも正しい情報を伝えることができます。

  • 100 Continue 複数のリクエストの継続
  • 200 OK
  • 201 Created リソース作成
  • 202 Accepted キュー/バッチ 受付
  • 204 No Content bodyがない場合
  • 304 Not Modified 未更新
  • 400 Bad Request リクエストに不備
  • 401 Unauthorized 認証が必要
  • 403 Forbidden 禁止
  • 404 Not Found
  • 405 Method Not Allowed
  • 503 Service Unavailable サーバーサイドでの一時的エラー

304#[Cacheable]アトリビュートを使っていると自動設定されます。404はリソースクラスがない場合、405はリソースのメソッドがない場合に自動設定されます。またDBの接続エラーなどは必ず503で返しクローラーに伝えます。

HTMLのFormメソッド

BEAR.SundayはHTMLのWebフォームでPOSTリクエストの時にX-HTTP-Method-Overrideヘッダーや_methodクエリーを用いてメソッドを上書きする事ができますが、推奨しているわけではありません。PageリソースではonGetonPost以外を実装しない方針でも問題ありません。

ハイパーリンク

  • リンクを持つリソースは#[Link]で示すことが推奨されます。
  • リソースは意味のまとまりのグラフにして#[Embed]で埋め込む事が推奨されます。

グローバル

グローバルな値をリソースやアプリケーションのクラスで参照することは推奨されません。(Modulesでのみ使用します)

  • スーパーグローバルの値を参照しない
  • defineは使用しない
  • 設定値を保持するConfigクラスを作成しない
  • グローバルなオブジェクトコンテナ(サービスロケータ)を使用しない
  • date関数やDateTimeクラスで現在時刻を直接取得することは推奨されません29。外部から時刻をインジェクトします。
  • スタティックメソッドなどのグローバルなメソッドコールも推奨されません。
  • アプリケーションコードが必要とする値は設定ファイルなどから取得するのではなく、全てインジェクトします。30

クラスとオブジェクト

  • トレイトは推奨されません。31
  • 親クラスのメソッドを子クラスが使うことは推奨されません。共通する機能は継承やtraitで共有ではなくクラスにしてインジェクトして使います。継承より合成します。

DI

  • 実行コンテキスト(prod, devなど)の値そのものをインジェクトしてはいけません。代わりにコンテキストに応じたインスタンスをインジェクトします。アプリケーションはどのコンテキストで動作しているのか無知にします。
  • ライブラリコードではセッターインジェクションは推奨されません。
  • Provider束縛を可能な限り避けtoConstructor束縛を優先することが推奨されます。
  • Moduleで条件に応じて束縛をすることを避けます。(AvoidConditionalLogicInModules)
  • モジュールのconfigure()から環境変数を参照しないで、コンストラクタインジェクションにします。

AOP

  • インターセプターの適用を必須にしてはいけません。例えばログやDBのトランザクションなどはインターセプターの有無でプログラムの本質的な動作は変わりません。
  • メソッド内の依存をインターセプターがインジェクトしないようにします。メソッド実装時にしか決定できない値は@Assistedインジェクションで引数にインジェクトします。
  • 複数のインターセプターがある場合にその実行順に可能な限り依存しないようにします。
  • 無条件に全メソッドに適用するインターセプターであればbootstrap.phpでの記述を考慮してください。
  • 横断的関心事と、本質的関心事を分けるために使われるものです。特定のメソッドのハックのためにインターセプトするような使い方は推奨されません。

スクリプトコマンド

  • composer setupコマンドでアプリケーションのセットアップが完了することが推奨されます。このスクリプトには、データベースの初期化や必要ライブラリの確認が含まれます。.envの設定など手動操作が必要な場合は、その手順を画面に表示することが推奨されます。

環境

  • Webだけでしか動作しないアプリケーションは推奨されません。テスト可能にするためにコンソールでも動作するようにします。
  • .envファイルをプロジェクトリポジトリに含まないことが推奨されます。
  • .envの代わりにスキーマを記述するKoriym.EnvJsonの利用を検討してください。

テスト

  • リソースクライアントを使ったリソーステストを中心にし、必要があればリソースの表現のテスト(HTMLなど)を加えます。
  • ハイパーメディアテストはユースケースをテストとして残すことができます。
  • prodはプロダクション用のコンテキストです。テストでprodコンテキストの利用は最低限、できれば無しにしましょう。

HTMLテンプレート

  • 大きなループ文を避けます。ループの中のif文はジェネレーターで置き換えられないか検討しましょう。

PHPDocタイプ

PHPは動的型付け言語ですが、psalmやphpstanといった静的解析ツールとPHPDocを使用することで、高度な型概念を表現し、静的解析時の型チェックの恩恵を受けることができます。このリファレンスでは、PHPDocで使用可能な型や関連する他の概念について説明します。

目次

  1. アトミック型
  2. 複合型
  3. 高度な型システム
  4. 型の演算子(ユーティリティ型)
  5. 関数型プログラミングの概念
  6. アサート注釈
  7. セキュリティ注釈
  8. 例:デザインパターンでの型の使用

アトミック型

これ以上分割できない基本的な型です。

スカラー型

/** @param int $i */
/** @param float $f */
/** @param string $str */
/** @param lowercase-string $lowercaseStr */
/** @param non-empty-string $nonEmptyStr */
/** @param non-empty-lowercase-string $nonEmptyLowercaseStr */
/** @param class-string $class */
/** @param class-string<AbstractFoo> $fooClass */
/** @param callable-string $callable */
/** @param numeric-string $num */ 
/** @param bool $isSet */
/** @param array-key $key */
/** @param numeric $num */
/** @param scalar $a */
/** @param positive-int $positiveInt */
/** @param negative-int $negativeInt */
/** @param int-range<0, 100> $percentage */
/** @param int-mask<1, 2, 4> $flags */
/** @param int-mask-of<MyClass::CLASS_CONSTANT_*> $classFlags */
/** @param trait-string $trait */
/** @param enum-string $enum */
/** @param literal-string $literalStr */
/** @param literal-int $literalInt */

複合型高度な型システムでこれらの型を組み合わせて使用できます。

オブジェクト型

/** @param object $obj */
/** @param stdClass $std */
/** @param Foo\Bar $fooBar */
/** @param object{foo: string, bar?: int} $objWithProperties */
/** @return ArrayObject<int, string> */
/** @param Collection<User> $users */
/** @return Generator<int, string, mixed, void> */

オブジェクト型はジェネリック型と組み合わせて使用することができます。

配列型

ジェネリック配列

/** @return array<TKey, TValue> */
/** @return array<int, Foo> */
/** @return array<string, int|string> */
/** @return non-empty-array<string, int> */

ジェネリック配列はジェネリック型の概念を使用しています。

オブジェクト風配列

/** @return array{0: string, 1: string, foo: stdClass, 28: false} */
/** @return array{foo: string, bar: int} */
/** @return array{optional?: string, bar: int} */

リスト

/** @param list<string> $stringList */
/** @param non-empty-list<int> $nonEmptyIntList */

PHPDoc配列(レガシー表記)

/** @param string[] $strings */
/** @param int[][] $nestedInts */

Callable型

/** @return callable(Type1, OptionalType2=, SpreadType3...): ReturnType */
/** @return Closure(bool):int */
/** @param callable(int): string $callback */

Callable型は高階関数で特に重要です。

値型

/** @return null */
/** @return true */
/** @return false */
/** @return 42 */
/** @return 3.14 */
/** @return "specific string" */
/** @param Foo\Bar::MY_SCALAR_CONST $const */
/** @param A::class|B::class $classNames */

特殊型

/** @return void */
/** @return never */
/** @return empty */
/** @return mixed */
/** @return resource */
/** @return closed-resource */
/** @return iterable<TKey, TValue> */

複合型

複数のアトミック型を組み合わせて作成される型です。

ユニオン型

/** @param int|string $id */
/** @return string|null */
/** @var array<string|int> $mixedArray */
/** @return 'success'|'error'|'pending' */

交差型

/** @param Countable&Traversable $collection */
/** @param Renderable&Serializable $object */

交差型はデザインパターンの実装で役立つことがあります。

高度な型システム

より複雑で柔軟な型表現を可能にする高度な機能です。

ジェネリック型

/**
 * @template T
 * @param array<T> $items
 * @param callable(T): bool $predicate
 * @return array<T>
 */
function filter(array $items, callable $predicate): array {
    return array_filter($items, $predicate);
}

ジェネリック型は高階関数と組み合わせて使用されることが多いです。

テンプレート型

/**
 * @template T of object
 * @param class-string<T> $className
 * @return T
 */
function create(string $className)
{
    return new $className();
}

テンプレート型は型の制約と組み合わせて使用できます。

条件付き型

/**
 * @template T
 * @param T $value
 * @return (T is string ? int : string)
 */
function processValue($value) {
    return is_string($value) ? strlen($value) : strval($value);
}

条件付き型はユニオン型と組み合わせて使用されることがあります。

型エイリアス

/**
 * @psalm-type UserId = positive-int
 * @psalm-type UserData = array{id: UserId, name: string, email: string}
 */

/**
 * @param UserData $userData
 * @return UserId
 */
function createUser(array $userData): int {
    // ユーザー作成ロジック
    return $userData['id'];
}

型エイリアスは複雑な型定義を簡略化するのに役立ちます。

型の制約

型パラメータに制約を加えることで、より具体的な型の要件を指定できます。

/**
 * @template T of \DateTimeInterface
 * @param T $date
 * @return T
 */
function cloneDate($date) {
    return clone $date;
}

// 使用例
$dateTime = new DateTime();
$clonedDateTime = cloneDate($dateTime);

この例では、T\DateTimeInterfaceを実装したクラスに制限されています。

共変性と反変性

ジェネリック型を扱う際には、共変性(covariance)と反変性(contravariance)の概念が重要になります。

/**
 * @template-covariant T
 */
interface Producer {
    /** @return T */
    public function produce();
}

/**
 * @template-contravariant T
 */
interface Consumer {
    /** @param T $item */
    public function consume($item);
}

// 使用例
/** @var Producer<Dog> $dogProducer */
/** @var Consumer<Animal> $animalConsumer */

共変性は、より派生した型(サブタイプ)を使用できることを意味し、反変性はより基本的な型(スーパータイプ)を使用できることを意味します。

型の演算子

型の演算子を使用して、既存の型から新しい型を生成できます。psalmではユーティリティ型と呼んでいます。

キー取得型と値取得型

  • key-of は、指定された配列またはオブジェクトのすべてのキーの型を取得し、value-of はその値の型を取得します。
/**
 * @param key-of<UserData> $key
 * @return value-of<UserData>
 */
function getUserData(string $key) {
    $userData = ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'];
    return $userData[$key] ?? null;
}

/**
 * @return ArrayIterator<key-of<UserData>, value-of<UserData>>
 */
function getUserDataIterator() {
    $userData = ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'];
    return new ArrayIterator($userData);
}

プロパティ取得型

properties-of は、クラスのすべてのプロパティの型を表します。これは、クラスのプロパティを動的に扱う場合に有用です。

class User {
    public int $id;
    public string $name;
    public ?string $email;
}

/**
 * @param User $user
 * @param key-of<properties-of<User>> $property
 * @return value-of<properties-of<User>>
 */
function getUserProperty(User $user, string $property) {
    return $user->$property;
}

// 使用例
$user = new User();
$propertyValue = getUserProperty($user, 'name'); // $propertyValue は string 型

properties-of には以下のバリアントがあります:

  • public-properties-of<T>: 公開プロパティのみを対象とします。
  • protected-properties-of<T>: 保護されたプロパティのみを対象とします。
  • private-properties-of<T>: プライベートプロパティのみを対象とします。

これらのバリアントを使用することで、特定のアクセス修飾子を持つプロパティのみを扱うことができます。

クラス名マッピング型

class-string-map は、クラス名をキーとし、そのインスタンスを値とする配列を表します。これは、依存性注入コンテナやファクトリーパターンの実装に役立ちます。

/**
 * @template T of object
 * @param class-string-map<T, T> $map
 * @param class-string<T> $className
 * @return T
 */
function getInstance(array $map, string $className) {
    return $map[$className] ?? new $className();
}

// 使用例
$container = [
    UserRepository::class => new UserRepository(),
    ProductRepository::class => new ProductRepository(),
];

$userRepo = getInstance($container, UserRepository::class);

インデックスアクセス型

インデックスアクセス型(T[K])は、型 T のインデックス K の要素を表します。これは、配列やオブジェクトのプロパティにアクセスする際の型を正確に表現するのに役立ちます。

/**
 * @template T of array
 * @template K of key-of<T>
 * @param T $data
 * @param K $key
 * @return T[K]
 */
function getArrayValue(array $data, $key) {
    return $data[$key];
}

// 使用例
$config = ['debug' => true, 'version' => '1.0.0'];
$debugMode = getArrayValue($config, 'debug'); // $debugMode は bool 型

これらのユーティリティ型はpsalm固有のもので高度な型システムの一部として考えることができます。

関数型プログラミングの概念

PHPDocは、関数型プログラミングの影響を受けた重要な概念をサポートしています。これらの概念を使用することで、コードの予測可能性と信頼性を向上させることができます。

純粋関数

純粋関数は、副作用がなく、同じ入力に対して常に同じ出力を返す関数です。

/**
 * @pure
 */
function add(int $a, int $b): int 
{
    return $a + $b;
}

関数の副作用がないこと、そして関数の結果が入力のみに依存することを明示できます。

不変オブジェクト

不変オブジェクトは、作成後に状態が変更されないオブジェクトです。

/**
 * @immutable
 * - すべてのプロパティは実質的に`readonly`として扱われます。
 * - すべてのメソッドは暗黙的に`@psalm-mutation-free`として扱われます。
 */
class Point {
    public function __construct(
        private float $x, 
        private float $y
    ) {}

    public function withX(float $x): static 
    {
        return new self($x, $this->y);
    }

    public function withY(float $y): static
    {
        return new self($this->x, $y);
    }
}

@psalm-mutation-free

このアノテーションは、メソッドがクラスの内部状態も外部の状態も変更しないことを示します。@immutableクラスのメソッドは暗黙的にこの性質を持ちますが、非イミュータブルクラスの特定のメソッドに対しても使用できます。

class Calculator {
    private float $lastResult = 0;

    /**
     * @psalm-mutation-free
     */
    public function add(float $a, float $b): float {
        return $a + $b;
    }

    public function addAndStore(float $a, float $b): float {
        $this->lastResult = $a + $b; // これは@psalm-mutation-freeでは許可されません
        return $this->lastResult;
    }
}

@psalm-external-mutation-free

このアノテーションは、メソッドがクラスの外部の状態を変更しないことを示します。内部状態の変更は許可されます。

class Logger {
    private array $logs = [];

    /**
     * @psalm-external-mutation-free
     */
    public function log(string $message): void {
        $this->logs[] = $message; // クラス内部の状態変更は許可されます
    }

    public function writeToFile(string $filename): void {
        file_put_contents($filename, implode("\n", $this->logs)); // これは外部状態を変更するため、@psalm-external-mutation-freeでは使用できません
    }
}

不変性アノテーションの使用ガイドライン

  1. クラス全体が不変である場合は @immutable を使用します。
  2. 特定のメソッドが状態を変更しない場合は @psalm-mutation-free を使用します。
  3. メソッドが外部の状態は変更しないが、内部状態を変更する可能性がある場合は @psalm-external-mutation-free を使用します。

不変性を適切に表現することで、並行処理での安全性向上、副作用の減少、コードの理解しやすさの向上など、多くの利点を得ることができます。

副作用の注釈

関数が副作用を持つ場合、それを明示的に注釈することで、その関数の使用に注意を促すことができます。

/**
 * @side-effect This function writes to the database
 */
function logMessage(string $message): void {
    // データベースにメッセージを書き込む処理
}

高階関数

高階関数は、関数を引数として受け取るか、関数を返す関数です。PHPDocを使用して、高階関数の型を正確に表現できます。

/**
 * @param callable(int): bool $predicate
 * @param list<int>           $numbers
 * @return list<int>
 */
function filter(callable $predicate, array $numbers): array {
    return array_filter($numbers, $predicate);
}

高階関数はCallable型と密接に関連しています。

アサート注釈

アサート注釈は、静的解析ツールに対して特定の条件が満たされていることを伝えるために使用されます。

/**
 * @psalm-assert string $value
 * @psalm-assert-if-true string $value
 * @psalm-assert-if-false null $value
 */
function isString($value): bool {
    return is_string($value);
}

/**
 * @psalm-assert !null $value
 */
function assertNotNull($value): void {
    if ($value === null) {
        throw new \InvalidArgumentException('Value must not be null');
    }
}

/**
 * @psalm-assert-if-true positive-int $number
 */
function isPositiveInteger($number): bool {
    return is_int($number) && $number > 0;
}

これらのアサート注釈は、以下のように使用されます:

  • @psalm-assert: 関数が正常に終了した場合(例外をスローせずに)、アサーションが真であることを示します。
  • @psalm-assert-if-true: 関数が true を返した場合、アサーションが真であることを示します。
  • @psalm-assert-if-false: 関数が false を返した場合、アサーションが真であることを示します。

アサート注釈は型の制約と組み合わせて使用されることがあります。

セキュリティ注釈

セキュリティ注釈は、コード内のセキュリティに関連する重要な部分を明示し、潜在的な脆弱性を追跡するために使用されます。主に以下の3つの注釈があります:

  1. @psalm-taint-source: 信頼できない入力源を示します。
  2. @psalm-taint-sink: セキュリティ上重要な操作が行われる場所を示します。
  3. @psalm-taint-escape: データが安全にエスケープまたはサニタイズされた場所を示します。

以下は、これらの注釈の使用例です:

/**
 * @psalm-taint-source input
 */
function getUserInput(): string {
    return $_GET['user_input'] ?? '';
}

/**
 * @psalm-taint-sink sql
 */
function executeQuery(string $query): void {
    // SQLクエリを実行
}

/**
 * @psalm-taint-escape sql
 */
function escapeForSql(string $input): string {
    return addslashes($input);
}

// 使用例
$userInput = getUserInput();
$safeSqlInput = escapeForSql($userInput);
executeQuery("SELECT * FROM users WHERE name = '$safeSqlInput'");

これらの注釈を使用することで、静的解析ツールは信頼できない入力の流れを追跡し、潜在的なセキュリティ問題(SQLインジェクションなど)を検出できます。

例:デザインパターンでの型の使用

型システムを活用して、一般的なデザインパターンをより型安全に実装できます。

ビルダーパターン

/**
 * @template T
 */
interface BuilderInterface {
    /**
     * @return T
     */
    public function build();
}

/**
 * @template T
 * @template-implements BuilderInterface<T>
 */
abstract class AbstractBuilder implements BuilderInterface {
    /** @var array<string, mixed> */
    protected $data = [];

    /** @param mixed $value */
    public function set(string $name, $value): static {
        $this->data[$name] = $value;
        return $this;
    }
}

/**
 * @extends AbstractBuilder<User>
 */
class UserBuilder extends AbstractBuilder {
    public function build(): User {
        return new User($this->data);
    }
}

// 使用例
$user = (new UserBuilder())
    ->set('name', 'John Doe')
    ->set('email', 'john@example.com')
    ->build();

リポジトリパターン

/**
 * @template T
 */
interface RepositoryInterface {
    /**
     * @param int $id
     * @return T|null
     */
    public function find(int $id);

    /**
     * @param T $entity
     */
    public function save($entity): void;
}

/**
 * @implements RepositoryInterface<User>
 */
class UserRepository implements RepositoryInterface {
    public function find(int $id): ?User {
        // データベースからユーザーを取得するロジック
    }

    public function save(User $user): void {
        // ユーザーをデータベースに保存するロジック
    }
}

まとめ

PHPDocの型システムを深く理解して適切に使用することで、コードの自己文書化、静的解析による早期のバグ検出、IDEによる強力なコード補完と支援、コードの意図と構造の明確化、セキュリティリスクの軽減などの利点が得られ、より堅牢で保守性の高いPHPコードを書くことができます。以下は利用可能な型を網羅した例です。

<?php

namespace App\Comprehensive\Types;

/**
 * アトミック型、スカラー型、ユニオン型、交差型、ジェネリック型を網羅するクラス
 * 
 * @psalm-type UserId = int
 * @psalm-type HtmlContent = string
 * @psalm-type PositiveFloat = float&positive
 * @psalm-type Numeric = int|float
 * @psalm-type QueryResult = array<string, mixed>
 */
class TypeExamples {
    /**
     * @param UserId|non-empty-string $id
     * @return HtmlContent
     */
    public function getUserContent(int|string $id): string {
        return "<p>User ID: {$id}</p>";
    }

    /**
     * @param PositiveFloat $amount
     * @return bool
     */
    public function processPositiveAmount(float $amount): bool {
        return $amount > 0;
    }
}

/**
 * イミュータブルクラス、関数型プログラミング、純粋関数の例
 * 
 * @immutable
 */
class ImmutableUser {
    /** @var non-empty-string */
    private string $name;

    /** @var positive-int */
    private int $age;

    /**
     * @param non-empty-string $name
     * @param positive-int $age
     */
    public function __construct(string $name, int $age) {
        $this->name = $name;
        $this->age = $age;
    }

    /**
     * @psalm-pure
     * @return ImmutableUser
     */
    public function withAdditionalYears(int $additionalYears): self {
        return new self($this->name, $this->age + $additionalYears);
    }
}

/**
 * テンプレート型、ジェネリック型、条件付き型、共変性と反変性の例
 * 
 * @template T
 * @template-covariant U
 */
class StorageContainer {
    /** @var array<T, U> */
    private array $items = [];

    /**
     * @param T $key
     * @param U $value
     */
    public function add(mixed $key, mixed $value): void {
        $this->items[$key] = $value;
    }

    /**
     * @param T $key
     * @return U|null
     */
    public function get(mixed $key): mixed {
        return $this->items[$key] ?? null;
    }
    
    /**
     * @template V
     * @param T $key
     * @return (T is string ? string : U|null)
     */
    public function get(mixed $key): mixed {
        return is_string($key) ? "default_string_value" : ($this->items[$key] ?? null);
    }
}

/**
 * 型の制約、ユーティリティ型、関数型プログラミング、アサート注釈の例
 * 
 * @template T of array-key
 */
class UtilityExamples {
    /**
     * @template T of array-key
     * @psalm-param array<T, mixed> $array
     * @psalm-return list<T>
     * @psalm-assert array<string, mixed> $array
     */
    public function getKeys(array $array): array {
        return array_keys($array);
    }

    /**
     * @template T of object
     * @psalm-param class-string-map<T, array-key> $classes
     * @psalm-return list<T>
     */
    public function mapClasses(array $classes): array {
        return array_map(fn(string $className): object => new $className(), array_keys($classes));
    }
}

/**
 * 高階関数、型エイリアス、インデックスアクセス型の例
 * 
 * @template T
 * @psalm-type Predicate = callable(T): bool
 */
class FunctionalExamples {
    /**
     * @param list<T> $items
     * @param Predicate<T> $predicate
     * @return list<T>
     */
    public function filter(array $items, callable $predicate): array {
        return array_filter($items, $predicate);
    }

    /**
     * @param array<string, T> $map
     * @param key-of<$map> $key
     * @return T|null
     */
    public function getValue(array $map, string $key): mixed {
        return $map[$key] ?? null;
    }
}

/**
 * セキュリティ注釈、型制約、インデックスアクセス型、プロパティ取得型、キー取得型、値取得型の例
 * 
 * @template T
 */
class SecureAccess {
    /**
     * @psalm-type UserProfile = array{
     *   id: int,
     *   name: non-empty-string,
     *   email: non-empty-string,
     *   roles: list<non-empty-string>
     * }
     * @psalm-param UserProfile $profile
     * @psalm-param key-of<UserProfile> $property
     * @return value-of<UserProfile>
     * @psalm-taint-escape system
     */
    public function getUserProperty(array $profile, string $property): mixed {
        return $profile[$property];
    }
}

/**
 * 非常に複雑な構造の型やセキュリティ・注釈、純粋関数の実装例
 * 
 * @template T of object
 * @template-covariant U of array-key
 * @psalm-type ErrorResponse = array{error: non-empty-string, code: positive-int}
 */
class ComplexExample {
    /** @var array<U, T> */
    private array $registry = [];

    /**
     * @param U $key
     * @param T $value
     */
    public function register(mixed $key, object $value): void {
        $this->registry[$key] = $value;
    }

    /**
     * @param U $key
     * @return T|null
     * @psalm-pure
     * @psalm-assert-if-true ErrorResponse $this->registry[$key]
     */
    public function getRegistered(mixed $key): ?object {
        return $this->registry[$key] ?? null;
    }
}

<?php

namespace App\Additional\Types;

/**
 * テンプレート型の制約とcontravariantの例
 * 
 * @template-contravariant T of \Throwable
 */
interface ErrorHandlerInterface {
    /**
     * @param T $error
     * @return void
     */
    public function handle(\Throwable $error): void;
}

/**
 * より具体的な型への実装例
 * 
 * @implements ErrorHandlerInterface<\RuntimeException>
 */
class RuntimeErrorHandler implements ErrorHandlerInterface {
    public function handle(\Throwable $error): void {
        // RuntimeExceptionの処理
    }
}

/**
 * 複雑な型の組み合わせと条件分岐の例
 * 
 * @psalm-type JsonPrimitive = string|int|float|bool|null
 * @psalm-type JsonArray = array<array-key, JsonValue>
 * @psalm-type JsonObject = array<string, JsonValue>
 * @psalm-type JsonValue = JsonPrimitive|JsonArray|JsonObject
 */
class JsonProcessor {
    /**
     * @param JsonValue $value
     * @return (JsonValue is JsonObject ? array<string, mixed> : (JsonValue is JsonArray ? list<mixed> : scalar|null))
     */
    public function process(mixed $value): mixed {
        if (is_array($value)) {
            return array_keys($value) === range(0, count($value) - 1) 
                ? array_values($value)
                : $value;
        }
        return $value;
    }
}

/**
 * より高度なタプル型とレコード型の例
 */
class AdvancedTypes {
    /**
     * @return array{0: int, 1: string, 2: bool}
     */
    public function getTuple(): array {
        return [42, "hello", true];
    }

    /**
     * @param array{id: int, name: string, meta: array{created: string, modified?: string}} $record
     * @return void
     */
    public function processRecord(array $record): void {
        // レコード型の処理
    }

    /**
     * @template T of object
     * @param class-string<T> $className
     * @param array<string, mixed> $properties
     * @return T
     */
    public function createInstance(string $className, array $properties): object {
        $instance = new $className();
        foreach ($properties as $key => $value) {
            $instance->$key = $value;
        }
        return $instance;
    }
}

/**
 * カスタム型ガードとアサーションの例
 */
class TypeGuards {
    /**
     * @psalm-assert-if-true non-empty-string $value
     */
    public function isNonEmptyString(mixed $value): bool {
        return is_string($value) && $value !== '';
    }

    /**
     * @template T of object
     * @param mixed $value
     * @param class-string<T> $className
     * @psalm-assert-if-true T $value
     */
    public function isInstanceOf(mixed $value, string $className): bool {
        return $value instanceof $className;
    }
}

/**
 * PHPUnit用のテスト関連の型アノテーションの例
 */
class TestTypes {
    /**
     * @param class-string<\Exception> $expectedClass
     * @param callable(): mixed $callback
     */
    public function expectException(string $expectedClass, callable $callback): void {
        try {
            $callback();
            $this->fail('Exception was not thrown');
        } catch (\Exception $e) {
            $this->assertInstanceOf($expectedClass, $e);
        }
    }

    /**
     * @template T
     * @param T $expected
     * @param T $actual
     * @param non-empty-string $message
     */
    public function assertEquals(mixed $expected, mixed $actual, string $message = ''): void {
        // 型安全な比較ロジック
    }
}

/**
 * コレクション型とイテレータの高度な例
 * 
 * @template-covariant TKey of array-key
 * @template-covariant TValue
 * @template-implements \IteratorAggregate<TKey, TValue>
 */
class TypedCollection implements \IteratorAggregate {
    /** @var array<TKey, TValue> */
    private array $items = [];

    /**
     * @return \Traversable<TKey, TValue>
     */
    public function getIterator(): \Traversable {
        yield from $this->items;
    }

    /**
     * @param TValue $item
     * @return void
     */
    public function add(mixed $item): void {
        $this->items[] = $item;
    }

    /**
     * @template TCallback
     * @param callable(TValue): TCallback $callback
     * @return TypedCollection<TKey, TCallback>
     */
    public function map(callable $callback): self {
        $result = new self();
        foreach ($this->items as $key => $value) {
            $result->items[$key] = $callback($value);
        }
        return $result;
    }
}

/**
 * 条件付きメソッドの例
 */
interface ConditionalInterface {
    /**
     * @template T
     * @param T $value
     * @return (T is numeric ? float : string)
     */
    public function process(mixed $value): mixed;
}

リファレンス

PHPDoc型を最大限に活用するためには、PsalmやPHPStanといった静的解析ツールが必要です。詳細については、以下のリソースを参照してください:


PHPDoc ユーティリティ型

ユーティリティ型は、既存の型を操作したり、動的に新しい型を生成するために使用される型です。これらの型を使用することで、より柔軟で表現力豊かな型定義が可能になります。

目次

  1. [key-of](#key-oft)
  2. [value-of](#value-oft)
  3. [properties-of](#properties-oft)
  4. class-string-map<T of Foo, T>
  5. T[K]
  6. Type aliases
  7. Variable templates

key-of

key-of<T> は、型 T のすべての可能なキーの型を表します。

/**
 * @template T of array
 * @param T $data
 * @param key-of<T> $key
 * @return mixed
 */
function getValueByKey(array $data, $key) {
    return $data[$key];
}

// 使用例
$userData = ['id' => 1, 'name' => 'John'];
$name = getValueByKey($userData, 'name'); // OK
$age = getValueByKey($userData, 'age'); // Psalmは警告を出します

value-of

value-of<T> は、型 T のすべての可能な値の型を表します。

/**
 * @template T of array
 * @param T $data
 * @return value-of<T>
 */
function getRandomValue(array $data) {
    return $data[array_rand($data)];
}

// 使用例
$numbers = [1, 2, 3, 4, 5];
$randomNumber = getRandomValue($numbers); // int型

properties-of

properties-of<T> は、型 T のすべてのプロパティの型を表します。

class User {
    public int $id;
    public string $name;
    public ?string $email;
}

/**
 * @param User $user
 * @param key-of<properties-of<User>> $property
 * @return value-of<properties-of<User>>
 */
function getUserProperty(User $user, string $property) {
    return $user->$property;
}

// 使用例
$user = new User();
$name = getUserProperty($user, 'name'); // string型
$id = getUserProperty($user, 'id'); // int型
$unknown = getUserProperty($user, 'unknown'); // Psalmは警告を出します

class-string-map<T of Foo, T>

class-string-map は、クラス名をキーとし、そのインスタンスを値とする配列を表します。

interface Repository {}
class UserRepository implements Repository {}
class ProductRepository implements Repository {}

/**
 * @template T of Repository
 * @param class-string-map<T, T> $repositories
 * @param class-string<T> $className
 * @return T
 */
function getRepository(array $repositories, string $className): Repository {
    return $repositories[$className];
}

// 使用例
$repositories = [
    UserRepository::class => new UserRepository(),
    ProductRepository::class => new ProductRepository(),
];

$userRepo = getRepository($repositories, UserRepository::class);

T[K]

T[K] は、型 T のインデックス K の要素を表します。

/**
 * @template T of array
 * @template K of array-key
 * @param T $data
 * @param K $key
 * @return T[K]
 */
function getArrayElement(array $data, $key) {
    return $data[$key];
}

// 使用例
$config = ['debug' => true, 'version' => '1.0.0'];
$debugMode = getArrayElement($config, 'debug'); // bool型

テスト

適切なテストは、ソフトウェアを継続性のある、より良いものにします。全ての依存がインジェクトされ、横断的関心事がAOPで提供されるBEAR.Sundayのクリーンなアプリケーションはテストフレンドリーです。

テスト実行

composerコマンドが用意されています。

composer test     // phpunitテスト
composer tests    // test + sa + cs
composer coverage // テストカバレッジ
composer pcov     // テストカバレッジ (pcov)
composer sa       // 静的解析
composer cs       // コーディングスタンダード検査
composer cs-fix   // コーディングスタンダード修復

リソーステストケース作成

全てがリソースのBEAR.Sundayではリソース操作がテストの基本です。Injector::getInstanceでリソースクライアントを取得してリソースの入出力テストを行います。

<?php
use BEAR\Resource\ResourceInterface;

class TodoTest extends TestCase
{
    private ResourceInterface $resource;
    
    protected function setUp(): void
    {
        $injector = Injector::getInstance('test-html-app');
        $this->resource = $injector->getInstance(ResourceInterface::class);
    }
    
    public function testOnPost(): void
    {
        $page = $this->resource->post('page://self/todo', ['title' => 'test']);
        $this->assertSame(StatusCode::CREATED, $page->code);
    }
}

テストダブル

テストダブル (Test Double) とは、ソフトウェアテストでテスト対象が依存しているコンポーネントを置き換える代用品のことです。テストダブルには以下のパターンがあります。

  • スタブ (テスト対象に「間接的な入力」を提供)
  • モック (テスト対象からの「間接的な出力」をテストダブルの内部で検証)
  • スパイ (テスト対象からの「間接的な出力」を記録)
  • フェイク (実際のオブジェクトに近い働きのより単純な実装)
  • ダミー (テスト対象の生成に必要だが呼び出しが行われない)

テスト対象のシステム(SUT)がテストダブルの出力を使用するのがスタブです。例えばいつもtrueを返すようなメソッドを持つテストダブルはスタブです。

モックはSUTからテストダブルへの間接的出力の検証をテストコードではなく、テストダブル内部で行います。スパイはモックと同じようにSUTの間接的出力の検証を行うためのものですが、その検証をテストコードで行うためにテストコードから読み取り可能な記録が行われます。

テストダブルの束縛

テスト用に束縛を変更する方法は2つあります。コンテキストモジュールで全テストの束縛を横断的に変更する方法と、1テストの中だけで一時的に特定目的だけで束縛を変える方法です。

コンテキストモジュール

TestModuleを作成してbootstrapでtestコンテキストを利用可能にします。

class TestModule extends AbstractModule
{
    public function configure(): void
    {
        $this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('1970-01-01 00:00:00'));
        $this->bind(Auth::class)->to(FakeAuth::class);
    }
}

テスト用束縛が上書きされたインジェクター:

$injector = Injector::getInstance('test-hal-app', $module);

一時的束縛変更

1つのテストのための一時的な束縛の変更はInjector::getOverrideInstanceで上書きする束縛を指定します。

スタブ、フェイク

public function testBindStub(): void
{
    $module = new class extends AbstractModule {
        protected function configure(): void
        {
            $this->bind(FooInterface::class)->to(FakeFoo::class);
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
}

モック

アサーションをテストダブル内部で実行します。

public function testBindMock(): void
{
    $mock = $this->createMock(FooInterface::class);
    // update()が一度だけコールされ、その際のパラメータは文字列'something'となることを期待
    $mock->expects($this->once())
         ->method('update')
         ->with($this->equalTo('something'));
         
    $module = new class($mock) extends AbstractModule {
        public function __construct(
            private FooInterface $foo
        ){}
        
        protected function configure(): void
        {
            $this->bind(FooInterface::class)->toInstance($this->foo);
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
}

スパイ

スパイ対象のインターフェイスまたはクラス名を指定してSpyModuleをインストールします。32 スパイ対象が含まれるSUTを動作させた後に、スパイログで呼び出し回数や呼び出しの値を検証します。

public function testBindSpy(): void
{
    $module = new class extends AbstractModule {
        protected function configure(): void
        {
            $this->install(new SpyModule([FooInterface::class]));
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
    $resource = $injector->getInstance(ResourceInterface::class);
    
    // 直接、間接に関わらずFooInterfaceオブジェクトのSpyログが記録されます
    $resource->get('/');
    
    // Spyログの取り出し
    $spyLog = $injector->getInstance(\Ray\TestDouble\LoggerInterface::class);
    // @var array<int, Log> $addLog
    $addLog = $spyLog->getLogs(FooInterface::class, 'add');
    
    $this->assertSame(1, count($addLog), 'Should have received once');
    // SUTからの引数の検証
    $this->assertSame([1, 2], $addLog[0]->arguments);
    $this->assertSame(1, $addLog[0]->namedArguments['a']);
}

ダミー

インターフェイスにNullオブジェクトを束縛するにはNull束縛を使います。

ハイパーメディアテスト

リソーステストは各エンドポイントの入出力テストです。対してハイパーメディアテストはそのエンドポイントをどう繋ぐかというワークフローの振る舞いをテストします。

Workflowテストは HTTPテストに継承され、1つのコードでPHPとHTTP双方のレベルでテストされます。その際HTTPのテストはcurlで行われ、そのリクエスト・レスポンスはログファイルに記録されます。

良いテストのために

  • 実装ではなく、インターフェイスをテストします。
  • モックライブラリを利用するよりフェイククラスを作成しましょう。
  • テストは仕様です。書きやすさよりも読みやすさを重視しましょう。

参考URL:


セキュリティ Beta

セキュリティツールでアプリケーションをスキャンして脆弱性診断ができます。静的解析・動的テスト・テイント解析・AI監査など、アーキテクチャを理解した専用ツールが多方面から解析するため、汎用ツールでは難しい脆弱性も検知します。

インストール

bear/securityをインストールします。

composer require --dev bear/security

スキャンツール

ツール 機能 使用タイミング
SAST33 静的解析でコード内の危険なパターンを検出 開発中
DAST34 動的解析でアプリに攻撃リクエストを送信 デプロイ前
AI Auditor AIがコードをレビュー コードレビュー時
Psalm Plugin ユーザー入力の流れを追跡 開発中

設計方針: Recall優先

セキュリティスキャナーには従来、Precision優先(確実なものだけ報告)とRecall優先(疑わしいものも報告)があり、トレードオフの関係にありました。

BEAR.SecurityはRecall優先を採用しています。脆弱性の見逃し(False Negative)は致命的ですが、偽陽性(False Positive)は確認すれば除外できるからです。偽陽性の確認をAIエージェントが代行できる今、この戦略はより効果的です。

推奨ワークフロー

# 1. SASTでパターンベースの脆弱性を検出
./vendor/bin/bear.security-scan src

# 2. 結果を確認し、脆弱性を修正
# 偽陽性には @security-ignore コメントを付与(下記例参照)

# 3. AI Auditorでビジネスロジックの問題を検出
./vendor/bin/bear-security-audit src

# 4. 検出された問題を確認・修正

偽陽性の抑制例:

$path = $this->buildPath($id); // @security-ignore PATH_TRAVERSAL_FILE_OPS: $id is validated integer from router

@security-ignoreを付与すると次回スキャンから抑制されます。

SAST

ソースコードをスキャンして危険なパターンを検出します。AIエージェント(Claude Code等)から実行し、検出結果の確認もAIに任せることを推奨します。

./vendor/bin/bear.security-scan src

14種類の脆弱性を検出:

カテゴリ
インジェクション SQLインジェクション、コマンドインジェクション、XSS
アクセス制御 パストラバーサル、オープンリダイレクト
暗号 弱いハッシュアルゴリズム、ハードコードされた秘密鍵
データ保護 安全でないデシリアライゼーション、XXE
セッション セッション固定、CSRF
ネットワーク SSRF、リモートファイルインクルージョン

各脆弱性の詳細は脆弱性リファレンスを参照してください。

DAST

実行中のアプリケーションに攻撃ペイロードを送信して脆弱性をテストします:

./vendor/bin/bear-security-dast 'MyVendor\MyApp' prod-app /path/to/app

テスト内容:

テスト 送信内容
SQLインジェクション ' OR '1'='1, ; DROP TABLE
XSS <script>alert(1)</script>
コマンドインジェクション ; ls -la, \| cat /etc/passwd
パストラバーサル ../../../etc/passwd
セキュリティヘッダー 欠落ヘッダーのチェック

AI Auditor

パターンマッチングでは検出できないセキュリティ問題をClaude AIが検出します:

# 方法1: APIキー
export ANTHROPIC_API_KEY=sk-ant-...
./vendor/bin/bear-security-audit src

# 方法2: Claude CLI(Maxプラン - APIキー不要)
claude auth login
./vendor/bin/bear-security-audit src
問題 説明
IDOR 認可チェックなしで他ユーザーのデータにアクセス
マスアサインメント 未検証のフィールドを更新で受け入れ
レースコンディション チェック時と使用時の競合
ビジネスロジック アプリケーション固有のセキュリティ欠陥

Psalm Plugin(テイント解析)

テイント解析は、ユーザー入力を汚染された変数とマークし、その汚染がコード内をどう伝播するかを追跡する静的解析手法です。汚染されたデータが適切なサニタイズなしにSQLクエリやHTML出力に到達した場合、脆弱性として報告します。

セットアップ

psalm.xmlにプラグインとスタブを追加:

<?xml version="1.0"?>
<psalm
    xmlns="https://getpsalm.org/schema/config"
    errorLevel="1"
>
    <projectFiles>
        <directory name="src"/>
    </projectFiles>
    <stubs>
        <file name="vendor/bear/security/stubs/AuraSql.phpstub"/>
        <file name="vendor/bear/security/stubs/PDO.phpstub"/>
        <file name="vendor/bear/security/stubs/Qiq.phpstub"/>
    </stubs>
    <plugins>
        <pluginClass class="BEAR\Security\Psalm\ResourceTaintPlugin">
            <targets>
                <target>Page</target>
                <target>App</target>
            </targets>
        </pluginClass>
    </plugins>
</psalm>

targetsで外部入力を受け取るリソースを指定します。htmlコンテキストでWebページを提供する場合はPageapiコンテキストでAPIを提供する場合はAppを指定します。

スタブ

スタブはサードパーティライブラリにテイントアノテーションを提供します:

スタブ 目的
AuraSql.phpstub SQLクエリメソッドをテイントシンクとしてマーク
PDO.phpstub PDOメソッドをテイントシンクとしてマーク
Qiq.phpstub テンプレート出力をテイントシンクとしてマーク

実行

テイント解析を実行:

./vendor/bin/psalm --taint-analysis

composer.jsonに便利スクリプトを追加:

{
    "scripts": {
        "security": "./vendor/bin/bear.security-scan src",
        "taint": "./vendor/bin/psalm --taint-analysis 2>&1 | grep -E 'Tainted' || true"
    },
    "scripts-descriptions": {
        "security": "Run SAST security scan",
        "taint": "Run Psalm taint analysis"
    }
}

以下で実行:

composer security
composer taint

GitHub Actions

CIパイプラインにセキュリティスキャンを追加できます:

cp vendor/bear/security/workflows/security-sast.yml .github/workflows/

このワークフローはプッシュとプルリクエストごとに実行されます:

ジョブ 機能
SAST Scan コードをスキャンしてGitHub Securityタブに結果をアップロード
Psalm Taint ユーザー入力の流れを追跡してGitHub Securityタブに結果をアップロード

結果はリポジトリの Security > Code scanning セクションに表示されます。

推奨手順: 初回はAIエージェントからスキャンを実行し、偽陽性に @security-ignore を付与してからCIを有効にしてください。

アーキテクチャとセキュリティ

BEAR.Sundayのアーキテクチャがセキュリティスキャンをより効果的にします:

  • 明確なエントリーポイント: すべてのエンドポイントはonGetonPostメソッドを持つResourceObjectです。スキャナーはすべての入力を特定してデータフローを追跡できます。

  • 隠れたマジックがない: 依存関係はコンストラクタインジェクションで明示的です。スキャナーは完全なコードパスを解析できます。

  • フレームワークを理解するAI: AI AuditorはBEAR.Sundayのパターンを理解し、一般的な脆弱性だけでなくビジネスロジックの欠陥も検出できます。

AIエージェント用プロンプト

AIコーディングアシスタントでbear/securityをセットアップするには、このプロンプトを使用してください:

Follow the setup instructions at:
https://raw.githubusercontent.com/bearsunday/BEAR.Skills/1.x/.claude/skills/bear-security-setup/SKILL.md

Examples

Coding Guideに従って作られたアプリケーションの例です。

Polidog.Todo

https://github.com/koriym/Polidog.Todo

基本的なCRUDのアプリケーションです。var/sqlディレクトリのSQLファイルでDBアクセスをしています。 ハイパーリンクを使ったREST APIとテスト、それにフォームのバリデーションテストも含まれます。

MyVendor.ContactForm

https://github.com/bearsunday/MyVendor.ContactForm

各種のフォームページのサンプルです。

  • 最小限のフォーム
  • 複数のフォーム
  • INPUTエレメントをループで生成したフォーム
  • チェックボックス、ラジオボタンを含んだプレビュー付きのフォーム

インジェクターアップグレードガイド

変更点

BEAR.Package 1.10 では従来のAppInjector, Bootstrap@deprecatedになり、統合されたInjectorになりました。

-AppInjector
-Bootstrap
+Injector

BEAR\Package\Injector::getInstance()ではコンテキストに応じたインジェクターが渡されます。 プロダクションでは従来のDIのスクリプトファイルを書き出すScriptInjector、開発用ではDIファイルを書き出さないRay\Di\Injectorが渡されます。

利用方法は変わりません。

$injector = Injector::getInstance($context);
$instance = $injector->getInstance($interface, name);

利点

  • ScriptInjectorは最初のリクエストでもファイル消去しなくなりより安全になります。

  • Ray\Di\Injectorvar\tmpにDIファイルを出力しません。開発時のインジェクションが(特にDockerで)高速になります。

  • コンパイルと実行の環境が違うコンテナ環境にも最適化されました。

  • テストが高速になります。

従来のAppInjectorではインジェクターインスタンスの取得を毎回行っていましたが、新しいInjectorではシングルトンでテスト間で共用されます。 速度が劇的に改善され、テスト毎のDB接続で接続数が枯渇するような事がありません。

アプリケーションやコンテキストを超えたアクセスが同一メモリ空間で可能になるほど、実装がクリーンに改善されました。 Swooleなど(PHPのシェアドナッシングアーキテクチャではない)のランタイム環境でもより安全、高速に動作します。

アップグレード方法

Step 1

src/Injector.phpにアプリケーションのInjectorを配置します。Vendor\Packageを自分のプロジェクト名に変更してください。

<?php
namespace Vendor\Package;

use BEAR\Package\Injector as PackageInjector;
use Ray\Di\InjectorInterface;

final class Injector
{
    private function __construct()
    {
    }

    public static function getInstance(string $context) : InjectorInterface
    {
        return PackageInjector::getInstance(__NAMESPACE__, $context, dirname(__DIR__));
    }
}

Step 2

bootstrap.phpを変更します。

-$app = (new Bootstrap)->getApp($name, $context, __DIR__);
+$app = Vendor\Package\Injector::getInstance($context)->getInstance(\BEAR\Sunday\Extension\Application\AppInterface::class);

Step 3

tests/で使用してるAppInjectorを変更します。

-new AppInjector('Vendor\Package', 'test-hal-api-app');
+\Vendor\Package\Injector::getInstance('test-hal-api-app');

複数アプリケーションのプロジェクトで、他のアプリケーションのインジェクターを取得する場合にはBEAR\PackageのInjectorを使います。

-new AppInjector('Vendor\Package', 'test-hal-api-app');
+\BEAR\Package\Injector::getInstance('Vendor\Package', 'test-hal-api-app', $appDir);

FCQN(完全修飾名)の長いクラス名はツールで変換するのが便利です。1

以上です。

互換性について

後方互換性は保たれます。@deprecateになったクラスも引き続き使用でき、廃止の予定もありません。 BEAR.Sundayはsemverを遵守します。



アトリビュート

BEAR.SundayはBEAR.Package ^1.10.3から従来のアノテーションに加えて、PHP8のアトリビュートをサポートします。

アノテーション

/**
 * @Inject
 * @Named('admin')
 */
public function setLogger(LoggerInterface $logger)

アトリビュート

#[Inject, Named('admin')]
public function setLogger(LoggerInterface $logger)
#[Embed(rel: 'weather', src: 'app://self/weather{?date}')]
#[Link(rel: 'event', href: 'app://self/event{?news_date}')]
public function onGet(string $date): self

引数に適用

アノテーションはメソッドにしか適用できず引数名を名前で指定する必要があるものがありましたが、PHP8では直接、引数のアトリビュートで指定することができます。

public function __construct(
    #[Named('payment')] LoggerInterface $paymentLogger,
    #[Named('debug')] LoggerInterface $debugLogger
)
public function onGet($id, #[Assisted] DbInterface $db = null)
public function onGet(#[CookieParam('id')] string $tokenId): void
public function onGet(#[ResourceParam(uri: 'app://self/login#nickname')] string $nickname = null): static

互換性

アトリビュートとアノテーションは1つのプロジェクトに混在することもできます。1 このマニュアルに表記されている全てのアノテーションはアトリビュートに変更しても動作します。

パフォーマンス

最適化されるため、プロダクション用にアノテーション/アトリビュート読み込みコストがかかることはほとんどありませんが、 以下のようにアトリビュートリーダーしか使用しないと宣言すると開発時の速度が向上します。

// tests/bootstap.php
use Ray\ServiceLocator\ServiceLocator;
ServiceLocator::setReader(new AttributeReader());
// DevModule
$this->install(new AttributeModule());


API Doc

アプリケーションがそのままドキュメントになります。

  • ApiDoc HTML: 開発者向けドキュメント
  • OpenAPI 3.1: ツールチェーン連携
  • JSON Schema: 情報モデル
  • ALPS: AIが理解できる語彙の意味論
  • llms.txt: AI向けアプリケーション概要

デモ

インストール

composer require bear/api-doc --dev
./vendor/bin/apidoc init

initコマンドはcomposer.jsonからapidoc.xmlを生成します。必要に応じて編集してください。

<apidoc>
    <appName>MyVendor\MyProject</appName>  <!-- アプリケーションの名前空間 -->
    <scheme>app</scheme>                    <!-- app または page -->
    <docDir>docs/api</docDir>
    <format>html</format>                   <!-- html, openapi など -->
</apidoc>

formatにはhtmlmdopenapillmsをカンマ区切りで指定できます。

使い方

コマンドラインからドキュメントを生成します。

./vendor/bin/apidoc

OpenAPI HTML生成

openapi形式を指定するとopenapi.jsonが生成されます。これをHTMLに変換するにはRedocly CLIを使用します。

npm install -g @redocly/cli
redocly build-docs docs/api/openapi.json -o docs/api/openapi.html

llms.txt

llmsフォーマットはllms.txt仕様に従ったllms.txtを生成します。出力にはAPIエンドポイント、リソースオブジェクト、インフラインターフェイス(Query/Command)、SQL文、エンティティ定義が含まれます。

Composerスクリプト

composer.jsonにスクリプトを追加すると便利です。

{
    "scripts": {
        "docs": "./vendor/bin/apidoc"
    },
    "scripts-descriptions": {
        "docs": "Generate API documentation"
    }
}
composer docs

GitHub Actions

mainブランチにプッシュすると、APIドキュメントが自動的に生成されGitHub Pagesに公開されます。再利用可能なワークフローがHTML生成、RedoclyによるOpenAPI変換、ALPS状態遷移図の作成を処理します。

name: API Docs
on:
  push:
    branches: [main]

jobs:
  docs:
    uses: bearsunday/BEAR.ApiDoc/.github/workflows/apidoc.yml@v1
    with:
      format: 'html,openapi,llms'

GitHub Pagesを有効化: Settings → Pages → Source: “GitHub Actions”

入力パラメータ

入力 デフォルト 説明
php-version '8.2' PHPバージョン
format 'html,openapi,llms' カンマ区切り: html, md, openapi, llms
docs-path 'docs' 出力ディレクトリ
publish-to 'github-pages' github-pagesまたはartifact-only

出力構造

docs/
├── index.html          # APIドキュメント
├── llms.txt            # AI向け概要
├── openapi.json        # OpenAPI仕様
└── schemas/
    ├── index.html      # スキーマ一覧
    └── *.json          # JSON Schema

設定ファイル

<?xml version="1.0" encoding="UTF-8"?>
<apidoc
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://bearsunday.github.io/BEAR.ApiDoc/apidoc.xsd">
    <appName>MyVendor\MyProject</appName>
    <scheme>app</scheme>
    <docDir>docs</docDir>
    <format>html</format>
    <alps>alps.json</alps>
</apidoc>
オプション 必須 説明
appName Yes アプリケーションの名前空間
scheme Yes appまたはpage
docDir Yes 出力ディレクトリ
format Yes html, md, openapi, llms
title   APIタイトル
alps   ALPSプロファイルのパス

プロファイル

ALPSプロファイルはAPIの語彙を定義します。定義を集中させることで表記揺れを防ぎ、理解共有を助けます。

{
    "$schema": "https://alps-io.github.io/schemas/alps.json",
    "alps": {
        "descriptor": [
            {"id": "firstName", "title": "The person's first name."},
            {"id": "familyName", "def": "https://schema.org/familyName"}
        ]
    }
}

Application as Documentation

コードこそが唯一の信頼できる情報源です。アプリケーションから生成されるドキュメントは実装と決して乖離しません。

llms.txtはAIが読み取れるアプリケーション概要を提供します。AIエージェントがアプリケーションに触れると、コードから直接生成されたこの単一ドキュメントを通じて全体構造を素早く把握できます。エンドポイントを列挙する一般的なAPIリファレンスとは異なり、llms.txtはDan Klynの情報アーキテクチャフレームワーク—Ontology、Taxonomy、Choreography—に従って完全な情報アーキテクチャを捉えます。ALPSの語彙意味論とJSON Schemaの情報モデルと組み合わせることで、AIエージェントはオペレーションだけでなく、その背後にある意味と構造を理解できます。

リファレンス

  • BEAR.ApiDoc - APIドキュメント生成ツール
  • ALPS - Application-Level Profile Semantics
  • JSON Schema - データ検証とドキュメンテーション
  • Redocly CLI - OpenAPIからHTMLへの変換

リファレンス

アトリビュート

アトリビュート 説明
#[CacheableResponse] キャッシュ可能なレスポンスを指定するアトリビュート。
#[Cacheable(int $expirySecond = 0)] リソースのキャッシュ可能性を指定するアトリビュート。$expirySecondはキャッシュの有効期間(秒)。
#[CookieParam(string $name)] クッキーからパラメータを受け取るためのアトリビュート。$nameはクッキーの名前。
#[DonutCache] ドーナツキャッシュを指定するアトリビュート。
#[Embed(src: string $src, rel: string $rel)] 他のリソースを埋め込むことを指定するアトリビュート。$srcは埋め込むリソースのURI、$relはリレーション名。
#[EnvParam(string $name)] 環境変数からパラメータを受け取るためのアトリビュート。$nameは環境変数の名前。
#[FormParam(string $name)] フォームデータからパラメータを受け取るためのアトリビュート。$nameはフォームフィールドの名前。
#[Inject] セッターインジェクションを指定するアトリビュート。
#[InputValidation] 入力バリデーションを行うことを指定するアトリビュート。
#[JsonSchema(key: string $key = null, schema: string $schema = null, params: string $params = null)] リソースの入力/出力のJSONスキーマを指定するアトリビュート。$keyはスキーマのキー、$schemaはスキーマファイル名、$paramsはパラメータのスキーマファイル名。
#[Link(rel: string $rel, href: string $href, method: string $method = null)] リソース間のリンクを指定するアトリビュート。$relはリレーション名、$hrefはリンク先のURI、$methodはHTTPメソッド。
#[Named(string $name)] 名前付きバインディングを指定するアトリビュート。$nameはバインディングの名前。
#[OnFailure(string $name = null)] バリデーション失敗時のメソッドを指定するアトリビュート。$nameはバリデーションの名前。
#[OnValidate(string $name = null)] バリデーションメソッドを指定するアトリビュート。$nameはバリデーションの名前。
#[Produces(array $mediaTypes)] リソースの出力メディアタイプを指定するアトリビュート。$mediaTypesは出力可能なメディアタイプの配列。
#[QueryParam(string $name)] クエリパラメータを受け取るためのアトリビュート。$nameはクエリパラメータの名前。
#[RefreshCache] キャッシュのリフレッシュを指定するアトリビュート。
#[ResourceParam(uri: string $uri, param: string $param = null)] 他のリソースの結果をパラメータとして受け取るためのアトリビュート。$uriはリソースのURI、$paramはパラメータ名。
#[ReturnCreatedResource] 作成されたリソースを返すことを指定するアトリビュート。
#[ServerParam(string $name)] サーバー変数からパラメータを受け取るためのアトリビュート。$nameはサーバー変数の名前。
#[Ssr(app: string $appName, state: array $state = [], metas: array $metas = [])] サーバーサイドレンダリングを指定するアトリビュート。$appNameはJSアプリケーション名、$stateはアプリケーションの状態、$metasはメタ情報の配列。
#[Transactional(array $props = ['pdo'])] メソッドをトランザクション内で実行することを指定するアトリビュート。$propsはトランザクションを適用するプロパティの配列。
#[UploadFiles] アップロードされたファイルを受け取るためのアトリビュート。
#[Valid(form: string $form = null, onFailure: string $onFailure = null)] リクエストの検証を行うことを指定するアトリビュート。$formはフォームクラス名、$onFailureは検証失敗時のメソッド名。

モジュール

モジュール名 説明
ApcSsrModule APCuを使用したサーバーサイドレンダリング用のモジュール。
ApiDoc APIドキュメントを生成するためのモジュール。
AppModule アプリケーションのメインモジュール。他のモジュールのインストールや設定を行う。
AuraSqlModule Aura.Sqlを使用したデータベース接続用のモジュール。
AuraSqlQueryModule Aura.SqlQueryを使用したクエリビルダー用のモジュール。
CacheVersionModule キャッシュのバージョン管理を行うモジュール。
CliModule コマンドラインインターフェース用のモジュール。
DoctrineOrmModule Doctrine ORMを使用したデータベース接続用のモジュール。
FakeModule テスト用のフェイクモジュール。
HalModule HAL (Hypertext Application Language) 用のモジュール。
HtmlModule HTMLレンダリング用のモジュール。
ImportAppModule 他のアプリケーションを読み込むためのモジュール。
JsonSchemaModule JSONスキーマを使用したリソースの入力/出力バリデーション用のモジュール。
JwtAuthModule JSON Web Token (JWT) を使用した認証用のモジュール。
NamedPdoModule 名前付きのPDOインスタンスを提供するモジュール。
PackageModule BEAR.Packageが提供する基本的なモジュールをまとめてインストールするためのモジュール。
ProdModule 本番環境用の設定を行うモジュール。
QiqModule Qiqテンプレートエンジン用のモジュール。
ResourceModule リソースクラスに関する設定を行うモジュール。
AuraRouterModule Aura.Routerのルーティング用のモジュール。
SirenModule Siren (Hypermedia Specification) 用のモジュール。
SpyModule メソッドの呼び出しを記録するためのモジュール。
SsrModule サーバーサイドレンダリング用のモジュール。
TwigModule Twigテンプレートエンジン用のモジュール。
ValidationModule バリデーション用のモジュール。
  1. 1つのメソッドで混在するときはアトリビュートが優先されます。  2 3 4 5 6

  2. PHPStorm データベースツールおよび SQL  2

  3. parse_str参照  2 3

  4. このSQLはSQLスタイルガイドに準拠しています。PhpStormからはJoe Celkoとして設定できます。 

  5. データベース図などでクエリプランや実行計画を確認し、作成するSQLの質を高めます。 

  6. PHP 8.0+ 名前付き引数 ¶。PHP 7.xの場合にはカラムの順番になります。 

  7. ここでは例としてMySQLから直接実行していますが、マイグレーションツールでシードを入力したりIDEのDBツールの利用方法も学びましょう。 

  8. Ray.MediaQueryはHTTP APIリクエストにも対応しています。 

  9. このようなコンテンツの階層構造のことを、IA(インフォメーションアーキテクチャ)ではタクソノミーと呼びます。Understanding Information Architecture参照 

  10. Ray.MediaQuery README 

  11. 広く誤解されていますが、統一インターフェイスはHTTPメソッドのことではありません。Uniform Interface参照 

  12. いわゆる”Restish API”。REST APIと紹介されている多くのAPIはこのURI/オブジェクトスタイルで、RESTが誤用されています。 

  13. チュートリアルからリンクを取り除けばURIスタイルになります。 

  14. https://www.iana.org/assignments/media-types/media-types.xhtml 

  15. オブジェクトは、他のオブジェクトを保持しているか、保持されているかによってつながっています。これをObject Graphといい、$appはそのルートオブジェクトとなります。 

  16. 依存性逆転の法則 

  17. コンテキストを変えると、プライベートなappリソースを外部公開することもできます。例えばHTMLアプリケーションでpageリソースがHTMLを出力し(この時appリソースはプライベート)、モバイルアプリケーションではappリソースがAPIとして公開しJSONを出力することができます。 

  18. RESTのメソッドはCRUDとのマッピングではありません。リソース状態を変えない安全なものか、冪等性があるかなどで分けられます。 

  19. https://www.rfc-editor.org/rfc/rfc5789 

  20. レスポンスの情報取得にはJsonSchemaの指定が必要です。 

  21. PHP8.xでは名前付き引数で呼ばれますが、PHP7.xでは順序引数でコールされます。 

  22. APIリクエストをJSONで送信する場合にはcontent-typeヘッダーにapplication/jsonをセットしてください。 

  23. out-bound links 例)HTMLは関連した他のHTMLにリンクを張ることができます。 

  24. embedded links 例)HTMLは独立した画像リソースを埋め込むことができます。 

  25. DIで依存関係のツリーがグラフになっているオブジェクトグラフと同様です。 

  26. query-locatorはSQLをファイルとして扱うライブラリです。Aura.Sqlと組み合わせると便利です。 

  27. JavaのDBアクセスフレームワークDomaと仕組みが似ています。 

  28. 名前はSmalltalkのフレームワーク Seasideの同様の機能が由来しています。 

  29. koriym/now 

  30. Web APIなど外部のシステムの値を利用する時には、クライアントクラスやWeb APIアクセスリソースなど1つの場所に集中させDIやAOPでモッキングが容易にするようにします。 

  31. ResourceInjectなどのインジェクション用トレイトはインジェクションのボイラープレートコードを削減するために存在しましたが、PHP8で追加されたコンストラクタの引数をプロパティへ昇格させる機能により意味を失いました。コンストラクタインジェクションを使いましょう。 

  32. SpyModuleの利用にはray/test-doubleのインストールが必要です。 

  33. Static Application Security Testing 

  34. Dynamic Application Security Testing