k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

マイクロフレームワークをつくろう - Pimpleの上に(RESTインタフェース編)

Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。

コードはWindows版 PHP5.4 ビルトインWebサーバにて動作確認しています。

Step 6 RESTインタフェースの実装

今度はマイクロフレームワークの定番、RESTインタフェースをアプリケーションオブジェクトに実装します。

この場合は、利用側コードをどう書きたいかを先に考えた方が良いでしょう。

こういう完成形をイメージしました。

www/index.php

<?php
$app = include realpath(__DIR__ . '/../app/app.php');

$app->on('GET|POST', function($app, $method) {

    // エラーメッセージの配列
    $errors = array();

    $form = array(
        'name'    => $app->findVar('P', 'name'),
        'comment' => $app->findVar('P', 'comment'),
    );

    // POSTメソッドの時だけ…
    if ($method === 'POST') {

        // 入力値のバリデーションとか…
        if (empty($errors)) {

            // リダイレクト先で表示する完了メッセージをセッションにセットしたり…
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

RESTじゃねえ!と怒られそうですが、リクエストをすべてAjaxで実装しない限り、POSTでもGETと同様にフォームのHTMLを返すのが普通ですし(バリデーションエラーとかあるじゃないですか)、この方が都合がいいわけで…。

これまでSilexアプリケーションを作っていて、結局ほとんどの画面で $app->post() を使わず $app->get() あるいは $app->match()->method('GET|POST') を使ったという実情もありますし、ここは自分の使いやすさを優先します。

$app->run() 実行時にリクエストメソッドによって $app->on() で定義されたコールバックが実行される流れで、$app->run() ではコールバックの $app->render() や $app->redirect() が返すResponseオブジェクトを受け取ってクライアントに送信、コールバックから例外がスローされたらそれをキャッチして、エラー用のレスポンスをクライアントに送信、といった感じになるでしょうか。

アプリケーションオブジェクトに登録すべき関数の内容が大体見えたところで、実装に入ります。

app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;
use Acme\Renderer;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new Renderer(array(
        'template_dir' => __DIR__ . DIRECTORY_SEPARATOR . 'templates',
    ));
    // $app
    $renderer->assign('app', $app);
    // $_SERVER
    $renderer->assign('server', $app->request->server->all());
    return $renderer;
});

// レンダラオブジェクトからのテンプレート出力でレスポンスを生成
$app->render = $app->protect(function($view, array $data = array(), $statusCode = 200, $headers = array()) use ($app) {
    return new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
});

// リダイレクトレスポンスを生成
$app->redirect = $app->protect(function($url, $statusCode = 303, $headers = array()) use ($app) {
    return new RedirectResponse(
        (false === strpos($url, '://'))
            ? $app->request->getSchemeAndHttpHost() . $url
            : $url,
        $statusCode, $headers
    );
});

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

// アプリケーションへのリクエストハンドラ登録
$app->on = $app->protect(function($allowableMethod, $function) use ($app) {
    $allowableMethods = explode('|', $allowableMethod);
    $handler = $app->protect(function(Application $app, $method) use ($function) {
        return $function($app, $method);
    });
    if (in_array('GET', $allowableMethods)) {
        $app->onGet = $handler;
    }
    if (in_array('POST', $allowableMethods)) {
        $app->onPost = $handler;
    }
    if (in_array('PUT', $allowableMethods)) {
        $app->onPut = $handler;
    }
    if (in_array('DELETE', $allowableMethods)) {
        $app->onDelete = $handler;
    }
});

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {
    $method = $app->request->getMethod();
    $handlerName = 'on' . ucfirst(strtolower($method));
    if (!$app->offsetExists($handlerName)) {
        $response = new Response('Method Not Allowed', 405);
    } else {
        try {
            $response = $app->{$handlerName}($app, $method);
        } catch (\Exception $e) {
            $response = new Response('Internal Server Error', 500);
        }
    }
    $response->send();
});

return $app;

前述の方針に合わせて、$app->render() でレスポンスを送信していたのをやめて、Responseオブジェクトを返すようにしました。

新たに登録した $app->redirect() は同様に、Symfony HttpFoundationのRedirectResponseオブジェクトを返しています。

(HttpFoundationにはデータをJSON形式で返せるJsonResponseや、コールバックでファイル等の内容を読みながら返せるStreamedResponseが用意されているので、Silexと同様 $app->json() や $app->stream() も似たような感じで実装できそうです)

$app->on() の内容がちょっと分かりづらいかもしれませんが、引数で指定されたHTTPメソッドに対応するコールバックを関数内で生成して、そのコールバックを on{HTTPメソッド} という関数としてアプリケーションに登録しています。

そして $app->run() でHTTPメソッドに合致するコールバックが検出されればそれを実行し、検出されなければステータス405のレスポンスを返します。

また、コールバック内で例外が発生した場合は、ステータス500のレスポンスを返します。

(ここは他にも 400,401,403,404といった典型的なエラーステータスを返すこともあるでしょうし、それらに対応した例外クラスを定義する必要がありそうです)

スクリプトはこうなりました。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Step 6
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

$app->on('GET|POST', function($app, $method) {

    $errors = array();

    $form = array(
        'name'    => $app->findVar('P', 'name'),
        'comment' => $app->findVar('P', 'comment'),
    );

    if ($method === 'POST') {

        if (strlen($form['name']) === 0) {
            $errors['name'] = '名前を入力してください。';
        } elseif (mb_strlen($form['name']) > 20) {
            $errors['name'] = '名前は20文字以内で入力してください。';
        }

        if (strlen($form['comment']) === 0) {
            $errors['comment'] = 'コメントを入力してください。';
        } elseif (mb_strlen($form['comment']) > 50) {
            $errors['comment'] = 'コメントは50文字以内で入力してください。';
        }

        if (empty($errors)) {
            // $app->addFlashMessage('success', '投稿を受け付けました');
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

簡単なものではありますが、POSTメソッド時に入力値のバリデーションを行うようにしました。

エラーがあればGETメソッド時の流れでそのままフォームのHTMLを返しますが、エラーメッセージの変数出力が追加されています。

アプリケーションオブジェクトの中ではSymfony HttpFoundationコンポーネントへの依存が広がっていますが、まだ利用側スクリプトにはそれに由来するコードは登場しません。

バリデーション後の $app->redirect() 前のコメントアウトしている部分はいわゆるポストバック方式でリダイレクト先に表示するメッセージを定義していますが、ここではセッション変数を扱う必要がありそうです。

そうなるとまたアプリケーションオブジェクトに追加コードが増えそうなので、今回は後回しにします。

テンプレートはこうなりました。

app/templates/index.html

<html>
<body>
<h1>俺のフレームワーク@<?=$app->escape($server['HTTP_HOST'])?></h1>

<form method="post" action="<?=$app->escape($server['REQUEST_URI'])?>">

<?php if (count($errors) > 0) : ?>
<ul>
<?php foreach ($errors as $error) : ?>
<li><?=$app->escape($error)?></li>
<?php endforeach ?>
</ul>
<?php endif ?>

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

</body>
</html>

相変わらずHTMLのフリをしつつ中身はPHPファイルですが、ついに制御構文が出てきてしまいました。

まだ単純なテキスト入力フォームなのでましですが、ラジオボタンやチェックボックスを扱いだすともう大変です。

様々な課題が洗い出されましたが、次回はRendererクラスを差し替えて、何かテンプレートエンジンを導入してみます。

(業務ではいつもSmarty3なので、多分それ以外になるかと…)