PHP用ORM Emonkak\Ormの概要
背景
データベースを利用したWebアプリケーションをインフラストラクチャ(技術的機能)の観点から単純化すると、クライアントのリクエストを入力に、データベースに格納されたデータをCRUD(Create/Read/Update/Delete)して、その結果をレスポンスとして出力するものと定義できる。
この定義からいうと、リクエストとレスポンスの入出力を管理するのがWAF(Web Application Framework)で、データベースに格納されたデータをCRUDするのがORM(Object-relational mapping)となる。 両者はWebアプリケーションを構成するインフラストラクチャのコアであり、特にORMはドメインモデルの設計にも大きな影響を与えるのでその重要性はより高い。
そのような中、ORMといえばRuby On RailsのActive Recordに代表されるような重厚長大なものが主流だ。 一方で、ドメインモデルを特定のインフラストラクチャ(ORM)に依存することを避けるという観点からは、よりシンプルで依存性の少ないORMの方が望ましい。
ドメインの関心とインフラストラクチャの関心を適切に分離して、それぞれの関心に注力することでシンプルかつ明瞭な設計を実現する。 そのために新しいORMが必要だった。
コンセプト
Emonkak\Ormはフレームワークではなくライブラリとして設計した。
ここで言うフレームワークとライブラリの違いは以下の点にある:
- フレームワークは基底クラスから継承するか、インターフェイスを実装して、新しいクラスを定義して利用する
- ライブラリは提供されるクラスのインスタンスを生成するだけで利用できる
- フレームワークは機能を自分で明示的に呼び出すのではなく、フレームワークによって呼び出される(制御の逆転)
- ライブラリは自分で明示的に呼び出さない限り何もしない
ここでフレームワークとしてのORMの問題を考えてみる。
よくある例としては、ドメインモデル(エンティティ)の実装にORMが提供する基底クラスを継承しなければならないということがある。 これでは、ドメインとインフラストラクチャの関心が混ざり合い、適切に分離するのが難しい。 ORMが提供するモデルとドメインモデルを別個のものとして設計するという考えもあるが、冗長な実装になりがちでデメリットの方が大きいだろう。
制御の逆転を利用したフレームワークの難点としては、単純にわかりづらいという問題がある。 どのように機能が呼び出されるのかは、フレームワークの動作を知らなければわからないからだ。
そこで、Emonkak\Ormはあくまでインフラストラクチャの実装を助けるためのライブラリであると位置付け、ドメインの設計に影響を与えないようにした。 いわゆるリポジトリの実装に使うための一つのライブラリに過ぎないということだ。
特徴
Emonkak\Ormはクエリビルダー、リレーション、オブジェクトへのマッピングと基本的な機能しか提供しない。 データベースの抽象はEmonkak\Databaseが、結果セットのシーケンス処理はEmonkak\Enumerableと別のライブラリによって提供されるものを利用する。
- SELECT, INSERT, UPDATE, DELETE文のクエリビルダー
- カスタマイズ可能なマッピング動作
- 一対一、一対多、多対多のリレーション
- アクセスされるまで読み込みが遅延される遅延リレーション
- PSR-6 Caching Interfaceを利用したキャッシュ付きのリレーション
- ページネーション
使い方
※すべて開発中のものなので変更可能性がある
基本的な使い方としてはクエリビルダーを作成してクエリを構築、用途に合った実行メソッドを呼ぶ形になる。
SELECT文を発行して結果セットを取得する
クエリの実行結果を得るための結果セットを取得するには getResult()
メソッドを呼び出す。
getResult()
実行時点ではまだクエリは実行されておらず、foreach
で走査するなどして、実際に結果を得ようとして初めてクエリは実行される。
結果セットは Emonkak\Enumerable\EnumerableInterface
を実装しているので、例えば first()
メソッドを呼び出すことで一件だけ取り出すことができる。
<?php use Emonkak\Database\PDOInterface; use Emonkak\Orm\Fetcher\PopoFetcher; use Emonkak\Orm\SelectBuilder; $users = (new SelectBuilder()) ->from('users') ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class)); $user = (new SelectBuilder()) ->from('users') ->where('user_id', '=', 1) ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class)) ->first();
ここで PopoFetcher
というのが出てきたが、これは Emonkak\Orm\Fetcher\FetcherInterface
の実装クラスで、 Emonkak\Database\PDOStatementInterface
からどのように値を取得するのかを決めるものだ。
PopoFetcher
の場合は指定されたクラスのインスタンスを作成して、それをPOPO(Plain Old PHP Object)としてプロパティに値を代入する。
POPOとはPOJO(Plain Old Java Object)のPHP版で、マジックメソッドなどを利用しないごく普通のPHPオブジェクトを表す。
POPOの実装例:
<?php class User { private $user_id; private $first_name; private $last_name; public function getUserId() { return $this->user_id; } public function getFirstName() { return $this->first_name; } public function getLastName() { return $this->last_name; } }
FetcherInterface
の別の実装としては指定されたクラスのコンストラクターに連想配列を与えてインスタンスを作成する ModelFetcher
と、オブジェクトにマッピングせずに連想配列をそのまま返す ArrayFetcher
の実装がある。
ModelFetcher
はActiveRecordタイプのORMにあるようなModelクラスを利用したい場合に役立つ。
ページネーションを利用する
指定された件数ごとにページ分けされた結果セットを取得するには paginate()
メソッドを呼び出す。
<?php use Emonkak\Database\PDOInterface; use Emonkak\Orm\Fetcher\PopoFetcher; use Emonkak\Orm\SelectBuilder; $userPages = (new SelectBuilder()) ->from('users') ->where('user_id', '=', 1) ->paginate($pdo /** @var PDOInterface */, new PopoFetcher(User::class), 100 /** @var integer ページ分けの単位 */); $users = $userPages->at(1); // 1ページ目を取得 $userPages->getNumItems(); // 総アイテム数を取得 $userPages->getNumPages(); // 総ページ数を取得
更新系のクエリを実行する
結果行がない更新系のクエリを実行する際は execute()
メソッドを利用する。
<?php use Emonkak\Database\PDOInterface; use Emonkak\Orm\UpdateBuilder; $stmt = (new InsertBuilder()) ->into('users', ['first_name', 'last_name']) ->values(['foo', 'bar']) ->execute($pdo /** @var PDOInterface */); $stmt = (new UpdateBuilder()) ->table('users') ->set('first_name', 'foo') ->set('last_name', 'bar') ->execute($pdo /** @var PDOInterface */); $stmt = (new DeleteBuilder()) ->from('users') ->where('user_id', '=', 1) ->execute($pdo /** @var PDOInterface */);
簡易クエリビルダー
Emonkak\Orm\Sql
クラスはSQL文字列と、プレースホルダーにバインドする値のペアを表すクラスだが、簡易的なクエリビルダーとしても機能する。
また、 Sql
オブジェクトを各種クエリビルダーのメソッドに渡すことで、クエリビルダー単体では実現できないような複雑なクエリも表現できる。
<?php use Emonkak\Database\PDOInterface; use Emonkak\Orm\Fetcher\PopoFetcher; use Emonkak\Orm\Relation\Relations; use Emonkak\Orm\SelectBuilder; use Emonkak\Orm\Sql; $users = (new Sql('SELECT')) ->append('*') ->append('FROM') ->append('users') ->append('WHERE') ->append('user_id IN')->appendValues([1, 2, 3]) ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class)); // SQLの断片を条件式に指定することもできる $users = (new SelectBuilder()) ->from('users') ->where(new Sql('user_id = ?', [1])) ->getResult();
リレーションを利用する
リレーションは SelectBuilder
あるいは Sql
クラスの with()
メソッドを利用することで行う。
with()
に与えるリレーションは Emonkak\Orm\Relation\Relations
が提供するファクトリメソッドで作成できる。
リレーションはネスト可能で、Emonkak\Orm\Relation\RelationInterface
の with()
メソッドを使うことでネストできる。
作成できるリレーションの種類としては以下のものがある:
Relations::oneToOne()
: 一対一Relations::oneToMany()
: 一対多Relations::manyToMany()
: 多対多Relations::lazyOneToOne()
: 遅延一対一Relations::lazyOneToMany()
: 遅延一対多Relations::cachedOneToOne()
: キャッシュ付きの一対一Relations::cachedOneToMany()
: キャッシュ付きの一対多
遅延リレーションについては別記事にて詳しい記述がある。
リレーション作成時に指定するパラメータは以下ものがある:
$tableName:
string
リレーション先のテーブル名。
$relationKey:
string
リレーション先のオブジェクトを格納するプロパティ名。
$outerKey:
string
リレーション元のテーブルの結合対象のカラム名。
$innerKey:
string
リレーション先のテーブルの結合対象のカラム名。
$connection:
Emonkak\Database\PDOInterface
リレーション先のテーブルのデータを取得するためのDBコネクション。 リレーションごとに指定するので、それぞれのリレーション先のテーブルが別のDBにあっても良い。
$fetcher:
Emonkak\Orm\Fetcher\FetcherInterface
リレーションのクエリの結果からどのように値を取得するかを決定する
FetcherInterface
のインスタンス。 リレーションごとに指定するので、全く別のクラスか、あるいは連想配列をも混在してリレーションさせることもできる。$proxyFactory:
ProxyManager\Factory\LazyLoadingValueHolderFactory
(Optional)遅延リレーションを利用する場合のプロキシオブジェクトを作成するためのファクトリ。
$cachePool:
Psr\Cache\CacheItemPoolInterface
キャッシュ付きのリレーションを利用する場合のキャッシュプール。
$cachePrefix:
string
キャッシュ付きのリレーションを利用する場合のキャッシュキーのプレフィックス。
$lifetime:
integer
キャッシュ付きのリレーションを利用する場合のキャッシュの有効期限。
$builder:
Emonkak\Orm\SelectBuilder
(Optional)リレーション先のテーブルからデータを取得するためのクエリを生成するクエリビルダー。 リレーションする条件を特別に指定したい場合に利用する。
以下はユーザー(User)が投稿(Post)と、それに対するコメント(Comment)を持っているというリレーションの例だ:
<?php use Emonkak\Database\PDOInterface; use Emonkak\Orm\Fetcher\PopoFetcher; use Emonkak\Orm\Relation\Relations; use Emonkak\Orm\SelectBuilder; $hasPosts = Relations::oneToMany( 'posts', 'posts', 'user_id', 'user_id', $pdo /** @var PDOInterface */, new PopoFetcher(Post::class) ); $hasComments = Relations::oneToMany( 'comments', 'comments', 'post_id', 'post_id', $pdo /** @var PDOInterface */, new PopoFetcher(Comment::class) ); $users = (new SelectBuilder()) ->with($hasPosts->with($hasComments)) ->from('users') ->where('user_id', '=', 1) ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));
おわりに
Web開発の世界は長らくフルスタックのフレームワークが主流で、小さなライブラリを組合せてアプリケーションを構築するという文化があまりなかった。 しかし、PHPの世界ではPRR-7 HTTP message interfaceの登場以降、様々なソフトウェアが登場し、小さなものを組合せて大きなアプリケーションを作るというUnix哲学にも通ずる考えが徐々に普及してきた。
さらに近年はDDD(Domain-Driven Design)の考えが広く浸透つつあるのもあってか、アプリケーションのアーキテクチャについてさまざまな論考が出てきている。 アーキテクチャはアプケーションの構成部品をどう分割し、どう組み合せるかということで決まる。 そのような中、構成部品の組み合わせ方に制限を加えてしまうようなフレームワークは、アーキテクチャの決定にも制限を与えてしまうだろう。
そこで、アプリケーションの要件に適合するフレームワークを、小さなのライブラリの組み合わせで独自に構築するというのが一つの考えだ。 Emonkak\Ormはこの考えを元に開発されたライブラリである。
エンティティのライフサイクルに基いたクラス設計
エンティティとはその属性によってではなく識別子によって識別されるオブジェクトである。 属性が違っていても識別子が同一であればそのエンティティは同一のものと見なされる。 そのためエンティティは多くの場合、変更可能(Mutable)なオブジェクトとして表現される。 変更可能(Mutable)なオブジェクトは最初に生成されてから、それ以後に状態を変えることがある。 この状態遷移の流れがエンティティのライフサイクルである。
では、エンティティの状態はどのように変化するだろう。 それは、ドメインモデルを使って業務手順を記述したユースケースによって決まる。 ユースケースでは業務手順に基いてエンティティを作成し、状態を変更し、あるいは削除する。 これがエンティティのライフサイクルだ。
PHPにおけるエンティティの実装
エンティティはユースケースによってその状態を変えるが、ドメインモデルの一部であるエンティティをドメインのルールに反するような状態にしてはならない。 エンティティがドメインのルールに反する状態にあるというのは、バグに他ならないからだ。 実装レベルでエンティティがドメインのルールに反する状態にすることをできなくするのが望ましい。
基本的な方針としてエンティティの持つプロパティが、DBの列名と一致するように実装する。
プロパティは自由に参照・変更できないように private
に隠蔽しておく。
class Person { private $person_id; private $first_name; private $last_name; ... }
コンストラクター
エンティティのコンストラクターは private
にして隠蔽しておく。
自由にインスタンスを作られては状態を適切に管理できないからだ。
インスタンスの生成は後述のFactory Methodによって行う。
private function __construct()
{
}
ゲッター
エンティティの属性値として、ただの文字列(String)や数値(Integer)ではドメインの関心を表現するオブジェクトして適切ではないことがある。 その場合はプロパティをそのまま返すのはでなく、ドメインモデル(値オブジェクト)を返すようにする。 そうでない場合はそのままプロパティを返却する。
public function getName() { return new PersonName($this->first_name, $this->last_name); }
セッター
単純なプロパティのセッターはすべて private
にして隠蔽しておく。
自由に属性を設定できては状態を適切に管理できない。
外から状態の変更するメソッドはセッターではなくドメインに基いた振舞いとして別途適切に実装する。
セッターで設定される値を検証して値がエンティティの属性として適切ではなければ例外を投げるということもありうる。
この検証はあくまで属性レベルの検証なので、エンティティ全体の状態として正しいことを保証しているわけではない。
private function setFirstName($first_name) { if ($first_name === null || $first_name === '') { throw new \InvalidArgumentException('名前は必ず設定する必要がある。'); } $this->first_name = $first_name; }
Factory Method
エンティティを生成する時は必ずFactory Methodを使用する。 コンストラターでいいと思うかもしれないが、PHPはメソッドのオーバーロードができないので別途Factory Methodを作った方が都合がいい。 このメソッドで作成されたエンティティの状態はドメインにおいて合法であることが保証される。
public static function create(Name $name) { // first_name や last_name を持たない人間を作ることはできない $person = new Person(); $person->setFirstName($name->firstName()); $person->setLastName($name->lastName()); return $person; }
エンティティのライフライクルに基いた振舞い
これはドメインのルールに基いて、エンティティの状態を変更させるメソッドである。 これは対象とするドメインによって異なるので、その都度考える必要がある。 多くの場合ドメインの用語(ユビキタス言語)で動詞に対応するものが命令形で定義されることになる。
例:名前を変更する
public function changeName(Name $name) { $this->setFirstName($name->firstName()); $this->setLastName($name->lastName()); }
まとめ
業務手順を表すユースケースとは、エンティティのライフサイクルの記述に他ならない。 エンティティのライフサイクルはドメインに基いたものでなければならず、ユースケース実装に際してエンティティを不適切な状態にできるのは望ましくない。 これを回避するために、コンストラクターとセッターを隠蔽して、エンティティのライフサイクルに基いた振舞いだけを公開するといった手法を紹介した。 これによってエンティティを不適切な状態にすることができなくなり、ライフサイクルを適切に管理することができるようになった。
集約の実装とLazy Loading
エンティティの設計においてオブジェクト同士の関連(参照)は避けては通れないものだ。 エンティティ単体では不十分で、関連するエンティティ同士が結び付いて始めて意味を成す場面は往々にしてある。 そのような場合、エンティティを他のエンティティの参照を保持させる集約 として設計する。
しかし無闇に関連を持たせた集約を設計すればいいというわけではない。 関連をたくさん持った巨大な集約というのは扱いづらいし、双方向の関連は複雑さを生む。 必要最低限かつ、単方向の関連しか持たない集約が望ましい。 そのためには、エンティティの参照を保持するのではなく、その識別子(ID)を保持させることも検討すべきだ。 この時、エンティティの参照を得るには識別子をリポジトリに問い合わせればいい。
N + 1問題
集約とそれが参照する各エンティティがデータベース内に別々に格納されているとして、集約のエンティティを複数取得するケースではパフォーマンスの悪化が懸念される。 いわゆるN + 1問題がその典型的なケースだ。 N個の集約が与えられた時、集約自身を得るクエリ 1 つと、その関連を辿るクエリN を足したN + 1のクエリを発行してしまうという問題である。
この問題の解決方法は単純で、そもそもクエリを分けずに JOIN を利用して1 つのクエリにしてしまうか、IN句を利用して関連を辿るクエリを 1 つにまとめてしまえばいい。 いわゆるEager Loadingと呼ばれる方法だ。 N + 1問題が発生する方法が関連を参照するまで遅延読み込み(Lazy Loading)するのと対比して、この方法では即時読み込み(Eager Loading)するからだ。
Lazy Loadingと N + 1問題の回避
N + 1問題を回避するためにLazy Loadingは避けて常にEager Loadingすべきかというとそうとも言えない。 ユースケースによっては集約の一部の関連しか参照しない場合もあるかもしれないが、この時Lazy Loadingが利用できればクエリの発行自体を避けることができるのでパフォーマンスで有利になる。 しかしN + 1問題が起きると途端にパフォーマンスが悪化してしまうので、N + 1問題の起きないLazy Loadingの実装があれば使い勝手がいい。 つまり、エンティティごとに個別に読み込みを遅延するのではなく関連ごとに遅延すればいい。
emonkak/ormにはこの方式のLazy Loadingが実装されている。 ocramius/proxy-managerを利用した簡単な実装だ。
利用例:
<?php use Emonkak\Orm\Relation\Relation; use Emonkak\Orm\SelectQuery; $users = (new SelectQuery()) ->with(Relation::lazyOneToOne( Profile::class, 'profiles', 'profile', 'user_id', 'user_id', $pdo )) ->with(Relation::lazyOneToMany( Post::class, 'posts', 'posts', 'user_id', 'user_id', $pdo )) ->from('users') ->getResult($pdo, User::class); foreach ($users as $user) { // $users自体も実際に走査されるまでクエリの発行が遅延されている $users->posts; // Postが格納されたArrayObjectのProxy Object $users->posts[0]; // 実際にアクセスするまでクエリの実行は遅延される // 一度アクセスした時点ですべてのUserのPostが読み込まれる $users->profile; // ProfileのProxyObject $users->profile->introduction; // Post の時と同様にアクセスするまで読み込みは遅延される }
おわりに
集約の読み込みにおいてLazy Loadingを利用することでクエリの発行自体を回避してパフォーマンスを向上させることができる場面があるが、N + 1問題が発生してしまうと逆にパフォーマンスが悪化してしまうので扱いづらい。 そこでN + 1問題の起きないLazy Loadingの実装があると使い勝手がいいのでemonkak/ormに実装した。
PSR-7とPSR-15を使ったWebアプリケーション開発
はじめに
PSR-7(HTTP Message)が承認されてからしばらく経ちますが、現在はこれを使った様々なライブラリ・フレームワークが登場しています。 これによって特定のライブラリ・フレームワークにロックインされずに、Webアプリケーションを実装できる道程が見えてきました。
しかし、PSR-7はあくまでHTTPメッセージのインターフェイスを提供するもので、リクエストを受け取ってレスポンスを返す流れを抽象化するものではありません。 これはHTTPミドルウェアと呼称されますが、そのインターフェイスはそれぞれの実装でまちまちです。 そこで、これを抽象化するPSR-15(HTTP Middleware)が提案されています。
ミドルウェアは大まかにダブルパスのミドルウェアと、シングパスのミドルウェアに分けることができます。 PSR-15は現在の所シングルパスのシグネチャを採用しています。
function (ServerRequestInterface $request, callable $next): ResponseInterface;
function (ServerRequestInterface $request, ResponseInterface $response, callable $next): ResponseInterface;
PSR-15のインターフェイスを提供するhttp-interop/http-middlewareにシングルパスを採用する理由があります。 ダブルパスのミドルウェアには事前にレスポンスを与える必要がありますが、そのレスポンスが利用可能なものかどうかを保証できないことが問題だと判断し、シングルパスを採用したようです。
The most severe is that passing an empty response has no guarantees that the response is in a usable state. This is further exacerbated by the fact that a middleware may modify the response before passing it for further dispatching.
残念ながら、この2つのインターフェイスには相互運用性がないので、アダプターを介してシグネチャを統一しても同じ動作を保証できません。 しかしこれは過渡期の問題で、恐らくはPSR-15の承認とともにどちらかに統一されることで解決に向かうはずです。
実装方針
本稿ではPSR-15を利用して簡単なサンプルとしてのWebアプリケーションを実装することを目的とします。 アプリケーションの設計については様々な手法がありますが、今回はDDD(Domain-driven design)を採用することを念頭に置いて進めていきます(サンプルなのでドメインと言っても実体があまりないですが)。
実装にあたっては既存のフレームワークを使うという選択肢もありますが、ここでは単機能なライブラリを組合わせて疎結合な独自のマイクロフレームワークを構築し、その上でアプリケーションを実装します。 これはあえてフルスタックフレームワークを避けたということでもあります。
フルスタックフレームワークは機能が豊富で便利なのは確かですが、注意深く設計をしないとドメインが特定のインフラストラクチャに依存する形になってしまいがちです。 さらには、複雑なドメインとアプリケーションの要件次第では、フレームワークの機能も十分に生かすことができず、かえって足枷になってしまうこともあります(フレームワークに汚いハックをして無理矢理要件を満たすなど)。 これはフレームワークが汎用のものである所以です。
ドメインは特定のインフラストラクチャから完全に分離されており、それ単体で動作するのが理想的です。 そうすると、ドメインへの影響は一切なしに、フレームワークを乗り換えることだってできます。 フレームワークもアプリケーションの要件に完全に合致したものがあればミスマッチもありません。
そこで、クリーンアーキテクチャを採用した、独自のマイクロフレームワークを作成するという道を選択しました。 幸いにして、そのための部品(ライブラリ)は揃いつつあります(揃えました)。 クリーンアーキテクチャではフレームワークに依存するレイヤーはこの図にある「Interface Adapters」と「Frameworks & Drivers」の部分だけです。
https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
仕様
作成するのはアカウント登録、ログイン、ログアウトがあるだけのごく単純なアプリケーションです。
- ユーザーはサービスを利用するためのアカウントを作成(サインアップ)しなければならない
- アカウント作成にはメールアドレスとパスワードが必要
- アカウントが作成されると自動的にログインされる
- ログインに成功すると「Hello World!」と表示される
- ログインしたアカウントはログアウトすることができる
- ログインするにはアカウント作成時に入力したメールアドレスとパスワードが必要
実装
今回実装したアプリケーションのソースは以下にあります。
https://github.com/emonkak/php-http-app-skeleton
使用ライブラリ
いくつか自作のライブラリも含まれますが、それぞれ詳細については追って記事にしたいと思います。
-
PSR-3を実装するロガー。
-
PSR-7を実装するHTTPメッセージライブラリ。
-
自作のシンプルなORM。 JavaのPOJOのPHP版にあたるPOPO(Plain Old PHP Object)を使ってエンティティを表現することができます。
-
自作のジェネレータを利用したPHPにおけるLINQ to Objectsの実装。 EnumerableExtensionsトレイトを利用することで任意のイテレーターオブジェクトに、LINQのクエリメソッドをミックスインすることができます。
-
自作のPSR-15ミドルウェアと、ミドルウェアのエントリーポイントとしてのアプリケーションの実装を提供するライブラリ。 エラーハンドリングを提供する独自のミドルウェアのインターフェイスも提供します。
-
symfony/http-kernelに含まれるHTTPのステータスコードに応じたエラーを表す例外を切り出したバッケージ。
-
パスのルーティングを提供する自作の汎用ルーター。 トライ木による実装と、最適化された正規表現による実装が提供されます。
-
シンプルなマイグレーションツール。
-
エラー発生時のデバッグ画面を表示するライブラリ。
-
.envファイルによる環境変数の設定を提供するライブラリ。
ディレクトリ構造
Laravelの構成を参考にしています。
bootstrap/
アプリケーションの初期化をするためのスクリプトを格納するディレクトリ。 ここに格納されたスクリプトはアプリケーションの設定ファイルとしても機能します。
database/migrations/
public/
サーバーのドキュメントルートとなるディレクトリ。
resources/templates/
src/
storage/
ログやキャッシュなどのファイルを書き込むディレクトリ。
パッケージ構成
App\Adapters
HTTP、コンソール(CLI)、データベースなどの外部インターフェイスへのアダプターが格納されます。 本来であればデータベースにアクセスするリポジトリの実装はこちらに配置するのが望ましいですが、今回はインターフェイス定義を省略して簡略化するために
App\Domain
に配置しています。App\UseCases
アプリケーションのユースケースが格納されます。 今回の場合はアカウントの認証と作成のためのサービスが格納されています。
App\Domain
アプリケーションが対象とする問題領域であるドメインの実装が格納されます。 パッケージ直下には実装は格納せずに、集約ルート(ルートエンティティ)ごとにサブパッケージを定義します。 今回はアカウントのサブパッケージのみが格納されています。
App\Supports
他の各レイヤーの実装を支援するためのユーティリティが格納されます。
エントリーポイント
まずはサーバーのエントリーポイントとなるスクリプトを見てみます。
最初に、リクエストからレスポンスを生成するための Application
クラスのインスタンスを別ファイルから require
しています。
この Application
のインスタンスにリクエストを与えて、生成されたレスポンスを送信するというのが全体の流れです。
<?php // public/index.php use Zend\Diactoros\Response\SapiEmitter; use Zend\Diactoros\ServerRequestFactory; // アプリケーションのインスタンスを読み込む $app = require __DIR__ . '/../bootstrap/http.php'; // グローバル変数からリクエストを生成 $request = ServerRequestFactory::fromGlobals(); // リクエストからレスポンスを生成 $response = $app->handle($request); // レスポンスを送信する (new SapiEmitter())->emit($response);
アプリケーションの初期化
Application
の初期化は bootstrap/http.php で行なわれます。
ここではオートローダーを読み込み環境変数の設定をした上で、各種ミドルウェアの登録を行っています。
以下は bootstrap/http.php の内容です:
<?php require __DIR__ . '/../vendor/autoload.php'; // .envから環境変数を読み込み (new Dotenv\Dotenv(__DIR__ . '/../'))->load(); if (getenv('APP_DEBUG')) { // デバッグモードならsymfony/debugによるエラー画面を表示する Symfony\Component\Debug\Debug::enable(); } $app = new App\Adapters\Http\Application(realpath(__DIR__ . '/../')); // 以降ミドルウェアの設定 // ミドルウェアは追加された順番通りに実行される // POST時に_methodパラメータをPOSTすることでメソッドをオーバーライドする $app->pipe( (new Middlewares\MethodOverride()) ->post(['PATCH', 'PUT', 'DELETE']) ->parsedBodyParameter('_method') ); // セッションを開始する $app->register(App\Adapters\Http\Middlewares\SessionStarter::class); // アカウントを認証する認証 $app->register(App\Adapters\Http\Middlewares\Authenticator::class); // テンプレートに設定される共通の変数($request, $uri, $session, $flashes)を設定する $app->register(App\Adapters\Http\Middlewares\ViewSharedVariables::class); // ルーティングの結果から処理を設定されたハンドラに移譲する $app->registerDispatcher(); // エラーのロギングを実行する $app->registerErrorHandler(); if (!getenv('APP_DEBUG')) { // Dispatcherが処理できなかった時に最後に実行されるミドルウェア // 404ぺージが表示される $app->register(App\Adapters\Http\Middlewares\ErrorPage::class); // 同様の実装をエラー発生時にも実行されるように登録する // catchされない例外があればここでハンドリングされてエラーページが作成される $app->registerOnError(App\Adapters\Http\Middlewares\ErrorPage::class); } return $app;
DIコンテナの生成
Application
のインスタンスが生成される時に、コンストラクターでは bootstrap/container.php を require
してPSR-11の ContainerInterface
の実装が生成されます。
要求されるのは ContainerInterface
のインスタンスなので、好きなDIコンテナの実装を利用することができます。
今回は自作の emonkak/di を使っています。
このコンテナにはアプリケーションに必要な各種インスタンスが登録されます。 環境の違いによって異なる設定を利用したい場合は、.env によって設定される環境変数を利用して切り替えます。
以下はDBのコネクションをコンテナに登録している部分のソースです:
<?php $container->factory(PDOInterface::class, function() { return new PDO( sprintf( '%s:host=%s;port=%d;dbname=%s', getenv('DB_CONNECTION'), getenv('DB_HOST'), getenv('DB_PORT'), getenv('DB_DATABASE') ), getenv('DB_USERNAME'), getenv('DB_PASSWORD'), [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ] ); })->in(SingletonScope::getInstance());
本サンプルでは設定値を管理するための専用の仕組みは得に設けていませんが、ほとんどの場合この環境変数を使う方法で事足りるはずです。
仮に環境によって特別な設定をしたい場合についても、以下のように if
で分岐してしまえば対応できます。
多くの設定は環境によらず共通のはずで(環境ごとの差異が大きすぎるのは問題)、異なる部分だけをif文で分岐するというのが重複も発生せずシンプルでいいと思ってます。
<?php if (getenv('APP_ENV') === 'production') { // production settings } else { // other settings }
ルーターの生成
ルーターはURLのパスに応じて、リクエストをハンドリングするクラス(ハンドラー)を決定するためのものです。
ルーターの生成は、パスに応じた処理を実行するための Dispatcher
ミドルウェアの登録時に bootstrap/router.php を require
することで行われます。
ここで要求されるのは emonkak/router で定義されたインターフェイスである RouterInterface
の実装です。
以下は bootstrap/router.php の内容です:
<?php use App\Adapters\Http\Handlers; use Emonkak\Router\TrieRouterBuilder; return (new TrieRouterBuilder()) ->get('/', Handlers\Index::class) ->get('/api/ping', Handlers\Api\Ping::class) ->get('/accounts/sign_up', Handlers\Accounts\SignUp::class) ->post('/accounts', Handlers\Accounts\Create::class) ->get('/sessions/login', Handlers\Sessions\Login::class) ->post('/sessions', Handlers\Sessions\Create::class) ->delete('/sessions', Handlers\Sessions\Delete::class) ->build();
これは単純にルーティングの内容を書き下しているだけですが、ハンドラーの命名に一定の法則を設けることで、クラスの一覧を取得して自動的にルーティングを定義するという方法も考えられます。 さらに生成したルーティングの内容をシリアライズした上でキャッシュすることで、性能的にも有利になります。 ルーティング数の多い大規模アプリケーションであれば、このような方法も検討もするといいかもしれません。
実装のイメージとしては以下のようなものです:
<?php if (file_exsits(__DIR__ . '/../storage/router.cache.php')) { return unserialize(file_get_contents('/../storage/router.cache.php')); } $builder = new TrieRouterBuilder(); foreach (RoutingResolver::resolve(__DIR__ . '/../src/Adapters/Http/Handlers') as list($method, $path, $handler)) { $builder->route($method, $path, $handler); } file_put_contents(__DIR__ . '/../storage/router.cache.php', serialize($builder->build())); return $router;
ハンドラーの実装
リクエストをハンドリングしてレスポンスを返すクラスをハンドラーと呼んでいます。 あらゆるPSR-15のミドルウェアはハンドラーとして利用できます。
その他、1つのクラスで複数のエンドポイントを取り扱う、コントローラースタイルのクラスをハンドラーとして登録することもできます。
コントローラーを登録する時はルーターにクラス名とメソッド名のペアを指定します。
指定されたクラスのメソッドは ServerMiddlewareInterface
の process()
メソッドと同じシグネチャであることを期待されます。
コントローラーの例:
<?php class ExampleController { public function index(ServerRequestInterface $request, DelegateInterface $delegate) { ... } public function create(ServerRequestInterface $request, DelegateInterface $delegate) { ... } ... }
http-interop/http-middleware に言及がありますが、PSR-15のシングルパスのミドルウェアはレスポンスの生成をミドルウェア自身が行うので、レスポンスの実装に対する依存が起きてしまいます。
Some have argued that passing the response helps ensure dependency inversion. While it is true that it helps avoid depending on a specific implementation of HTTP messages, the problem can also be resolved by injecting factories into the middleware to create HTTP message objects, or by injecting empty message instances. With the creation of HTTP Factories in PSR-17, a standard approach to handling dependency inversion is possible.
確かにPSR-17を使うことで特定の実装に依存することは回避できるのですが、ちょっと使い勝手が良くありません。
例えばJSONのレスポンスを返す時にContent-Typeを適切に設定してデータを json_encode()
を設定しなければならないのはなかなかに面倒です。
そこでもっと簡単にレスポンスオブジェクトを生成できる Respondable
トレイトを作成しました。
<?php trait Respondable { public function html($html, $statusCode = 200, array $headers = []) { return new HtmlResponse($html, $statusCode, $headers); } public function json($data, $statusCode = 200, array $headers = []) { return new JsonResponse($data, $statusCode, $headers); } public function redirect($uri, $statusCode = 302) { return new RedirectResponse($uri, $statusCode); } ... }
これらのメソッドをハンドラーにミックスインして呼び出すことで、直接特定のレスポンスの実装に依存することはなくなりました。
おわりに
PSR-7とPSR-15を使ってマイクロフレームワークを作成して、簡単なサンプルアプリケーションという試みをしてみました。 機能としては不足している点はありますが、この成果は現実のアプリケーションに十分に適用できるものだと思います。
実は、このような試みをしたのは2度目で、その時の成果として emonkak/wafというライブラリがあります。 これは前職で関わったとあるプロダクトで利用されています。 この時はアプリケーションの要件がかなり特殊で既存のフレームワークとは適合しなかったので、独自のフレームワークを作成する必要がありました。
本稿では説明不足な点も多々ありますが、ソースはシンプルで読み易いはずなので、興味があれば読んで頂ければと思います。
PDOにInterface欲しい
PHPの色々なフレームワークでDatabase Driverのラッパーが量産されているのが不毛に思った。 ここに共通化されたインターフェイスがあればフレームワークの違いを気にせず色々できるのになと。
そこで、PSR-3のLoggerのように標準化できないのかなと思って調べてみると、DoctrineのDBALが目に付いた。
ソースを読んでみると、DBALのConnection
インターフェイスはPDO
のサブセットになっている。
そうすると、PDO
に対する実装であるPDOConnection
はConnection
に対して実装を宣言するだけで済むわけだ。
PDO
をインターフェイスとして定義することで、PDO
の機能をそのまま同じインターフェイスで使うことができるし、PDO
を使う時には不要な抽象化レイヤーを挟まないのでシンプルでいいなと。
PDO
は事実上の標準なので多くの場合これで必要十分だと思う。
しかしながら、DBALにはクエリビルダーなど不要な機能も含まれているしdoctrin/commonへの依存もある。Connection
部分だけ抜きだしたpackageがあればいいのだけど、今の所ないようだった。
ということでPDO
のサブセットとしてのインターフェイスだけを提供するcomposerのpackageを作りました。
PHPでフィボナッチ数列を出力するソースコードを出力するプログラム
Quineみたいなものだけど、自身のソースコードを次々に書き換えて
それを新たに起動したPHPプロセスに標準入力として与えている。
fib.php
<?php function php($input) { $process = proc_open('php', array(array('pipe', 'r'), array('pipe', 'w')), $pipes); if (is_resource($process)) { fwrite($pipes[0], $input); fclose($pipes[0]); $output = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($process); return $output; } } function callback($output) { $n = 50; $lines = explode("\n", trim($output)); $count = count($lines); $lines[] = end($lines) + prev($lines); $f = __FILE__; $s = file_get_contents($f); $s = preg_replace("/__FILE__[;]/", "'$f';", $s); $s = preg_replace('/0\n1\n/', implode("\n", $lines), $s); return $count < $n ? php($s) : end($lines); } ob_start("callback") ?> 0 1 <?php ob_end_flush() ?>
Result
$ time php fib.php 12586269025 php fib.php 0.51s user 0.28s system 92% cpu 0.851 total