可変関数を駆使してテンプレートにTwigを採用したPHPフレームワークを自作してみた話

個人開発をしていたアプリで、レンタルサーバーを借りてサーバーサイドの開発をしていたのですが、

LaravelやZendFramework等の大手のフレームワークを使おうとしたら、必要なエクステンションのインストールができなかったため、 勉強がてら最低限必要な機能を搭載したPHPフレームワークを自作してみました。

そもそもそれらのPHPフレームワークはどうやって動いているのか?という疑問もあったので、このあたりを勉強した記録も載せます。

本稿で紹介しているフレームワークは以下のURLで公開しています。

https://github.com/KLab/Levis_PHPFramework

目標

  1. オートローダーの実装
  2. マイグレーション機能の搭載
  3. APIとCMS用のWebサイトを表示する機能を実装する
  4. テンプレートシステムはTwigを使用する(要求される権限が極めて少ないため)
  5. MVCパターンを採用

オートローダーについて

PHPがサポートしている可変関数の概念と呼び出したいクラスがロードされていない事がわかったタイミングで呼び出されるコールバック機能を利用して、URLから自動でコントローラとメソッドを判断して実行する仕組みを作成しています。

不勉強だったので知らずに作成していましたが、このフレームワークはPSR-4 Autoloaderには対応しておらず、PSR-0 Autoloading Standardの名前空間以外に対応しています。

同時にCMSとしても使用できるように、Twigをロードしてビューを生成できるようにしています。

以下の機能を利用して、オートローダーを作成していきます。

可変関数について

参考

文字列型の変数の後ろに()を付けると、文字列に該当する関数を呼び出してくれます。

この機能のおかげでかなりフレキシブルに処理をすることができますが、その解決の分だけ処理が重くなります。

実際に使用した例が以下になります。

class Controller
{
    public function action1()
    {
        echo "Call Action1".PHP_EOL;
    }
 
    public function action2()
    {
        echo "Call Action2".PHP_EOL;
    }
}
 
$controller = new Controller();
$method = "action1";
$controller->$method();
 
$method = "action2";
$controller->$method();

このコードの実行結果は以下のようになります。

$ php demo.php
Call Action1
Call Action2
// クラスも文字列から推測してくれるので、以下の様なコード度でも同じ結果を得られる
$controller_name = "Controller";
$controller = new $controller_name();
$action = "action1";
$controller->$action();
 
$action = "action2";
$controller->$action();

URLから実行する処理を取得する

次にURLから実行する機能を推測して実行する機能を作成します。

$_SERVER['PATH_INFO']から必要な情報を取得します。

この変数は、URLから実行するファイル名とクエリの間にある情報を格納しています。

http://www.example.com/php/path_info.php/some/stuff?foo=bar

というURLでアクセスしていた場合、$_SERVER['PATH_INFO']には/some/stuff が格納されています。

ここで取得した情報を元に上述した可変関数と文字列からクラス名を推測する機能を用いてルーターを作成します。

Routerの作成

おおよそ以下のコードでやりたいことが実現できます。

コード全体はリポジトリindex.phpを参照してください。

このフレームワークの設計では、CMSとAPIでディレクトリが1つ分異なり、

コントローラやメソッド名が格納されている配列の位置が異なるので呼び分ける処理を実装しておく必要があります。

