Create your PHP Frameworkをやってみる④

Create your PHP Frameworkをやってみる③の続きです。

この記事は、version 2.7に基づいています。

The Routing Component (current)

テンプレートを読みやすく

現状だとテンプレート上に、Requestからデータを取得してテンプレートで必要な変数を定義する処理があります。

できればこのような処理はテンプレートから削除したいですね。

そこで、front.phpを以下のように変更します。

--- a/web/front.php
+++ b/web/front.php
@@ -6,21 +6,20 @@ use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;

 $request = Request::createFromGlobals();
-$response = new Response();

 $map = array(
-    '/hello' => __DIR__.'/../src/pages/hello.php',
-    '/bye'   => __DIR__.'/../src/pages/bye.php',
+    '/hello' => 'hello',
+    '/bye'   => 'bye',
 );

 $path = $request->getPathInfo();
 if (isset($map[$path])) {
     ob_start();
-    include $map[$path];
-    $response->setContent(ob_get_clean());
+    extract($request->query->all(), EXTR_SKIP);
+    include sprintf(__DIR__.'/../src/pages/%s.php', $map[$path]);
+    $response = new Response(ob_get_clean());
 } else {
-    $response->setStatusCode(404);
-    $response->setContent('Not Found');
+    $response = new Response('Not Found', 404);
 }

 $response->send();

これで、テンプレートで$requestからデータを取得するPHPコードを削除できます。

--- a/src/pages/hello.php
+++ b/src/pages/hello.php
@@ -1,3 +1 @@
-<?php $name = $request->get('name', 'World') ?>
-
 Hello <?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>

ただし、これだとクエリ文字列にnameがない場合にエラーになりますが、それはこの後で対処されるようです。

Routingコンポーネントの導入

変数$mapで定義していたURLパスとテンプレートの関係(ルーティング)を処理するため、Symfonyコンポーネントの1つであるRoutingコンポーネントを導入します。

$ composer require symfony/routing
Using version ^2.7 for symfony/routing
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing symfony/routing (v2.7.2)
    Downloading: 100%         

symfony/routing suggests installing symfony/config (For using the all-in-one router or any loader)
symfony/routing suggests installing symfony/yaml (For using the YAML loader)
symfony/routing suggests installing symfony/expression-language (For using expression matching)
symfony/routing suggests installing doctrine/annotations (For using the annotation loader)
Writing lock file
Generating autoload files

v2.7.2がインストールされました。

front.phpをRoutingコンポーネントを使うように書き換えます。

--- a/web/front.php
+++ b/web/front.php
@@ -4,22 +4,25 @@ require_once __DIR__.'/../vendor/autoload.php';

 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing;

 $request = Request::createFromGlobals();
+$routes = include __DIR__.'/../src/app.php';

-$map = array(
-    '/hello' => 'hello',
-    '/bye'   => 'bye',
-);
+$context = new Routing\RequestContext();
+$context->fromRequest($request);
+$matcher = new Routing\Matcher\UrlMatcher($routes, $context);

