それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

PHPでLeague/Routeを使ってルーティングを構成する

はじめに

どうもよく訓練されたJVM教のけんつです。
JVM環境でしか使い物にならないと言われてきた自分がWebサーバサイドのコードをPHPで書く機会がここ半年ほどで多くなってきた。
それでも、Webやったことないしわからんぞ!という感じなので素のPHPでオレオレTwitterクライアントを書く事にした。
とりあえず、イカしたdocker-composeを適当に引っ張ってきてイマドキのなうでヤングな環境を構築するところまではやった。

結構順調だったので、このまま最小構成を作ってしまおうとした矢先に自分を苦しめる存在が出てきた。
そうそれこそが

「素のPHPでルーティングどうやんの?」

という疑問である。


とりあえず、「PHP ルーティング」でぐぐってみると

  • apache 環境では…。いや使ってるのnginxだし!!
  • Cakeでルーティングはこうする…。 いや素のPHPで書きたいんだ!!
  • Laravelではこうする…。 だからフレームワークは使わんと言った!!
  • なかったから自作したお。 わけわからん!!

といった調子になった。

それでもいくつか、FastRouteのような使えそうなライブラリを見つけた。
しかし、以前Play Frameworkで色々やっていた自分に取ってみれば「書く事が多すぎてだるい!!」に尽きる。
それでも探し続けて、やっと「League/Route」なるものを見つけた。

色々とサンプルをみて、「これええやん!」となったが問題はPHPに浸かっている人間でなかったのでわかりにくいというか何をやっているかわからないという個人的な点にあった。
なので、ここでは自分なりに頑張って色々調べてみて「League/Route」を使うための公式にあるサンプルが何をやっているのかまとめてみようと思う。

route.thephpleague.com

環境

まずPHPのバージョンだが、Routeは5.4以降に対応しているので大抵の環境で動作するだろう。

Routeを使うにはcomposerかautoloadで持ってくるのを前提にしている。
自分はPHPを書くときはcomposerを使うのでそちらを参考にする。

$ composer require league/route

またこのライブラリはPSR-7を前提にしているのでそちらも解決する。

composer require zendframework/zend-diactoros

あとの文章に出てくるLeague\Containerでは2.x系のものを使用している。
こちらはバージョンによって仕様がかなり違うので注意が必要。

と環境はここまでで構築できるはず。

ルーティングを構成する基本要素

環境を構築したら、次のようにコードを書けばルーティングが実装できるよ。と公式には書いているが

<?php

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

$container = new League\Container\Container;

$container->share('response', Zend\Diactoros\Response::class);
$container->share('request', function () {
    return Zend\Diactoros\ServerRequestFactory::fromGlobals(
        $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
    );
});

$container->share('emitter', Zend\Diactoros\Response\SapiEmitter::class);

$route = new League\Route\RouteCollection($container);

$route->map('GET', '/', function (ServerRequestInterface $request, ResponseInterface $response) {
    $response->getBody()->write('<h1>Hello, World!</h1>');

    return $response;
});

$response = $route->dispatch($container->get('request'), $container->get('response'));

$container->get('emitter')->emit($response);

ここらへんを全てフレームワークに投げていた人にはよく分からない。
なので、該当するPSRなどを読んで理解していく。

まず、先頭にあるこのコード

$container = new League\Container\Container;

League\Containerを呼び出しているのだが、ここで指すコンテナってなんぞ?とおもったので調べてみた。

Dependency Injection Container (PSR-11)

DIコンテナと言われるものでPSR-11内で決定されている。
DIコンテナと言われても分からない。なので調べた。
PSR-11については以下のページが
qiita.com

DIについては以下のページが個人的に分かりやすかった。
qiita.com

そもそもDIとは

つまりこれは何かと言われると、例えば以下の処理をしようとする。

<?php
class Sample
{
    private $engine;

    public function __construct()
    {
        $this->engine = new Engine();
    }
}

ここではnew Engineとしたがこれはテンプレートエンジンでもいいし、エンジンでなくてもいい。
依存性を直接コンストラクタやメソッドのスコープ内で生成してしまうコードを指す。

これらは上記のqiita記事でも書かれているが
上のコードだと何が嬉しくないというと

  • Engineクラスを別のクラスに変えたりするとSampleクラスを直接変更しなければならない。
  • Engineが上手く動作してくれないとそもそも、このクラスのテストが出来ない。

という点にある。

それを次のようにすると嬉しいことがある。

<?php
class Car
{
    /**
     * @var EngineInterface
     */
    private $engine;

    /**
     * @param EngineInterface $engine
     */
    public function __construct(EngineInterface $engine)
    {
        $this->engine = $engine;
    }
}

何が嬉しいかというと、依存性が外部で完結しこちらのクラスではそれを受け取っているだけなので変更も容易でテストもしやすい。

しかしこれも残念な点を抱えている。
何かというと色んな依存関係を解消しようとすると引数がめちゃくちゃ長くなりやすいという点。
それと結局依存を注入する手続きは手動で行っており,抽象ではなく具象に対して依存するコードを書くことになるという点。
それらの問題を解決するためにDIコンテナが存在する。

