ITOO's Blog

プログラミングを語る

素のPHPでディスパッチャを作ってみた

はじめに

手軽に使えるディスパッチャーを作りました。最小限の機能で使いがっていいので、フレームワークを使うほどでもないってときにつかえるかもしれません!ちなみに前回テンプレートエンジンをつくりました。

syarig.hatenablog.com

ディスパッチャーとはなにかについてはこのサイトが分かりやすく説明してくれてます。

ディレクトリ構成

app
├── classes
│   └── controller
│       └── HelloController.php
├── configs
│   └── routes.php
├── public
│   └── index.php
└── utils
    └── Dispatcher.php

まずは、htaccessなどでindex.phpにリダイレクトするように設定しておいてください。そして、今回作ったディスパッチャーでリクエストをさばくようにするとMVCモデルの感じがでてくると思います。アプリで使う定数(ルートパス)などもindex.phpに書いていくといいのではないでしょうか。

今回はディスパッチャーのテストとしてHelloControllerindexActionを用意しました。ルーティング設定はconfigs/routes.phpに書いてあります。もし使われる場合はここにルーティングを追加するといいでしょう。

ディスパッチャー(app/public/index.php

<?php

/**
 * Class Dispatcher
 * uriに従ってコントロータとアクションを振り分ける
 */
class Dispatcher
{
    const CONTROLLER_DIR = 'classes/controller' . DIRECTORY_SEPARATOR;

    // コントローラファイルへのパス
    private $controller_path;

    // 設定ファイルを正規表現の形にしたものが入る
    private $patterns;

    // コントローラ名
    private $controller;

    // アクション名 ex.) indexAction
    private $action;

    // アクションに渡される引数 ex.) $args['name']
    private $args;

    // リクエストがあったURI
    private $request_uri;

    /**
     * コンストラクタでルーティングの初期設定を行う
     *
     * @param string[] $routes
     */
    public function __construct($routes)
    {
        $this->controller_path = ROOT . self::CONTROLLER_DIR;
        $this->patterns = $this->setPatterns($routes);
        if ($this->resolve() === false) {
            $this->controller = $routes[DIRECTORY_SEPARATOR]['controller'];
            $this->action = $routes[DIRECTORY_SEPARATOR]['action'];
        }
    }

    /**
     * 正規表現に変換する。ex.) {action} -> (?P<action>[^/]+)
     * 
     * @param string $route
     * @return string 正規表現の文字列
     * 
     */
    private function tokenToRegexp($route)
    {
        if (preg_match('/{(.+?)}/', $route, $matches)) {
            $route = "(?P<{$matches[1]}>[^/]+)";
        }
        return $route;
    }

    /**
     * ルーティングURLを正規表現に変換する
     * ex.) /{name} -> /(?P<name>[^/]+)
     * 
     * @param string[] $route
     * @return string[] ルーティングの対応を示す配列
     */
    private function setPatterns($routes)
    {
        $patterns = [];

        foreach ($routes as $url => $route) {
            $url = rtrim($url, DIRECTORY_SEPARATOR);
            $url = ltrim($url, DIRECTORY_SEPARATOR);
            $tokens = explode(DIRECTORY_SEPARATOR, $url);
            $tokens = array_map([$this, 'tokenToRegexp'], $tokens);

            $pattern = DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $tokens);
            $patterns[$pattern] = $route;
        }
        return $patterns;
    }

    /**
     * URLとルーティングパターンからコントローラ、アクション、引数を決める
     * 
     * @return bool
     */
    public function resolve()
    {
        // リクエストURIの末尾の'/'を削除する。
        $this->request_uri = rtrim($_SERVER['REQUEST_URI'], DIRECTORY_SEPARATOR);

        if (DIRECTORY_SEPARATOR !== substr($this->request_uri, 0, 1)) {
            $this->request_uri = DIRECTORY_SEPARATOR . $this->request_uri;
        }

        foreach ($this->patterns as $pattern => $route) {
            if (preg_match('#^' . $pattern . '$#', $this->request_uri, $matches)) {
                $route = array_merge($route, $matches);
                $this->controller = $route['controller'];
                $this->action = $route['action'];

                unset($route['controller']);
                unset($route['action']);

                $this->args = $route;
                return true;
            }
        }
        return false;
    }

    public function getRequestUri()
    {
        return $this->request_uri;
    }

    /**
     * アクションを実行する
     */
    public function dispatch()
    {
        $controller_file = $this->controller_path . $this->controller . '.php';

        if (file_exists($controller_file) === false) {
            throw new InvalidArgumentException("{$controller_file}が存在しません。");
        }

        require_once $controller_file;

        $controller_instance = new $this->controller();
        $action_method = $this->action . 'Action';

        if (method_exists($controller_instance, $action_method) === false) {
            throw new InvalidArgumentException("{$action_method}が存在しません。");
        }

        $controller_instance->$action_method($this->args);
    }
}