-$path = $request->getPathInfo();
-if (isset($map[$path])) {
+try {
+    extract($matcher->match($request->getPathInfo()), EXTR_SKIP);
     ob_start();
-    extract($request->query->all(), EXTR_SKIP);
-    include sprintf(__DIR__.'/../src/pages/%s.php', $map[$path]);
+    include sprintf(__DIR__.'/../src/pages/%s.php', $_route);
+
     $response = new Response(ob_get_clean());
-} else {
+} catch (Routing\Exception\ResourceNotFoundException $e) {
     $response = new Response('Not Found', 404);
+} catch (Exception $e) {
+    $response = new Response('An error occurred', 500);
 }

 $response->send();

ルーティングを定義するsrc/app.phpを作成します。

src/app.php

<?php

use Symfony\Component\Routing;

$routes = new Routing\RouteCollection();
$routes->add('hello', new Routing\Route('/hello/{name}', array('name' => 'World')));
$routes->add('bye', new Routing\Route('/bye'));

return $routes;

ルーティングの動作を確認しておきましょう。以下のようにfront.phpを変更して、ブラウザでアクセスしてみます。

--- a/web/front.php
+++ b/web/front.php
@@ -13,6 +13,12 @@ $context = new Routing\RequestContext();
 $context->fromRequest($request);
 $matcher = new Routing\Matcher\UrlMatcher($routes, $context);

+var_dump($matcher->match('/bye'));
+var_dump($matcher->match('/hello/Fabien'));
+var_dump($matcher->match('/hello'));
+//$matcher->match('/not-found');
+exit;
+
 try {
     extract($matcher->match($request->getPathInfo()), EXTR_SKIP);
     ob_start();

結果は以下のようになりました。

array (size=1)
  '_route' => string 'bye' (length=3)

array (size=2)
  'name' => string 'Fabien' (length=6)
  '_route' => string 'hello' (length=5)

array (size=2)
  'name' => string 'World' (length=5)
  '_route' => string 'hello' (length=5)

以下のようになっていることがわかります。

  • URLパスが/byeの場合は、_routebye
  • /hello/Fabienの場合は、_routehellonameFabien
  • /helloの場合は、_routehellonameWorld

また、マッチしないURLパスの場合は、例外Symfony\Component\Routing\Exception\ResourceNotFoundExceptionが発生しました。

現在のfront.phpを確認しておきましょう。

web/front.php

<?php

require_once __DIR__.'/../vendor/autoload.php';

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

$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';

$context = new Routing\RequestContext();
$context->fromRequest($request);
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);

try {
    extract($matcher->match($request->getPathInfo()), EXTR_SKIP);
    ob_start();
    include sprintf(__DIR__.'/../src/pages/%s.php', $_route);

    $response = new Response(ob_get_clean());
} catch (Routing\Exception\ResourceNotFoundException $e) {
    $response = new Response('Not Found', 404);
} catch (Exception $e) {
    $response = new Response('An error occurred', 500);
}

$response->send();

全部で28行です。かなり、フレームワークらしくなりました。

(続く)

Tags: php, symfony

Create your PHP Frameworkをやってみる③

Create your PHP Frameworkをやってみる②の続きです。

この記事は、version 2.7に基づいています。

The Front Controller (current)

Goodbyeページの作成

別のページ、Goodbyeページを作成します。

bye.php

<?php

require_once __DIR__.'/vendor/autoload.php';

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

$request = Request::createFromGlobals();

$response = new Response('Goodbye!');
$response->send();

http://127.0.0.1:4321/bye.phpにブラウザでアクセスすると「Goodbye!」と表示されるはずです。

ただ、これだとindex.phpbye.phpどちらも以下のコードは同じです。

<?php

require_once __DIR__.'/vendor/autoload.php';

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

$request = Request::createFromGlobals();

また、この方法では、ページを増やすたびに同じコードをコピーすることになり、よくなさそうです。

そこで、重複するコードを別のファイルにまとめるようにリファクタリングします。

共通するコードを別のファイルにまとめる

init.phpを作成し、共通するコードをまとめます。

init.php

<?php

require_once __DIR__.'/vendor/autoload.php';

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

$request = Request::createFromGlobals();
$response = new Response();

index.phpinit.phpをインクルードするようにします。

index.php

<?php

require_once __DIR__.'/init.php';

$input = $request->get('name', 'World');

$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
$response->send();

bye.phpも同じように書き換えます。

bye.php

<?php

require_once __DIR__.'/init.php';

$response->setContent('Goodbye!');
$response->send();

かなりすっきりしました。しかし、まだ$response->send();は重複しています。

フロントコントローラの導入

さらに改良するために、フロントコントローラを導入します。

フロントコントローラとは、すべてのリクエストを受ける1つのPHPファイルのことです。

フロントコントローラとしてfront.phpを作成します。

front.php

<?php

require_once __DIR__.'/vendor/autoload.php';

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

$request = Request::createFromGlobals();
$response = new Response();

$map = array(
    '/hello' => __DIR__.'/hello.php',
    '/bye'   => __DIR__.'/bye.php',
);

$path = $request->getPathInfo();
if (isset($map[$path])) {
    require $map[$path];
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}

$response->send();

上記のファイルがすべてのアクセスを受け、必要なファイルを呼び出すことになります。

どのファイルを呼び出すかは$mapで定義されています。

hello.phpを作成します。

hello.php

<?php

$input = $request->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));