DIコンテナとは

DIコンテナについては以下のサイトがわかりやすくまとまっていた。
qiita.com

つまり、今まで抽象っぽくなってたけど結局具象な上に手動で行っていたものを別な場所(DIコンテナ)に突っ込んで管理しよう。というのを実現するためにある。(らしい)

今回の環境ではLeague/Containerを使っているので公式から使用例を引用してくる。
container.thephpleague.com

まずは前提として以下のようなコードがあるとする。

<?php

namespace Acme\Service;

class SomeService
{
    // ...
}

これに対してDIコンテナを次のように使うことができる

<?php

$container = new League\Container\Container;

// register the service as a prototype against an alias
$container->add('service', 'Acme\Service\SomeService');

// now to retrieve this service we can just retrieve the alias
// each time we `get` the service it will be a new instance
$service1 = $container->get('service');
$service2 = $container->get('service');

var_dump($service1 instanceof Acme\Service\SomeService); // true
var_dump($service1 === $service2); // false

使い方としてはコンテナにDIしたいサービスのクラスを渡すこと。
それをgetすることで実際にそれらを利用することができる。
しかし、add/getではgetするたびに新しいインスタンスになるため

$service1 === $service2

という、評価式がfalseを返す。この点には注意したい。

実際にルーティングを組むときにはgetではなくshareメソッドが呼ばれているが実装は以下のようになっている。

<?php

    /**
     * {@inheritdoc}
     */
    public function share($alias, $concrete = null)
    {
        return $this->add($alias, $concrete, true);
    }

addメソッドに$shareというフラグをtrueにして呼び出しているだけである。
公式ドキュメントでは$shareがtrueになっているもの関しては先程の評価式がtrueになる。
つまり同一のインスタンスが返ることになる。
同一インスタンスが返る場合としては、addメソッドをコールした場合でもクラスインスタンスを直接渡す場合もなると公式に書いていた。


ここまでの内容を理解すれば、ルーティング設定時のこの部分を理解できる。

<?php

$container = new League\Container\Container;

$container->share('response', Zend\Diactoros\Response::class);
$container->share('request', function () {
    return Zend\Diactoros\ServerRequestFactory::fromGlobals(
        $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
    );
});

$container->share('emitter', Zend\Diactoros\Response\SapiEmitter::class);

まずDIコンテナを作り、そこにサービスをshareメソッドを使って追加している。
具体的にはPSR-7に準拠したサービスである、ResponseとRequestにEmitterを追加している。

が、しかしPSR-7とは何者か知らなかったので次はそれをまとめる。

HTTP message interfaces (PSR-7)

PSR-7は何を決めているかというと、題名の通りPHPでHTTP通信を扱うときのリクエストとレスポンスの標準となる仕様を決定している。
これに関しては公式と以下のサイトが役に立った。

www.php-fig.org
qiita.com

全てのHTTPリクエストには特定の形式が存在しRequestは以下のような

POST /path HTTP/1.1
Host: example.com

foo=bar&baz=bat

Responseは次のような形式をとる。

HTTP/1.1 200 OK
Content-Type: text/plain

This is the response body

これをPSR-7では次の2つのインターフェースを使って宣言している

Psr\Http\Message\RequestInterface
Psr\Http\Message\ResponseInterface

これらを使うことで、自分でああだこうだしないでHTTPリクエストをPHPで扱うことができる。
ここをまとめようと思うと、DIコンテナの比にならないくらい文章量が多くなってしまうのでここでは割愛。後日別記事にする。

しかし、何故これが必要なのかというとルーティングを構成する次の部分で使っているからである。

<?php
$route->map('GET', '/', function (ServerRequestInterface $request, ResponseInterface $response) {
    $response->getBody()->write('<h1>Hello, World!</h1>');

    return $response;
});

そう、この部分をみてわかるようにルーティングを構成して呼び出すメソッドはPSR-7準拠のインターフェースを引数に持ち$responseを返すことになっているからだ。
今上記のコードだと、HTTPレスポンスのボディにHTMLを書き込みそれを返しているのでHello,World!が表示されるといった状況になっている。
これはこの後、クラスを指定する場合でも同様に呼び出すメソッドには関係してくる。

Dispatchから先

残りはこの部分。

<?php

$response = $route->dispatch($container->get('request'), $container->get('response'));

$container->get('emitter')->emit($response);

ここではdispatchに先ほど設定したDIコンテナの値を使っている。
それをdispatchメソッドに渡している。dispatchメソッドの実装は次の通り。

<?php
    /**
     * Dispatch the route based on the request.
     *
     * @param \Psr\Http\Message\ServerRequestInterface $request
     * @param \Psr\Http\Message\ResponseInterface      $response
     *
     * @return \Psr\Http\Message\ResponseInterface
     */
    public function dispatch(ServerRequestInterface $request, ResponseInterface $response)
    {
        $dispatcher = $this->getDispatcher($request);
        $execChain  = $dispatcher->handle($request);
        foreach ($this->getMiddlewareStack() as $middleware) {
            $execChain->middleware($middleware);
        }
        try {
            return $execChain->execute($request, $response);
        } catch (Exception $exception) {
            $middleware = $this->getStrategy()->getExceptionDecorator($exception);
            return (new ExecutionChain)->middleware($middleware)->execute($request, $response);
        }
    }