少しコードが複雑だと思うのでコメントを多めに書きました。簡単に説明をするとリクエスURIからコントローラのインスタンスを作成して、そのコントローラーのアクションメソッドを呼びだしています。アクションメソッドをget_xxxみたいな指定にしたりしてGETメソッドだけを受けとるみたいなことをすればよりフレームワークっぽくなる気がします。

ルーティングの設定(app/configs/routes.php

<?php

return [
    '/' => [
        'controller' => 'HelloController',
        'action' => 'index'
    ],
    '/{name}' => [
        'controller' => 'HelloController',
        'action' => 'index'
    ],
];

ルーティングの設定ファイル。{変数名}という形で動的ルーティングができるようになっています。

app/public/index.php

<?php

require_once __DIR__ . '/../utils/Dispatcher.php';

// アプリのルート
const ROOT = __DIR__ . '/..' . DIRECTORY_SEPARATOR;

$dispatcher = new Dispatcher(include_once __DIR__ . '/../configs/routes.php');

try {
    $dispatcher->dispatch();

} catch (InvalidArgumentException $e) {
    echo "404, Page Not Found\n";
    echo $e;

} catch (Exception $e) {
    echo "500, Internal Server Error\n";
    echo $e;
}

このファイルにアクセスが集まることになります。また、「コントローラやアクションが存在しないとき」 = 「ページが存在しないとき」は404 Page Not Foundを表示します。ほんとうは独自に例外を定義して、404ページも用意するといいと思います。

実行する

app/public/配下でphp -S localhost:8080とコマンドを打ってから、ブラウザでlocalhost:8080/にアクセスしてみてください。Hello Worldと画面に表示されたら成功です!また、試しにlocalhost:8080/HogehogeとしてみるとHello Hogehogeと表示されるはずです!

まとめ

ディスパッチャーは百数十行程度ですが、シンプルで使い易いものができたと思います。前回作ったテンプレートエンジンと組み合わせるとフレームワークっぽくなってきたのがわかると思います!ですが、まだまだフレームワークと呼ぶには足りないものがあります!セッションやリクエスト、レスポンスなども専用のクラスを用意したほうがよいでしょう。次回はSessionクラスを作ってみたいと思います。

最後に

今回はテンプレートエンジンと組み合わせる予定でしたが、結構な分量になってしまったのでここで終わりたいと思います。このブログではこんな感じでフレームワークのパーツを一つずつ作っていきオレオレフレームワークを作っていきます。PHPの学習などにもお役に立てたら幸いです。 また、「こんな風にしたらもっとよくなるよ」「ここ間違ってるよ」などなど意見、質問、アドバイスをいただけたら嬉しいです。

参考資料

PHPフレームワーク Laravel入門

PHPフレームワーク Laravel入門

改訂 FuelPHP入門

改訂 FuelPHP入門