bye.phpを変更します。

bye.php

<?php

$response->setContent('Goodbye!');

これで、コードの重複はなくなり、以下のURLでアクセスできるようになりました。

  • http://127.0.0.1:4321/front.php/hello?name=Fabien
  • http://127.0.0.1:4321/front.php/bye

フォルダ構成の改良

フォルダ構成を改良します。

フロントコントローラ以外はブラウザからアクセスする必要はありませんので、webフォルダを作成しその中に移動します。

ページのソースもsrc/pagesフォルダに移動します。

いらないファイルは削除しましょう。

    deleted:    index.php
    deleted:    init.php
    renamed:    bye.php -> src/pages/bye.php
    renamed:    hello.php -> src/pages/hello.php
    deleted:    test.php
    renamed:    front.php -> web/front.php

ということで、以下のようになりました。よりちゃんとしたフレームワークっぽくなりました。

framework/
├── composer.json
├── composer.lock
│   src/
│   └── pages/
│       ├── hello.php
│       └── bye.php
├── vendor/
└── web/
    └── front.php

ファイルを移動したので、それに合うようにパスを修正しておきます。

--- a/web/front.php
+++ b/web/front.php
@@ -1,6 +1,6 @@
 <?php

-require_once __DIR__.'/vendor/autoload.php';
+require_once __DIR__.'/../vendor/autoload.php';

 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -9,8 +9,8 @@ $request = Request::createFromGlobals();
 $response = new Response();

 $map = array(
-    '/hello' => __DIR__.'/hello.php',
-    '/bye'   => __DIR__.'/bye.php',
+    '/hello' => __DIR__.'/../src/pages/hello.php',
+    '/bye'   => __DIR__.'/../src/pages/bye.php',
 );

 $path = $request->getPathInfo();

これで、PHPのビルトインWebサーバを以下のように起動すると、

$ php -S 127.0.0.1:4321 -t web/ web/front.php

http://127.0.0.1:4321/?name=Fabienのようにフロントコントローラのファイル名を消したURLでアクセスできるようになりました。

私たちのフレームワークの最初のバージョン

現在、ページのソースがPHPファイルですが、これをテンプレートに変更しましょう。