class Router
{
    public function dispatch()
    {
        // スラッシュで分けて、使用するクラス名とメソッド名を取得する
        $path_info_list = explode('/', $_SERVER['PATH_INFO']);
        // 先頭は空なので無視
        array_shift($path_info_list);
        // スネークケースで指定される想定なので、キャメルケースのクラス名に変換する
        // また、_controllerはURL上では省略する想定でもある
        $controller_name = $input . '_controller';
        $controller = camelize($controller_name);
        // 指定メソッドがなければindexを使用する(ApiとCMSの振り分けはこの辺りで行う)
        $method = count($path_info_list) < 2 : 'index' : $path_info_list[1];
        // 指定したメソッドを実行 
        $controller->$method();
    }
    // CMSを描画する場合はこちら
    public function renderWeb(string $view_path)
    {
        require_once('./vendor/autoload.php');
        require_once('./libs/twig_extension.php');
        $loader = new Twig\Loader\FilesystemLoader(__DIR__ . '/cms/view');
        $twig = new Twig\Environment($loader);
        $twig->addExtension(new TwigExteinsion());
        $params = $this->controller ? $this->controller->getVars() : [];
        $params['has_notification'] = Session::exists('notification_message');
        if ($params['has_notification']) {
            $params['notification_level'] = Session::get('notification_level');
            $params['notification_message'] = Session::get('notification_message');
            session_destroy();
        }
        echo $twig->render($view_path, $params);
    }
}

しかし、これだけではまだrequireしていないクラスを指定したときにエラーになってしまうので、自動でrequireをする機能を作成します。

足りないファイルを自動でrequireする機能

spl_autoload_registerを使用し、 未定義のクラスを呼び出そうとした際に呼ばれるコールバックを登録します。

このコールバックでも解決できなかった場合はエラーになります。

クラスのメンバ関数を登録したい場合は、配列に[クラス名,メソッド名]と入れておきます。

クロージャーでも問題ありません。

先にクラス名をファイル名に変換するため、キャメルケースをスネークケースに変換する関数を作成します。

function underscore($str)
{
    return ltrim(strtolower(preg_replace('/[A-Z]/', '_\0', $str)), '_');
}

オートロードコールバックに登録する処理を実装したクラスを作成して、モデル・コントローラーを格納しているディレクトリをリストにしておきます。

APIとCMSで読み込み先を変えるのでメンバ変数としてではなく、リストを取得する関数として作成します。

class AutoLoader
{
    private static $is_api = true;
    public static function setIsApi($flag) { self::$is_api = $flag; }
 
    private static function directories()
    {
        static $dirs;
        if (!$dirs) {
            $base = __DIR__. "../api/";
            $dirs = ["{$base}models"];
            if (self::$is_api) {
                $dirs[] = "{$base}controllers";
            } else {
                $cms_base = __DIR__. "../cms/";
                $dirs[] = "{$cms_base}models";
                $dirs[] = "{$cms_base}controllers";
            }
        }
        return $dirs;
    }
 
    public static function loadClass($class)
    {
        foreach (self::directories() as $directory) {
            $file_name = underscore($class). ".php";
            $file_path = "{$directory}/{$file_name}";
            if (is_file($file_path)) {
                require_once $file_path;
                return;
            }
        }
        Logger::getInstance()->error("{$class} is not found");
    }
}
 
spl_autoload_register(['AutoLoader', 'loadClass']);
// 以下のコードでも同じ
spl_autoload_register(function($class_name) {
    Autoloader::loadClass($class_name);
});

おまけ:スネークケースをキャメルケースに変換する関数

function camelize($str)
{
    return lcfirst(strtr(ucwords(strtr($str, ['_' => ' '])), [' ' => '']));
}

ここまでの機能でAPIフレームワークに必要な機能は大体実現できました。

CMSを使う準備

composerで配置した機能のautoloaderと、後述するTwigの拡張機能をまとめたクラスを読み込みこんでおきます。

以下のような関数を用意し、CMSを呼び出す際にはこちらの関数を呼ぶように修正します。

require_once('./vendor/autoload.php');
require_once('./libs/twig_extension.php');
$loader = new Twig\Loader\FilesystemLoader(__DIR__ . '/cms/view');
$twig = new Twig\Environment($loader);
$twig->addExtension(new TwigExteinsion());
$params = $this->controller ? $this->controller->getVars() : [];
$params['has_notification'] = Session::exists('notification_message');
if ($params['has_notification']) {
    $params['notification_level'] = Session::get('notification_level');
    $params['notification_message'] = Session::get('notification_message');
    session_destroy();
}
echo $twig->render($view_path, $params);