dispatcherを取得した後にDispatcherクラスのhandleメソッドをコールしてミドルウェアを適用しそれらを実行している。
最後に今までのあれこれをemit(うまく表現できる日本語がわからなかった)することでルーティングで設定したあれこれが実行される。

ルーティング周りをもう少し頑張ってみる

これまでまとめたところが実は公式で言うところのHello Worldでしかない。
PHPをやったこともない人間が雑に触ろうとした結果PSR-7,11を調べたりしないと行けないのですごく大変だった。
ただ、ここまでやった段階だけでは無名関数を毎回書いて呼び出す必要があったり、もしルーティング内でワイルドカードを使用したい場合などどうするんだとなるのでそのあたりを公式サイトをみてまとめていく。

ワイルドカードを使いたい場合

これが自分自身このライブラリを使いたかった理由のひとつでもあるが、ルーティングとしてpathを設定するときにワイルドカードは次のように使える。

<?php

$router = new League\Route\RouteCollection;

$router->map('GET', '/user/{id}/{name}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) {
    // $args = [
    //     'id'   => {id},  // the actual value of {id}
    //     'name' => {name} // the actual value of {name}
    // ];

    return $response;
});

もっと値を限定して使うなら次のようにする。

<?php

$router = new League\Route\RouteCollection;

// this route will only match if {id} is numeric and {name} is a alpha
$router->map('GET', '/user/{id:number}/{name:word}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) {
    // $args = [
    //     'id'   => {id},  // the actual value of {id}
    //     'name' => {name} // the actual value of {name}
    // ];

    return $response;
});

ここで指定できる種別には以下のものがある。

  • number
  • word
  • alphanum_dash
  • slug
  • uuid

ここまででも十分便利なのに正規表現を適用することもできる。

<?php

$router = new League\Route\RouteCollection;

$router->addPatternMatcher('wordStartsWithM', '(m|M)[a-zA-Z]+');

$router->map('GET', 'user/mTeam/{name:wordStartsWithM}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) {
    // $args = [
    //     'id'   => {id},  // the actual value of {id}
    //     'name' => {name} // the actual value of {name}
    // ];

    return $response;
});

HTTPメソッドでルーティングを設定

今まではmapを使った例が多かったがもう少し便利なものがある。

<?php
$route = new League\Route\RouteCollection;

$route->get('/acme/route', 'Acme\Controller::getMethod');
$route->post('/acme/route', 'Acme\Controller::postMethod');
$route->put('/acme/route', 'Acme\Controller::putMethod');
$route->patch('/acme/route', 'Acme\Controller::patchMethod');
$route->delete('/acme/route', 'Acme\Controller::deleteMethod');
$route->head('/acme/route', 'Acme\Controller::headMethod');
$route->options('/acme/route', 'Acme\Controller::optionsMethod');

直接指定することもできるし、HTTPメソッドごとに処理を分けることもできる。

ホストやスキームを指定する

なんてこともできる。

<?php

$route = new League\Route\RouteCollection;

// this route will respond to http://example.com/acme/route
// or https://example.com/acme/route
$route->map('GET', '/acme/route', 'AcmeController::method')->setHost('example.com');

// this route will only respond to https://example.com/acme/route
$route->map('GET', '/acme/route', 'AcmeController::method')->setScheme('https')->setHost('example.com');

ルーティングをグループとしてまとめる

これが一番使いたかった機能だと思う。
ルーティングをグループ化して追加できるのはスゴク便利。

<?php

$route = new League\Route\RouteCollection;

$route->group('/admin', function ($route) {
    $route->map('GET', '/acme/route1', 'AcmeController::actionOne');
    $route->map('GET', '/acme/route2', 'AcmeController::actionTwo');
    $route->map('GET', '/acme/route3', 'AcmeController::actionThree');
});

もちろんこれらも個別にホストやスキームを設定できる。

<?php

$route = new League\Route\RouteCollection;

$route->group('/admin', function ($route) {
    $route->map('GET', '/acme/route1', 'AcmeController::actionOne');
    $route->map('GET', '/acme/route2', 'AcmeController::actionTwo')->setScheme('https');
    $route->map('GET', '/acme/route3', 'AcmeController::actionThree');
})
    ->setScheme('http')
    ->setHost('example.com')
;

そして実際のリクエストは次のようになる

GET http://example.com/admin/acme/route1
GET https://example.com/admin/acme/route2
GET http://example.com/admin/acme/route3

これスゴク便利。

さいごに

とりあえず、ここまでが自分の使いたかった機能を一通り網羅した。
ルーティングについて雑に書くはずがDIコンテナやPSRについてまで触れるとは思わなかったけどかなり理解が深まった。
関連するPSRやその他についてもこの後まとめていきたい。

あと自分はWebあたりの知識が割と不足していてわからないことだらけだったので、単純に語句とか調べてなるほどなとなるより
コードをみてこういうふうになっているのかと理解する方がよいということがわかったのは収穫だった