PHP8でフレームワークを作ってみた

18 min read読了の目安(約16600字

はじめに

PHP8でwebフレームワークをフルスクラッチで作っています。

一旦実装してみたい部分は、(細かい考慮などはおいといてざっくりとした実装が)できたので、作っていく中で得た気付きや勉強になった事を書いてみようと思いました。

実装したものは、DIコンテナルーティングミドルウェアORマッパーです。

本記事は、フレームワークの核であるルーティングDIコンテナの実装過程を通して、拡張に強い設計とはどういうものかについて考えてみた記事です。

「フレームワークを作ってみたい」「ライブラリを実装してみたい」という方に少しでも参考になる部分があれば幸いです。

まとめ

  • 「フレームワークやライブラリを作りたい!」となったら、PSRで定義されているインターフェイスを実装してみるのが良さそう
  • PSRに準拠したOSSのライブラリはたくさんあるので、実装に困った時に参考にできる

動機

  • php8を触りたい!
    • match式とか使いたい!(使って嬉しい場面がなく結局使わなかった...)
  • Webフレームワーク作りたい!

作ったもの

https://github.com/task2021/oil
に公開しています。ちなみに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

パッケージ構成や、アプリケーションの初期化、クラスのインターフェイスについてなどを参考にしました。

主に以下を参考にしました。

@tadsan さんの作って理解するDIコンテナ

配列がインスタンスを返すだけという最小限の実装から、段階的にDIコンテナを作っていくのは非常に参考になりました。

フレームワークを作ってみる

「リクエストに対して、特定のControllerを実行する」をいう最低限の機能を持ったフレームワークを作ってみましたので、解説していきたいと思います。

ルーティングを実装する

ルーティングとは

ルーティングとは簡単にいうと「エンドポイントに対して、実行したい処理をマッピングする」事です。

index.phpにアクセスを集めるようなフロントコントローラーパターンでは、URIからどの処理を実行するかを解決する必要があります。

一般的にフレームワークを使うときは、Controller以下を実装することになると思いますが、どんなリクエストが来た時に、どのコントローラーを実行するかを解決するのがルーティングの役割です。

必要な機能

ルーティングを実装するにあたり、ざっくりと以下の機能が必要になると思います

  • 特定のエンドポイントとControllerのマッピングを定義しておく
  • リクエストが来た時に、定義したマッピングと照らし合わせて適切な処理を呼ぶ

クラス図

以下のようなクラスを実装しました。

Routerの責務

Routerには二つの責務があります。「マッピングの登録」と「どの処理を実行するかの選択」です。

「マッピングの登録」は、Router::mapメソッドにて行います。実際の「どのエンドポイントに対して」「どのコントローラーを実行するか」に関しての情報はRouteクラスが保持します。

Router::mapメソッドが呼ばれる度にRouteオブジェクトを生成し、配列に格納していきます。

Router.php
<?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を呼び出す、というマッピングを定義する例です。

routes.php
<?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オブジェクトを生成する」処理を実行しています。

index.php
require __DIR__ . '/../routes/api.php';

// DIコンテナの生成。詳細は後述します。
$container = require __DIR__ . '/../bootstrap/container.php';

$kernel = new HttpKernel(router($container));

そして、実際のリクエストを解釈する場面では、Router::dispachを実行します。保持しているRouteオブジェクトの中からリクエストを処理可能なRouteオブジェクトを探し出し、処理をRouteオブジェクトに委譲します。

Router.php
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オブジェクトが生成される事になります。

Route.php
<?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内ではRoutehandlerSampleController::classを渡しています。

なので、基本的には$this->handlerには**Controller::classで展開された名前空間付きのクラス名が格納されています。

参考: PHPマニュアル>言語リファレンス>クラスとオブジェクト>::class

そして、is_string($this->handler)の結果、「handlerの中身は、なんらかのControllerのクラス名を示す文字列だ」と判断した場合、$this->container->get($this->handler)を実行し、対象のControllerクラスのインスタンスを得ます。

php
$controller = $this->container->get($this->handler);

assert($controller instansOf SampleController); 

$this->containerにはContainerInterfaceのオブジェクトが格納されています。

Route.php
/**
 * 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はインターフェイスだという事です。なので、RouteContainerInterface::getの具体的な実装を知りません。「SanpleControllerを生成(new)するためにはどんな引数を渡したら良いか」も知りません。知っているのはクラス名だけです。

「具体的な実装を知らないけど、getSampleController::classを指定したら、SanpleControllerのインスタンスがもらえる」という事しか知らないとう状態です。

最終的にRouting周りのクラスは、以下のような形になりました。

DIコンテナを実装する

次に先ほど登場した、DIコンテナについて、解説を試みます。

DIコンテナとは

DIコンテナとはざっくりいうと、クラスの依存関係をまとめたコンテナです。

コンテナとは、入れ物、箱、容器などの意味を持つ英単語。ITの分野では何らかの入れ物のような働きをする要素や仕組みなどを比喩的にコンテナと呼ぶことが多い。

出典: IT用語辞典

普段、クラスをnewする時はそのクラスを組みたてる引数などを一緒に指定していると思うのですが、その依存関係がコンテナで一元管理されているので、こんな感じでインスタンスを生成する事が可能になります。

php
// 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が持つメソッドは以下の二つのみです。

ContainerInterface.php
<?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が指定されているので、「それを渡さなきゃ生成できないよ」と怒られると思います。

SampleUseCase.php
class SampleUseCase implements SampleUseCaseInterface
{
    public function __construct(private SampleRepository $repository) {}
}

なのでSampleUseCaseを生成するgetメソッドを実装するためには、「コンストラクタにはこの引数が必要」というクラス定義情報を知っておく必要があります。

その定義情報を格納するために、Containerクラスと別にDefinitionクラスを定義しています。

Containerクラスでは配列でDefinitionクラスの参照を保持しており、識別子($id)で対象の定義クラスを取り出す事ができます。

Container.php
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.");
        }
    }

    ...
}
Definition.php
/**
 * クラス定義を持つクラス
 */
class Definition
{
    /**
     * コンストラクタの引数情報
     */
    private array $arguments = [];
  
	  /**
     * $id: 			クラス定義の識別子
     * $concrete: 生成対象のクラス
     */
    public function __construct(private string $id, private string $concrete)
    {
    }
  
  	...
}

!$this->has($id)にて、クラス定義を持っているかを確認した後、$idを元に取得したクラス定義情報から、ReflectionClassのインスタンスを生成します。

Container.php
$definition      = $this->definitions[$id];
$reflectionClass = new \ReflectionClass($definition->concrete());

$reflectionClass->getConstructor()は対象のクラスのコンストラクタの有無を確認します。もしコンストラクタが存在しなければ、素直にnewすれば良いだけなので、

php
return $reflectionClass->newInstance();

にて、対象のクラスのオブジェクトをreturnします。

しかし、コンストラクタが存在する場合はもう少し複雑になってきます。もし、引数に指定されているクラスもそのままnewするだけで良いなら話はシンプルなのですが、そうとは限りません。

更に、「引数のクラスのコンストラクタ」の「引数に指定されているクラス」にもコンストラクタがあれば...などと考えると、決まった深さがあるわけではなく、深さの読めない木構造を持っている事がわかります。

そこで、以下のようにarray_mapの中でContainer::getを更に呼び出す事により、再帰的にクラスを組み立てていく実装にしました。

php
$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のインターフェイスに依存させて実装してみると、すごい勉強になったからおすすめかもしれないという事でした。

初めて記事を書いてみましたが、技術書執筆したり記事を量産している方って凄すぎますね。。。