--- a/web/front.php
+++ b/web/front.php
@@ -15,7 +15,9 @@ $map = array(

 $path = $request->getPathInfo();
 if (isset($map[$path])) {
-    require $map[$path];
+    ob_start();
+    include $map[$path];
+    $response->setContent(ob_get_clean());
 } else {
     $response->setStatusCode(404);
     $response->setContent('Not Found');

これでページのソースは以下のようになります。

src/pages/hello.php

<?php $name = $request->get('name', 'World') ?>

Hello <?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>

src/pages/bye.php

Goodbye!

新たにページを増やす場合は、

  1. front.php$mapに登録
  2. src/pagesフォルダにテンプレートを追加

となり、テンプレート内では$request$responseが使えます。

これが、私たちのフレームワークの最初のバージョンです。

Create your PHP Frameworkをやってみる④へ続く。

Tags: php, symfony

Create your PHP Frameworkをやってみる②

Create your PHP Frameworkをやってみる①の続きです。

この記事は、version 2.7に基づいています。

The HttpFoundation Component (current)

昨日の「最もシンプルなWebアプリ」を改良していきます。

最もシンプルなWebアプリの改良

クエリ文字列にnameがない場合に対応し、Content-Typeヘッダを出力し、XSS脆弱性をなくします。

--- a/index.php
+++ b/index.php
@@ -1,5 +1,7 @@
 <?php

-$input = $_GET['name'];
+$input = isset($_GET['name']) ? $_GET['name'] : 'World';

-printf('Hello %s', $input);
+header('Content-Type: text/html; charset=utf-8');
+
+printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'));

これで少し改良されました。

PHPUnitでのテストを作成します。

test.php

<?php

class IndexTest extends \PHPUnit_Framework_TestCase
{
    public function testHello()
    {
        $_GET['name'] = 'Fabien';

        ob_start();
        include 'index.php';
        $content = ob_get_clean();

        $this->assertEquals('Hello Fabien', $content);
    }
}

ちなみにテストを実行したらエラーでした。

$ phpunit test.php 
PHPUnit 4.6.10 by Sebastian Bergmann and contributors.

E

Time: 425 ms, Memory: 2.25Mb

There was 1 error:

1) IndexTest::testHello
Cannot modify header information - headers already sent by (output started at /Users/kenji/.composer/vendor/phpunit/phpunit/src/Util/Printer.php:139)

/Users/kenji/work/framework/index.php:5
/Users/kenji/work/framework/test.php:10

FAILURES!
Tests: 1, Assertions: 0, Errors: 1.

どうしてもテストを通しておきたい人は、以下のように@runInSeparateProcessを指定してください。

--- a/test.php
+++ b/test.php
@@ -2,6 +2,9 @@

 class IndexTest extends \PHPUnit_Framework_TestCase
 {
+    /**
+     * @runInSeparateProcess
+     */
     public function testHello()
     {
         $_GET['name'] = 'Fabien';

HttpFoundationコンポーネントの導入

さて、ここからコンポーネントを使っていきます。

まず、Symfonyコンポーネントの1つであるHttpFoundationを導入します。

$ composer require symfony/http-foundation
Using version ^2.7 for symfony/http-foundation
./composer.json has been created
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing symfony/http-foundation (v2.7.2)
    Loading from cache

Writing lock file
Generating autoload files

v2.7.2がインストールされました。

index.phpをHttpFoundationのRequestクラスとResponseクラスを使うように書き換えます。

--- a/index.php
+++ b/index.php
@@ -1,7 +1,14 @@
 <?php

-$input = isset($_GET['name']) ? $_GET['name'] : 'World';
+require_once __DIR__.'/vendor/autoload.php';

-header('Content-Type: text/html; charset=utf-8');
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;

-printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'));
+$request = Request::createFromGlobals();
+
+$input = $request->get('name', 'World');
+
+$response = new Response(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
+
+$response->send();

index.php全体は以下のようになります。

index.php

<?php

require_once __DIR__.'/vendor/autoload.php';

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

$request = Request::createFromGlobals();

$input = $request->get('name', 'World');

$response = new Response(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));

$response->send();

ちなみに、Requestクラスは以下のように使えます。

// the URI being requested (e.g. /about) minus any query parameters
$request->getPathInfo();

// retrieve GET and POST variables respectively
$request->query->get('foo');
$request->request->get('bar', 'default value if bar does not exist');

// retrieve SERVER variables
$request->server->get('HTTP_HOST');

// retrieves an instance of UploadedFile identified by foo
$request->files->get('foo');

// retrieve a COOKIE value
$request->cookies->get('PHPSESSID');

// retrieve an HTTP request header, with normalized, lowercase keys
$request->headers->get('host');
$request->headers->get('content_type');

$request->getMethod();    // GET, POST, PUT, DELETE, HEAD
$request->getLanguages(); // an array of languages the client accepts

Responseクラスは以下のように使えます。

$response = new Response();

$response->setContent('Hello world!');
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/html');

// configure the HTTP cache headers
$response->setMaxAge(10);

Create your PHP Frameworkをやってみる③へ続く。

Tags: php, symfony