PHPのルーティングライブラリ比較検証 FastRoute, klein, Pux

## はじめに

汎用的なWebフレームワークを利用せず独自フレームワークで開発している場合に、.htaccess をルーティング処理に利用することがあります。Apacheのmod_rewriteモジュールにより、.htaccessファイルを配置するだけで簡単に利用することが出来ます。

一方で、パフォーマンス向上の為PHP-FPMが利用されますが、PHP-FPMでは.htaccessは利用できないためルーティング処理を別で置き換える必要がありました。今回はその置き換え候補となるルーティングライブラリを調べます。

## ルーティングライブラリに望むこと

* 簡単に導入・利用ができること
* ドキュメントがしっかりしていること
* 高速であること
* エラーハンドリングが容易にできること
* 将来性があり、継続的なメンテナンスがされていること

## 比較ライブラリ

以下の３つを比較します。候補として選んだ理由は [Packagist](https://packagist.org/)で「Route」で検索時に上位に表示されかつStar数が多いものを選びました。

* [nikic/FastRoute: Fast request router for PHP](https://github.com/nikic/FastRoute)
* [klein/klein\.php: A fast & flexible router](https://github.com/klein/klein.php)
* [c9s/Pux: Pux is a fast PHP Router and includes out\-of\-box controller tools](https://github.com/c9s/Pux)

## サンプルコード

以下の要件を満たすルーティングを各ライブラリで実装しました。

* エントリポイント
    * `/`
    * `/profile/@hypermkt`
    * `/old_url`
        * `/new_url` に301リダイレクトする 
    * `/not_found`
        * 404 HTTP ステータスコードを返す

### FastRoute

```php
<?php

require_once './vendor/autoload.php';

$dispatcher= FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $router) {
    $router->addRoute('GET', '/', 'index');
    $router->addRoute('GET', '/profile/@{name:\w+}', 'profile');
    $router->addRoute('GET', '/old_url', 'old_url');
    $router->addRoute('GET', '/new_url', 'new_url');
});

// HTTPメソッドとUILを取得する
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string (?foo=bar) and decode URI
if (false !== $pos = strpos($uri, '?')) {
    $uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        header('HTTP/1.0 404 Not Found');
        echo not_found();
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        $allowedMethods = $routeInfo[1];
        header('HTTP/1.0 405 Method Not Allowed');
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        echo $handler($vars);
        break;
}

function index()
{
    return 'トップページです';
}

function profile($vars)
{
    return $vars['name'] . 'のプロフィールページです';
}

function old_url()
{
    header('HTTP/1.1 301 Moved Permanently');
    header('Location: /new_url');
    exit;
}

function new_url()
{
    return '新しいURLです';
}

function not_found()
{
    return 'Not Foundです';
}
```

### klein.php
```php
<?php

require_once './vendor/autoload.php';

$klein = new \Klein\Klein();

$klein->respond('GET', '/', function() {
    return index();
});

$klein->respond('GET', '/profile/@[:name]', function($request) {
    return profile($request);
});

$klein->respond('GET', '/old_url', function($request, \Klein\Response $response) {
    // https://github.com/klein/klein.php#api
    $response->redirect('/new_url', 301);

});

$klein->respond('GET', '/new_url', function() {
    return new_url();
});

// refs: https://github.com/klein/klein.php/wiki/Handling-404's
$klein->onHttpError(function ($code, $router) {
    switch ($code) {
        case 404:
            $router->response()->body(not_found());
            break;
        default:
            $router->response()->body(
                'Oh no, a bad error happened that caused a '. $code
            );
    }
});

$klein->dispatch();

function index()
{
    return 'トップページです';
}

function profile(Klein\Request $request)
{
    return $request->name . 'のプロフィールページです';
}

function old_url()
{
    header('HTTP/1.1 301 Moved Permanently');
    header('Location: /new_url');
    exit;
}

function new_url()
{
    return '新しいURLです';
}

function not_found()
{
    return 'Not Foundです';
}
```

### Pux

```php
<?php

require_once './vendor/autoload.php';

use Pux\Executor;

class HogeController
{
    public function index()
    {
        return 'トップページです';
    }

    public function profile($name)
    {
        return $name . 'のプロフィールページです';
    }

    public function old_url()
    {
        header('HTTP/1.1 301 Moved Permanently');
        header('Location: /new_url');
    }

    public function new_url()
    {
        return '新しいURLです';
    }
}

$mux = new Pux\Mux();
$mux->get('/', ['HogeController', 'index']);
$mux->get('/profile/@:name', ['HogeController', 'profile'], [
    'require' => ['name' => '\w+']
]);

$uri = $_SERVER['REQUEST_URI'] ?? [];
$route = $mux->dispatch($uri);

// 同じ悩みの人: https://github.com/c9s/Pux/issues/101
if (is_null($route)) {
    header('HTTP/1.0 404 Not Found');
    echo 'Not Foundです';
} else {
    echo Executor::execute($route);
}
```

では、個別に見ていきましょう。

## ルーティング設定

### FastRoute

`FastRoute\simpleDispatcher` メソッドは最初は何をしているか分かりづらいですが、ソースコードを読むと分かりました。引数にコールバック関数を渡しており、その引数でルーティングを設定できます。引数は `RouteCollector` のオブジェクトなので、`simpleDispatcher` 関数内からでも参照出来るようになっていたんですね。

ルーティング設定という点では `$router` に `addRoute`メソッドを利用することで設定できます。ポイントとなるのは第３引数のハンドラーの文字列ですが、ディスパッチ時に利用します。一旦次に進みます。

```php
$dispatcher= FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $router) {
    $router->addRoute('GET', '/', 'index');
    // ...
});
```

ここではHTTPメソッド( GET, POSTなど )とURI( /users/1 )を取得します。またGETパラメーターがある場合は `?` を取り除いたパラメーター部( label=value ) だけにします。

```php
// HTTPメソッドとUILを取得する
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string (?foo=bar) and decode URI
if (false !== $pos = strpos($uri, '?')) {
    $uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);
```

取得したHTTPメソッド、URIに沿ってディスパッチ処理が実行され、結果別に処理を割り振ります。ルーティングにマッチした場合は、 `FastRoute\Dispatcher::FOUND` のcaseに入り、ルーティング設定したハンドラー文字列のメソッドが `echo $handler($vars);` のように実行されます。

このサンプルでは `echo $handler($vars);` でハンドラー名のメソッドを呼ぶ方式になっていますが、オブジェクト指向の設計になっていれば、コントローラークラスをインスタンス化して、アクションメソッドを呼ぶような方式でも良さそうですね。

```php
$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        header('HTTP/1.0 404 Not Found');
        echo not_found();
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        $allowedMethods = $routeInfo[1];
        header('HTTP/1.0 405 Method Not Allowed');
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        echo $handler($vars);
        break;
}

function index()
{
    return 'トップページです';
}
```

全体的な印象としては、機能毎にブロック単位で分割されており、見通しが良かったです。特にルーティング設定の箇所は、URI毎に1行で記述できるのがスッキリしていいですね。

### klein.php

まずは `\Klein\Klein` をインスタンス化します。ルーティング設定はインスタンス化したオブジェクトの`respond`メソッドに、HTTPメソッド、URI、コールバックの引数を渡すことで設定できます。FastRouteと違って、第３引数がコールバックになっているのが特徴的です。

```php
$klein = new \Klein\Klein();

$klein->respond('GET', '/', function() {
    return index();
});

$klein->respond('GET', '/profile/@[:name]', function($request) {
    return profile($request);
});
```

kleinの良い点は[API](https://github.com/klein/klein.php#api)が充実しているので、各種便利メソッドが用意されています。

```php
$klein->respond('GET', '/old_url', function($request, \Klein\Response $response) {
    $response->redirect('/new_url', 301);
});
```

ルーティング設定にマッチしなかったケース別での処理も実装可能でした。FastRouteと違ってステータスコード別に処理が出来るのが分かりやすくていいです。

```php
$klein->onHttpError(function ($code, $router) {
    switch ($code) {
        case 404:
            $router->response()->body(not_found());
            break;
        default:
            $router->response()->body(
                'Oh no, a bad error happened that caused a '. $code
            );
    }
});

$klein->dispatch();
```

全体的な印象としてはAPIも充実しており、メソッド名も分かりやすく実用的でした。ルーティング処理もシンプルではありますが、気になるのは第３引数にコールバックを渡している箇所です。1エントリポイントに最低3行記述するということは、ざっくりFastRouteの3倍のコード量となるので、見通しも悪くなってしまう懸念があります。多数のルーティング設定をする場合には不向きだと感じました。

### Pux

Puxの特徴はディスパッチ時にクラスのインスタンスメソッドを呼ぶ設計となっていることです。オブジェクト指向のシステムを見越しての機能だと考えますが、全てのアプリケーションがそのような実装にはなっていないので、自由度を狭めている印象がありました。

```php
$mux = new Pux\Mux();
$mux->get('/', ['HogeController', 'index']);
$mux->get('/profile/@:name', ['HogeController', 'profile'], [
    'require' => ['name' => '\w+']
]);
```

Puxの欠点はルーティングにマッチしなかった場合の処理は独自で実装する必要があることでした。[同じように悩んでいる人](https://github.com/c9s/Pux/issues/101)もおり、ドキュメントに説明がないとほとんど人が悩むと思います。

```php
$uri = $_SERVER['REQUEST_URI'] ?? [];
$route = $mux->dispatch($uri);

// 同じ悩みの人: https://github.com/c9s/Pux/issues/101
if (is_null($route)) {
    header('HTTP/1.0 404 Not Found');
    echo 'Not Foundです';
} else {
    echo Executor::execute($route);
}
```

## パフォーマンス計測

### 計測方法
* Macローカル環境でPHPビルトインサーバーを利用する
* Gatlingをローカルサーバーに対して実行する
* Failedが発生しない最大の平均リクエスト毎秒、平均レスポンスタイムを計測する
* シナリオは以下と、パラメーター有り・無し、リダイレクト有りの３エントリポイントをリクエストする

```scala
  val scn = scenario("GET")
    .exec(http("/")
    .get("/"))
    .exec(http("/profile/@hypermkt")
    .get("/profile/@hypermkt"))
    .exec(http("/old_url")
    .get("/old_url"))
```

### 結果

平均リクエスト数はFastRoute, klein.phpは同等、Puxが4%ほど劣る。平均リクエスト毎秒はFastRouteとklein.phpの両者では、klein.phpの方が低かった。結果的にはklein.phpが優勝。ただ手元で実行時に同じ条件でもfailedが発生したときもあるので、大きな差はないと考えても良さそう。

#### FastRoute 35req/s(10s)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/29755/6c63eb34-e9c8-b54f-2ad1-51967990416d.png)

#### klein.php 35req/s(10s)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/29755/990ca086-80c9-98ed-5b59-1cccbfb8f100.png)

#### Pux 35req/s(10s)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/29755/df5eb69c-9d4f-d959-8bc7-242395bdce69.png)

## 将来性
どれも直近の更新がないのは気になるが、klein.phpとPuxは半年間動きがないのが心配。

#### FastRoute
* 3個のPRが残ったままだが、2017/10まで更新されている
* [Slim3のルーティングライブラリ](https://www.slimframework.com/docs/objects/router.html)として採用されている
* 開発者がPHPのコミッター

と信頼性と安心は一番高い

#### klein.php
* 15個のPRが放置されたまま
* 2017/02から更新なし

#### Pux
* `2.0.x Branch Build Status (This branch is under development)` とのことで、該当するブランチは無かった。

## まとめ
各ツールを3点(1:悪い/難しい 2:普通 3:良い/簡単)で評価します。

-|FastRoute|klein.php|Pux
---|---|---|---
ドキュメントの充実性|3|3|2
学習コスト|3|2|3
機能性|2|3|2
将来性|3|2|2
スピード|3|3|2
合計|14|12|10

結果、`FastRoute` が総合的に一番となった。各ツールごとの感想は以下のとおりです。

* FastRoute
    * 実装面ではルーティング設定が一番簡素に書けるのがとても良かった。[Route Groups](https://github.com/nikic/FastRoute#route-groups) 設定もあり、一番実用的な印象。APIは他と劣る点はあるが、薄いライブラリである文、コードリーディングもしやすいので、そこはプラスと捉えて良さそう。
    * PHPのコミッターの方が開発されたという安心感も大きい
* klein.php
    * 機能メンテは一番充実しており、多少のコーディング量が我慢できればklein.phpを使うのも有りだと思う。ただPRが放置されているので、今後のメンテナンスが心配。
* Pux
    * コントローラークラスを自動マンティングしてくれるのはとても良い。だが、それが必須条件になっているのは自由度が低いと思った。（世の中にはもう多くはないが）関数型の古いシステムへの導入は出来ない。
    * 404 Not Found時の対応が提示されていなかったのも困った。https://github.com/c9s/Pux/issues/101