PHP8でフレームワークを作ってみた
はじめに
PHP8でwebフレームワークをフルスクラッチで作っています。
一旦実装してみたい部分は、(細かい考慮などはおいといてざっくりとした実装が)できたので、作っていく中で得た気付きや勉強になった事を書いてみようと思いました。
実装したものは、DIコンテナ
、ルーティング
、ミドルウェア
、ORマッパー
です。
本記事は、フレームワークの核であるルーティング
・DIコンテナ
の実装過程を通して、拡張に強い設計とはどういうものかについて考えてみた記事です。
「フレームワークを作ってみたい」「ライブラリを実装してみたい」という方に少しでも参考になる部分があれば幸いです。
まとめ
- 「フレームワークやライブラリを作りたい!」となったら、PSRで定義されているインターフェイスを実装してみるのが良さそう
- PSRに準拠したOSSのライブラリはたくさんあるので、実装に困った時に参考にできる
動機
- php8を触りたい!
- match式とか使いたい!(使って嬉しい場面がなく結局使わなかった...)
- Webフレームワーク作りたい!
作ったもの
Oil
という名前は、職場の近所の麻婆豆腐が美味しいお店の名前です。
パッケージ構成はざっくりと以下のような構成です。パッケージ構成はlaravel/laravelを参考にしています。
.
├── app
│ ├── Container // DIコンテナなどを配置
│ ├── Database // ORマッパーなどDB関連の処理を配置
│ ├── Http // ControllerやMiddlewareなどを配置
│ ├── Providers // DIコンテナを使って、依存関係を定義するパッケージ
│ └── Routing // ルーティングを実現するクラスを配置
├── bootstrap // 初期化処理を行うファイルを配置
│ └── container.php
├── config
│ ├── database.php // DB接続するための設定情報を書くところ。設定値は環境変数から取得する。
│ └── providers.php // bootsrap/container.phpから呼ばれるファイル。*ServiceProviderをあらかじめファイルに書いておく。
├── packages // コントローラーから依存させるパッケージ群をおく想定。Application, Domainからは/app配下へはアクセスしない。
│ ├── Application // アプリケーション層。UseCaseクラスなどをここに配置する想定
│ ├── Domain // ドメイン層
│ └── Port
│ └── Adapter
│ └── Infrastructure // インフラ層。フレームワークに依存するのはここからのみの想定。外部接続系もここ。
├── phpunit.xml
├── public
│ └── index.php // リクエストは全てこのファイルで受ける。
├── routes
│ └── api.php // ルーティングの定義を書くファイル
└── tests
ただただ実装したい機能を実装しただけのフレームワークなので、脆弱性対策などは一切してません。(無効なURLが指定された場合の考慮等々)例外処理などもほとんどしてません。なので、実用に耐えうるものではありません。
貧弱すぎる分実装はすごくシンプルです。実際に使う予定は全くないので、割り切ってかなり思想強めなフレームワークになっています。
ルーティングに指定できるController
はシングルコントローラー限定だったり(Closure
も指定できるので完全ではありませんが)、Package以下にUseCaseクラスなどを置く事を想定しているのですが、既に自分が切りたいようにパッケージを切ってたり等...オレオレフレームワーク万歳。
参考にしたもの
パーフェクトPHP
一部のコードはまんま使用しています。RequestクラスやResponseクラス、PDOのラップクラスなどは、PHP8仕様に書いている以外はそのまんまです。
各種OSS
パッケージ構成や、アプリケーションの初期化、クラスのインターフェイスについてなどを参考にしました。
主に以下を参考にしました。
作って理解するDIコンテナ
@tadsan さんの配列がインスタンスを返すだけという最小限の実装から、段階的にDIコンテナを作っていくのは非常に参考になりました。
フレームワークを作ってみる
「リクエストに対して、特定のController
を実行する」をいう最低限の機能を持ったフレームワークを作ってみましたので、解説していきたいと思います。
ルーティングを実装する
ルーティングとは
ルーティングとは簡単にいうと「エンドポイントに対して、実行したい処理をマッピングする」事です。
index.phpにアクセスを集めるようなフロントコントローラーパターンでは、URIからどの処理を実行するかを解決する必要があります。
一般的にフレームワークを使うときは、Controller以下を実装することになると思いますが、どんなリクエストが来た時に、どのコントローラーを実行するかを解決するのがルーティングの役割です。
必要な機能
ルーティングを実装するにあたり、ざっくりと以下の機能が必要になると思います
- 特定のエンドポイントと
Controller
のマッピングを定義しておく - リクエストが来た時に、定義したマッピングと照らし合わせて適切な処理を呼ぶ
クラス図
以下のようなクラスを実装しました。
Routerの責務
Routerには二つの責務があります。「マッピングの登録」と「どの処理を実行するかの選択」です。
「マッピングの登録」は、Router::map
メソッドにて行います。実際の「どのエンドポイントに対して」「どのコントローラーを実行するか」に関しての情報はRoute
クラスが保持します。
Router::map
メソッドが呼ばれる度にRoute
オブジェクトを生成し、配列に格納していきます。
<?php
declare(strict_types = 1);
namespace App\Routing;
...
/**
* Class Router
*
* @package App\Routing
*/
class Router
{
use MiddlewareTrait;
/**
* @var Route[]
*/
private array $routes = [];
/**
* Router constructor.
*
* @param ContainerInterface $container
*/
public function __construct(private ContainerInterface $container) {}
/**
* @param string $method
* @param string $pass
* @param Closure|string $handler
*
* @return Route
*/
final public function map(string $method, string $pass, Closure|string $handler): Route
{
$route = new Route($this->container, $method, $pass, $handler);
$this->routes[] = $route;
return $route;
}
...
「マッピングの登録」に関しては、プロジェクトルート直下のroutes
パッケージにapi.php
というファイルを置き、そこで定義するようにしました。
以下は、/tests/{id}
のエンドポイント に対して、Get
メソッドでリクエストが来た際に、SampleController
を呼び出す、というマッピングを定義する例です。
<?php
declare(strict_types = 1);
...
function router(ContainerInterface $container): Router
{
$router = new Router($container);
$router->map('GET', '/tests/:test_id', SampleController::class);
return $router;
}
フレームワークによって、上記の処理をどこで実行するかは変わると思いますが、今回はindex.php
で「マッピング情報を持ったRouter
オブジェクトを生成する」処理を実行しています。
require __DIR__ . '/../routes/api.php';
// DIコンテナの生成。詳細は後述します。
$container = require __DIR__ . '/../bootstrap/container.php';
$kernel = new HttpKernel(router($container));
そして、実際のリクエストを解釈する場面では、Router::dispach
を実行します。保持しているRouteオブジェクトの中からリクエストを処理可能なRoute
オブジェクトを探し出し、処理をRoute
オブジェクトに委譲します。
final public function dispatch(Request $request): Response
{
foreach ($this->routes as $route) {
if ($route->processable($request)) {
return $route->process($request);
}
}
}
Routeの責務
Route
クラスは「動的パラメーター(Router
の例では:test_id
)を解釈し、Controller
が実行するメソッドの引数として渡す」という責務を持っています。
1エンドポイント に対して、1オブジェクトが生成される事になります。
<?php
declare(strict_types = 1);
namespace App\Routing;
...
/**
* Class Route
*
* @package App\Routing
*/
class Route
{
use MiddlewareTrait;
/**
* Route constructor.
*
* @param ContainerInterface $container
* @param string $method
* @param string $pass
* @param string|Closure $handler
*/
public function __construct(
private ContainerInterface $container,
private string $method,
private string $pass,
private string|Closure $handler
)
{
}
final public function processable(Request $request): bool {...}
final public function process(Request $request): Response
{
$this->processMiddleware($request);
$vars = [];
foreach ($this->createTokens($request) as $exploded_uri_pattern => $exploded_uri) {
if (str_starts_with($exploded_uri_pattern, ':')) {
$vars[ltrim($exploded_uri_pattern, ':')] = $exploded_uri;
}
}
$handler = is_string($this->handler)
? $this->container->get($this->handler)
: $this->handler;
return $handler($request, $vars);
}
/**
* @param Request $request
*
* @return array
*/
private function createTokens(Request $request): array
{
$exploded_uri_patterns = explode('/', ltrim($this->pass, '/'));
$exploded_uris = explode('/', ltrim($request->getPathInfo(), '/'));
if (count($exploded_uri_patterns) !== count($exploded_uris)) {
return [];
}
return array_combine($exploded_uri_patterns, $exploded_uris);
}
}
$handler
にはController
の他に、Closure(無名関数など)
も受け取れるようにしているので、is_string($this->handler)
でController
のクラスを示す文字列なのかどうかを解決しています。
1エンドポイント に対して、1Contolrer
になるシングルコントローラーを前提としています。なので、HogeController::create
だったり、HogeController::show
なども実行できるようにするなら、もう少しコードが複雑になります。
ルーティングの定義で、$router->map('GET', '/tests/:test_id', SampleController::class);
という書き方をしましたが、この時、Router::map
内ではRoute
のhandler
にSampleController::class
を渡しています。
なので、基本的には$this->handler
には**Controller::class
で展開された名前空間付きのクラス名
が格納されています。
参考: PHPマニュアル>言語リファレンス>クラスとオブジェクト>::class
そして、is_string($this->handler)
の結果、「handler
の中身は、なんらかのController
のクラス名を示す文字列だ」と判断した場合、$this->container->get($this->handler)
を実行し、対象のController
クラスのインスタンスを得ます。
$controller = $this->container->get($this->handler);
assert($controller instansOf SampleController);
$this->container
にはContainerInterface
のオブジェクトが格納されています。
/**
* Route constructor.
*
* @param ContainerInterface $container
* @param string $method
* @param string $pass
* @param string|Closure $handler
*/
public function __construct(
private ContainerInterface $container,
private string $method,
private string $pass,
private string|Closure $handler
)
{
}
「ContainerInterface
ってのがいきなり出てきたけどこれはなんやねん」ということについては後述します。ざっくりと説明すると「指定したクラスのインスタンスを返してくれる箱」のようなものです。
強調しておきたいのは、ContainerInterface
はインターフェイスだという事です。なので、Route
はContainerInterface::get
の具体的な実装を知りません。「SanpleController
を生成(new
)するためにはどんな引数を渡したら良いか」も知りません。知っているのはクラス名だけです。
「具体的な実装を知らないけど、get
にSampleController::class
を指定したら、SanpleController
のインスタンスがもらえる」という事しか知らないとう状態です。
最終的にRouting周りのクラスは、以下のような形になりました。
DIコンテナを実装する
次に先ほど登場した、DIコンテナについて、解説を試みます。
DIコンテナとは
DIコンテナとはざっくりいうと、クラスの依存関係をまとめたコンテナです。
コンテナとは、入れ物、箱、容器などの意味を持つ英単語。ITの分野では何らかの入れ物のような働きをする要素や仕組みなどを比喩的にコンテナと呼ぶことが多い。
出典: IT用語辞典
普段、クラスをnew
する時はそのクラスを組みたてる引数などを一緒に指定していると思うのですが、その依存関係がコンテナで一元管理されているので、こんな感じでインスタンスを生成する事が可能になります。
// SampleControllerのインスタンスを取得する
$controller = $this->container->get($this->handler);
assert($controller instansOf SampleController);
DIコンテナの詳細については、@tadsan さんの作って理解するDIコンテナが非常に参考になります。
特に参考になったのは、PSRで策定されているインターフェイスに関してです。PSRとはなんぞやという事についてはPSRの誤解という記事が参考になります。
PSR-11: Container interface
今から実装するDIコンテナですが、インターフェイスがPSR-11: Container interfaceにて策定されています。
これは「DIコンテナを実装するなら、必ずPSR11のインターフェイスに準拠しようね」という意味では決してないのですが、少なくとも「PSR11に準拠しておけば、他のみんなもPSR11に準拠したDIコンテナを実装してたりするし、いい事あるよ」くらいのニュアンスです。
そのあたりの詳細については、前述のPSRの誤解という記事が参考になります。
今回作ったDIコンテナはPSR-11: Container interfaceに準拠するように実装しました。また、DIコンテナのクライアント(僕の実装ではRoute.phpがそれにあたります)は、PSR-11: Container interfaceで定義されるインターフェイスに依存するようにしました。
こうする事で、DIコンテナの実装クラス
と呼び出し側
が完全に切り離されているので、他の(PSR-11に準拠した)DIコンテナライブラリにいつでも差し替える事ができます。
PSR-11: Container interfaceが持つメソッドは以下の二つのみです。
<?php
namespace Psr\Container;
/**
* Describes the interface of a container that exposes methods to read its entries.
*/
interface ContainerInterface
{
/**
* ...省略
*/
public function get($id);
/**
* ...省略
*/
public function has($id);
}
-
ContainerInterface::get($id)
-
id
の値で、目的のオブジェクトを取得する
-
-
ContainerInterface::has($id)
-
id
の値で、目的のオブジェクトが取得できるか確認する
-
すごいシンプルです。ここには特に、どうやって依存関係を定義するか、のようなメソッドは用意されていません。
確かにDIコンテナの利用する側からすると、「どのように依存関係を定義するか
については利用するクラス側は知る必要がない」んですよね。これは大きな気付きでした。
今まで、インターフェイスを実装する側のクラス
がインターフェイスに定義されていないpublicなメソッドを持っても良いのか?というところが自分の中でわかってなかったのですが、ContainerInterface::get
で何らかのインスタンスを取得するためには、必ずどこかで依存関係を定義する処理が必要になります。(コンテナクラスの中で定義するっていうのも考えられますが、あまり使い勝手はよくなさそう。)
そういった、(DIコンテナのインスタンス変数に状態を持たせるような)前準備的な処理は、具体的な実装クラスにpublicなメソッドとして持たせてしまっても良いのかーという気づきは良い収穫でした。
ちなみに、依存関係の登録処理は、ServieProviderクラス群で行っており、bootstrap/container.phpにて、DIコンテナの初期化処理を実行しています。
クラス図/package構成
以下のようなクラスを実装しました。
パッケージは以下のようにしました。
└── Container
├── Container.php
├── Definition.php
├── Exception
│ ├── NoClassDefinitionException.php
│ └── NotFoundException.php
└── ServiceProvider.php
使い方
SampleUseCase
クラス(サンプル用の適当クラスです)の依存関係を定義して、get
で取り出す流れは以下のようになるます。
// UseCaseのインターフェイス
interface SampleUseCaseInterface
{
}
// UseCaseの実態
class SampleUseCase implements SampleUseCaseInterface
{
public function __construct(private SampleRepository $repository) {}
}
// Repositoryのインターフェイス
interface SampleRepository
{
}
// Repositoryの実態
class SampleRepositoryImpl implements SampleRepository
{
}
$containre = new Container();
// 依存関係を定義する。
$this->container->add(SampleUseCaseInterface::class, SampleUseCase::class)
->addArgument(SampleRepository::class);
$this->container->add(SampleRepository::class, SampleRepositoryImpl::class);
# SampleUseCaseInterfaceが定義されているか
assert($this->container->has(SampleUseCaseInterface::class));
# SampleUseCaseのインスタンスを取得
$useCase = $this->container->get(SampleUseCaseInterface::class);
assert($useCase instanceof SampleUseCase);
Containerクラスの詳細
実装の肝となるContainer::get
で何をやっているかを簡単に解説します。これはあくまで「私の場合、こうやってみた」というものです。
ガチモンのDIコンテナについては、league/containerが参考になると思います。
例えば以下のクラスをnew
しようと思うと、new SampleUseCase()
であはエラーになってしまいます。引数にSampleRepository
が指定されているので、「それを渡さなきゃ生成できないよ」と怒られると思います。
class SampleUseCase implements SampleUseCaseInterface
{
public function __construct(private SampleRepository $repository) {}
}
なのでSampleUseCase
を生成するget
メソッドを実装するためには、「コンストラクタにはこの引数が必要」というクラス定義情報を知っておく必要があります。
その定義情報を格納するために、Container
クラスと別にDefinition
クラスを定義しています。
Container
クラスでは配列でDefinition
クラスの参照を保持しており、識別子($id)で対象の定義クラスを取り出す事ができます。
class Container implements ContainerInterface
{
/**
* @var Definition[]
*/
private array $definitions = [];
final public function get(string $id): object
{
if (!$this->has($id)) {
throw new NotFoundException("Entry '$id' not found.");
}
try {
$definition = $this->definitions[$id];
$reflectionClass = new \ReflectionClass($definition->concrete());
if ($reflectionClass->getConstructor() === null) {
return $reflectionClass->newInstance();
}
$args = array_map(function (mixed $argument) {
return $this->isClassOrInterface($argument) ? $this->get($argument) : $argument;
}, $definition->arguments());
return $reflectionClass->newInstanceArgs($args);
} catch (ReflectionException) {
throw new NoClassDefinitionException("'$id' is not defined.");
}
}
...
}
/**
* クラス定義を持つクラス
*/
class Definition
{
/**
* コンストラクタの引数情報
*/
private array $arguments = [];
/**
* $id: クラス定義の識別子
* $concrete: 生成対象のクラス
*/
public function __construct(private string $id, private string $concrete)
{
}
...
}
!$this->has($id)
にて、クラス定義を持っているかを確認した後、$idを元に取得したクラス定義情報から、ReflectionClass
のインスタンスを生成します。
$definition = $this->definitions[$id];
$reflectionClass = new \ReflectionClass($definition->concrete());
$reflectionClass->getConstructor()
は対象のクラスのコンストラクタの有無を確認します。もしコンストラクタが存在しなければ、素直にnew
すれば良いだけなので、
return $reflectionClass->newInstance();
にて、対象のクラスのオブジェクトをreturn
します。
しかし、コンストラクタが存在する場合はもう少し複雑になってきます。もし、引数に指定されているクラスもそのままnew
するだけで良いなら話はシンプルなのですが、そうとは限りません。
更に、「引数のクラスのコンストラクタ」の「引数に指定されているクラス」にもコンストラクタがあれば...などと考えると、決まった深さがあるわけではなく、深さの読めない木構造を持っている事がわかります。
そこで、以下のようにarray_map
の中でContainer::get
を更に呼び出す事により、再帰的にクラスを組み立てていく実装にしました。
$args = array_map(function (mixed $argument) {
return $this->isClassOrInterface($argument) ? $this->get($argument) : $argument;
}, $definition->arguments());
$this->isClassOrInterface
は「渡された引数からインターフェイスorクラスか」を判定します。true
の場合は、依存関係を再度解決する必要があるため、Container::get
を呼びます。
最終的に、args
に全ての引数が準備できたので、ReflectionClass::newInstanceArgs
に渡す事で、インスタンスを生成します。
ルーティングとDIコンテナをつなげてみる
ルーティングとDIコンテナが実装できたので、クラス図を再喝します。
ルーティング
DIコンテナ
では、このクラス図をつなげてみます。
前述の通り、Route
クラスはContainerInterface
に依存している為、Container
クラスについては何も知りません。
つまり、ContainerInterface
を実装しているクラスなら、いつでも取り替えが可能ということを意味します。
例えば、このRoute
クラスにDIするクラスを、他のDIコンテナライブラリ(league/containerなど)に差し替えても問題なく動作します。(依存関係の定義処理などは変更する必要がありますが。).
以上が、私の作ったへっぽこフレームワークの作り方(抜粋)です。
記事を通して何が言いたかったかというと、フレームワークやライブラリを実装してみる時は、各部品の接合部分をPSRのインターフェイスに依存させて実装してみると、すごい勉強になったからおすすめかもしれないという事でした。
初めて記事を書いてみましたが、技術書執筆したり記事を量産している方って凄すぎますね。。。
Discussion