TwigはFilesystemLoaderインスタンス作成時に指定したパスをルートとして使用するファイルを表示します。

例えば上記のコードの場合、hoge/fuga.twig をrender関数の第一引数に指定した場合は、 Levis/cms/view/hoge/fuga.twig を読み込み、テンプレートを処理した結果の文字列を返却します。

$paramsはテンプレート内で使用する変数群を連想配列形式で設定しておきます。

今回作成したフレームワークでは、コントローラ内でset関数に入れた値を受け取っていますが、必要な値が得られるのであればやり方は問いません。

Twigの拡張機能は自作する必要がありますので、以下のようなコードを作成します。

Twigの拡張機能

フレームワークではurlを取得する関数・ファイルパスを取得する関数・JSONに整形する関数だけ実装してあります。

作成方法を、簡単にですが記載しておきます。

登録するクラスは、Twig\Extension\AbstractExtensionを継承したものである必要があります。

大量に作成することもないと思いますので、基本的には一つのクラスにまとめてしまっていいと思います。

PHPで作成した機能を使うには、getFunctions()を実装し、TwigFunctionインスタンス配列を返却するようにします。

new TwigFuntion("Twigで参照する名称", [クラスインスタンス, "関数名"])

use Twig\TwigFunction;
 
class TwigExteinsion extends Twig\Extension\AbstractExtension
{
    // ルートindexまでのURLをここに入れる
    const APP_URL = "/index.php";
    public function getFunctions()
    {
        return [
            new TwigFunction('url', [$this, 'url']),
            new TwigFunction('getFilePath', [$this, 'getFilePath']),
            new TwigFunction('json_encode', [$this, 'json_encode'])
        ];
    }
 
    public function url($url)
    {
        return self::APP_URL.$url;
    }
 
    public function getFilePath($path)
    {
        return resolveFilePath(__DIR__, $path);
    }
 
    public function json_encode($json)
    {
        return json_encode($json);
    }
}

ここで作成した関数を、$twig->addExtension()に指定することで、実際にTwigで関数を使用することができるようになります。

マイグレーション機能について

schemalexというGo言語で書かれたSQLファイルを比較して差分を取り、 それを元に変更SQLを生成するツールを使用します。

使い方は、schemalex -o 出力先ファイル名 旧SQLを纏めたファイル 新SQLを纏めたファイルで、

出力先ファイルに新旧SQLファイルから出た差分を埋めるSQLを書き出してくれます。

前準備として、現在のDBのスキーマと、変更予定のスキーマをそれぞれ1ファイルに纏めておきます。

このフレームワークではパーティションの使用を想定していないため省いていますが、パーティションの記述はshcemalexが対応していないので、1ファイルに纏める際に削除しておくことで余計な差分が出ないようになります。

DBからのスキーマの取得はPDOを使用し、SHOW CREATE TABLE の結果を全テーブルから取得します。

こちらが古いデータになるため、old_schema.sqlとしてtmpディレクトリに保存します。

新しいスキーマは、dbフォルダ以下の.sqlファイルを使用します。

こちらをnew_schema.sqlとしてtmpディレクトリに同じく保存します。

最後に、exec関数で以下のコードを実行し、マイグレーションSQLを作成します。

./bin/schemalex -o ./tmp/migration.sql ./tmp/old_schema.sql ./tmp/current_schema.sql

これで生成されたtmp/migration.sqlをmysqlに食わせることでマイグレーションが完了します。

以上の機能をまとめたmigration.phpLevis/以下に用意してありますので、

こちらのプログラムを実行することで自動でマイグレーションをしてくれるようになっています。

これで今回自作したPHPフレームワークの解説を終わります。

PSR-4に対応していなかったりでまだまだ改良の余地がありますが、やりたいことは大体できるようになったため、使いながらブラッシュアップしていく予定です。

ご興味のある方は是非リポジトリをクローンしてみてください。

また、修正も歓迎していますので、是非プルリクエストを作成してください。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。