フラットなPHPからSlimへ

フラットなPHPからSilexへの姉妹版記事です。

追記

  • configにモデルを突っ込むコードからcontainerプロパティを作り、配列としてクロージャを登録する方式に変更
  • $app全体を持ち回す必要がないところは必要な情報のみ渡すように修正

追記 2014/08/13

前提

前回の記事Symfony Componentを使い始める前までは同じです。
まずは、前回の記事で、素のPHPでブログアプリのコードを書いてみるところまで実践してみてください。

Slimを使ってみる

Slimのインストール

前回は Symfony Component (HttpFoundation) や Pimple を使いつつ Silexへ移行していきましたが、SlimはSilexのような外部ライブラリを使わず、Slimが用意したライブラリを使って書くことになります。

というわけで、Slimをインストールをして続きを書き換えていきます。
SlimのインストールはComposerでできます。

まずはComposerをコマンドラインからインストールします。

$ curl -s https://getcomposer.org/installer | php
// もし curlがインストールされていない場合は以下でもOK
$ php -r "eval('?>'.file_get_contents('https://getcomposer.org/installer'));"

これで、composer.pharというファイルがダウンロードされます。次に、Slimをインストールするためのcomposer.jsonファイルを用意し以下のように書いておきます。

{
    "require": {
        "slim/slim": "1.6.4"
    }
}

あとは、コマンドラインでinstallを叩くだけです。

$ php composer.phar install

Installing dependencies
  - Installing slim/slim (1.6.4)

Writing lock file
Generating autoload files

これで、vendorディレクトリが作成され、そこにSlimのファイルとautoload.phpが用意されます。
もし、追加で必要なコンポーネントがあれば composer.jsonに追加してインストールが行えます。

Slimのコントローラに書き換える

ここまでの、Webアプリケーション開発を通してどのようにコードを分離してきたかを整理してみましょう。

アプリケーションへのアクセスとは"どのURIに、どのリクエストメソッドで、どのパラメータをもってアクセスされるか"ということです。
つまり、ルーティングによってどの処理を行うかが決定されるということだけなのです。
この部分に注目したのが、マイクロフレームワークです。マイクロフレームワークではルーティングごとに処理を定義するだけです。
では、これまで書いてきたコードをSlimで書き換えてみます。

Slimのドキュメントは英語しかありませんが、コードと共に紹介されているので、それほど難しくはありません。
参照: Slimのドキュメント

<?php
require_once 'vendor/autoload.php';

$app = new Slim();

require_once 'controllers.php';
require_once 'model.php';

// リクエストを内部的にルーティング
$app->get('/', function () {
    list_action();
});

$app->get('/show', function () use ($app) {
    $id = $app->request()->get('id');
    show_action($id);
});

$app->notFound(function () use ($app) {
    echo '<html><body><h1>ページが見つかりません</h1></body></html>';
});

$app->run();

これまでif文で書いていたURIの条件が$pp->getメソッドで記述できるようになっています。比べると分かりやすいことがわかります。
$_GETへのアクセスは Slim では $app->request()->get('name') が用意されています。SilexではRequest, ResponseはSymfony ComponentのHttpFoundationコンポーネントを利用していた部分です。
そして、ルーティングに一致しないアクセスの場合はフレームワーク側で自動的にnotFoundメソッドが呼び出されます。このあたりの本来のロジックとは関係が薄い典型的な処理がゼロから書かなくても用意されているのがフレームワークを使うメリットです。

また、getメソッドの場合は$app->request()でパラメータの値を取得することもできますが、URIのパスにIDを含めておきURIから取得することもできます。

// (例) /show/2 => $id = 2 として処理する
$app->get('/show/:id', function ($id) use ($app) {
    show_action($id);
});

データベースの設定を外に出す

Silexの説明ではサービスコンテナを使うパターンでしたが、Slimの場合は$app->configメソッドを通して設定などのデータを共有することができます。セットするときは $app->config(array('key', 'value')) で、ゲットするときは $app->config('key') になります。

まず、フロントコントローラ(index.php)にデータベースの設定を記述しコントローラでcontrollers.phpやmodel.phpに引数で渡すようにします。

<?php
require 'bootstrap.php';

$app = new Slim();

$app->config('db.config', array(
               'host' => 'localhost',
               'database' => 'blog_db',
               'user' => 'myuser',
               'password' => 'mypassword',
               ));

require_once 'controllers.php';
require_once 'model.php';

// リクエストを内部的にルーティング
$app->get('/', function () use($app) {
    list_action($app->config('db.config'));
});

$app->get('/show/:id', function ($id) use ($app) {
    show_action($id, $app->config('db.config'));
});

$app->notFound(function () use ($app) {
    echo '<html><body><h1>ページが見つかりません</h1></body></html>';
});

$app->run();

これにあわせて、controllers.php, model.phpを書き換えます。

<?php
// controllers.php
function list_action($db_config)
{
    $posts = get_all_posts($db_config);
    require 'templates/list.php';
}

function show_action($id, $db_config)
{
    $post = get_post_by_id($id, $db_config);
    require 'templates/show.php';
}
<?php

// model.php
function get_database_connection($config)
{
    $pdo = new PDO(
      sprintf('mysql:host=%s;dbname=%s;charset=utf8', $config['host'], $config['database']),
      $config['user'],
      $config['password'],
      array(PDO::ATTR_EMULATE_PREPARES => false)
    );
    return $pdo;
}

function close_database_connection(&$pdo)
{
    $pdo = null;
}

function get_all_posts($db_config)
{
    $pdo = get_database_connection($db_config);

    $stmt = $pdo->query('SELECT id, title FROM post');
    $posts = array();
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $posts[] = $row;
    }

    close_database_connection($pdo);

    return $posts;
}

function get_post_by_id($id, $db_config)
{
    $pdo = get_database_connection($db_config);

    $sth = $pdo->prepare('SELECT id, date, title, body FROM post where id = :id');
    $sth->bindValue(':id', $id, PDO::PARAM_INT);
    $sth->execute();
    $post = $sth->fetch(PDO::FETCH_ASSOC);

    close_database_connection($pdo);

    return $post;
}

次に、フロントコントローラーに記述しているロジックをcontrollers.phpに移してしまいましょう。show_actionやlist_actionというグローバル関数を使わずにSlimのルーティングコントローラーで記述していきます。

// index.php
<?php
// グローバルライブラリの読み込みと初期化
require 'bootstrap.php';

$app = new Slim();

$app->config('db.config', array(
               'host' => 'localhost',
               'database' => 'blog_db',
               'user' => 'myuser',
               'password' => 'mypassword',
               ));

require_once 'controllers.php';
require_once 'model.php';

$app->run();
<?php
// controllers.php
$app->get('/', function () use($app) {
    $posts = get_all_posts($app->config('db.config'));
    $app->render('list.php', array('posts' => $posts));
});

$app->get('/show/:id', function ($id) use ($app) {
    $post = get_post_by_id($id, $app->config('db.config'));
    if (!$post) {
        // 該当する記事がないので、このルーティングにマッチしなかったとして
        // 次のマッチするルーティングに処理を委譲するpassメソッドをコールする
        // => つまり、どのルーティングにもマッチしないのでnotFoundが実行される
        $app->pass();
    }
    $app->render('show.php', array('post' => $post));
});

$app->notFound(function () use ($app) {
    echo '<html><body><h1>ページが見つかりません</h1></body></html>';
});

フロントコントローラがすっきりしました。またコントローラからテンプレートの描画処理もSlimが用意していうるテンプレート機能を用いるように書き換えたため、読みやすくなりましたね。 (Slimでは標準でrenderに渡したファイル名はtemplatesディレクトリ以下から探します)

また、Slimのpassメソッドを利用して、詳細表示時に指定したIDが存在しなかった場合はnotFoundメソッドが処理されるようにしています。

コンテナを用意する

これまでモデルは関数の集まりでした。今後も増えていく予定だとしてクラスとしてまとめてみます。
そのため、Postモデルクラスを作成してPostオブジェクトをconfig経由で利用コンテナ経由で利用するようにしてみましょう。
SilexではPimpleというDIコンテナを使いました。同様にcomposer.jsonにpimpleを追加しインストールして利用してもよいですが、外部ライブラリに依存していないSlimらしさを活かすためにSlimクラスにcontainerプロパティを作るシンプルなパターンで書いてみましょう。
*1

まずは、Sliemのオブジェクト自身にcontainerプロパティを作成して空配列で初期化しておきます。

<?php
...
$app = new Slim();
$app->container = array(); // <= コンテナとして使う配列プロパティ
...

このコンテナにたとえばUserオブジェクトを作成するための処理を次のように用意します

<?php

$app->container['model.user'] = function() use($app) {
    return new User($app->config('logger'));
};

Pimpleと同じで、関数を定義しているだけなので、この時点ではクロージャ実行されません。
遅延評価で必要なときにUserオブジェクトを作成することができるのです。
また、$app を use を使って渡しているので呼び出すときは意識する必要がありません。

このサンプルのUserオブジェクトを作成したいときは以下のように使います。

<?php
$app->get('/', function () use($app) {
    $model_user = $app->container['model.user'](); // <= コンテナからクロージャを取得し実行
    $users = $model_user->get_all();
    ....
});

この例では引数が無いですが、もし引数を渡したい場合はクロージャーに引数を定義すれば良いだけです。

モデルを関数からクラスに

Postクラス

とりあえず、これまでmodel.phpで記述した関数をPostクラスとして書きなおしてみます。

<?php
// model.php
class Post
{
  public $db_config;
  public $pdo = null;

  public function __construct($db_config)
  {
    $this->db_config = $db_config;
  }

  public function open_database_connection()
  {
    if ($this->pdo === null ) {
      $this->pdo = new PDO(
        sprintf('mysql:host=%s;dbname=%s;charset=utf8', $this->db_config['host'], $this->db_config['database']),
        $this->db_config['user'],
        $this->db_config['password'],
        array(PDO::ATTR_EMULATE_PREPARES => false)
      );
    }
  }

  function close_database_connection()
  {
      $this->pdo = null;
  }

  public function get_all_posts()
  {
    $this->open_database_connection();
    $stmt = $this->pdo->query('SELECT id, title FROM post');
    $posts = array();
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $posts[] = $row;
    }
    return $posts;
  }
  function get_post_by_id($id)
  {
    $this->open_database_connection();
    $sth = $this->pdo->prepare('SELECT id, date, title, body FROM post where id = :id');
    $sth->bindValue(':id', $id, PDO::PARAM_INT);
    $sth->execute();
    $post = $sth->fetch(PDO::FETCH_ASSOC);

    return $post;
  }
}

// containerにmodel.postというkeyで登録
$app->container['model.post'] = function() use($app) {
    return new Post($app->config('db.config'));
};

Postクラスは、データベース(PDO)を扱いつつ、postデータを処理しています。
データベースコネクションを行うために$app->configメソッドを使うので$appをコンストラクタで渡すようにしました。
また、データベースのコネクションはクラスの中でしか利用しないのでクラス変数として扱うようにし、それに伴いメソッド名をget_database_connectionからopen_database_connectionに変更しました。

これをSlimのコントローラで利用したいので、コンテナに'model.post'という名前で登録しています。
こうすることで、コントローラは次のように$app->contaier['model.post'] を通してクロージャを実行することでPostオブジェクトを作成することができます。

<?php
// controllers.php
$app->get('/', function () use($app) {
    $post_model = $app->container['model.post'](); // <= Postモデルオブジェクトを生成
    $posts = $post_model->get_all_posts();
    $app->render('list.php', array('posts' => $posts));
});

$app->get('/show/:id', function ($id) use ($app) {
    $post_model = $app->container['model.post']();  // <= Postモデルオブジェクトを生成
    $post = $post_model->get_post_by_id($id);
    $app->render('show.php', array('post' => $post));
});

ここで、Postクラスで気になることがあるのでちょっとリファクタリングしてみます。
たとえば、Userテーブルが新しく追加され、Userモデルが追加されたとします。その場合に今のままだとUserモデルにもstart_database_connectionメソッドが用意しなければならないことになります。
つまり、PostクラスはDatabaseを使いたいだけでPostクラスそのものがDatabseの情報を把握する必要はないということです。
PHP5.4からは trait が利用できるようになったので、Postクラスと Databaseトレイトに分けてみましょう。
*2

<?php
// model.php

trait Database
{
  public $pdo = null;

  public function open_database_connection($config)
  {
    if ($this->pdo === null ) {
      $this->pdo = new PDO(
        sprintf('mysql:host=%s;dbname=%s', $config['host'], $config['database']),
        $config['user'],
        $config['password'],
        array(PDO::ATTR_EMULATE_PREPARES => false)
      );
    }
  }
}


class Post
{
  use Database;
  public $db_config;

  public function __construct($db_config)
  {
    $this->db_config = $db_config;
  }

  public function get_all_posts()
  {
    $this->open_database_connection($this->db_config);
    $stmt = $this->pdo->query('SELECT id, title FROM post');
    $posts = array();
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $posts[] = $row;
    }
    return $posts;
  }
  function get_post_by_id($id)
  {
    $this->open_database_connection($this->db_config);
    $sth = $this->pdo->prepare('SELECT id, date, title, body FROM post where id = :id');
    $sth->execute(array(':id' => $id));
    $post = $sth->fetch(PDO::FETCH_ASSOC);

    return $post;
  }
}

// containerにmodel.postというkeyで登録
$app->container['model.post'] = function() use($app) {
    return new Post($app->config('db.config'));
};

これで、Userクラスを追加するとしても use Database; をするだけでPDOのためのコードを再利用できるようになりました。

configにセットしていくスタイルはシンプルですが、今回の例だとconfigにセットする時点でクラスをnewしています。つまり、configに沢山のオブジェクトをセットするとそれだけconfigが膨れていきます。そのため、configを使わずにファクトリメソッドを用意してコントローラーでPostクラスを作成するというアプローチもあるとおもいます。 コンテナ経由でクロージャを利用する方法に変更済み。

Silexの場合は無名関数を利用した遅延評価になっているため、実際に呼び出されるまで実態が作成されません。大量に登録しても実際に利用されるオブジェクトだけ展開されるというメリットがあります。
ただ、そのような大量なオブジェクトを扱う必要があるようなアプリケーションになると、Silexそのもので開発するのも大変になると思います。

このあたりは"良い感じ"にフレームワークの制約を活用しつつ柔軟に書くことが大事だと思います。

最後に

フラットなPHPからSlimに変化していく様子を見てきました。なぜフレームワークが便利なのかというのが見えてきたのではないでしょうか?とはいえ、フレームワーク銀の弾丸ではありません。このようにルーティングの処理は任せて、本来コーディングしたい部分に集中できるように助けてくれます。

今回はSlimで説明してきましたが、Slimには他にも良い感じの機能を提供してくれています。今回の記事でいう$app->pass() のような便利な機能は他にもあります。まずはSlimのドキュメントでさらに理解を深めてください。

2日分書き終えて

Silexだと無名関数をうまく利用しているコードが面白いですし、SlimはDBアクセス部分の標準での機能が無いなどSilexのように多機能でない分、設計力が試されるような気がします。マイクロフレームワークはとても楽しくて便利なのですが、フラットなPHPに近いので、ある程度の複雑なアプリケーション開発になってくると難しいんじゃないかとも思いました。

*1:configにモデルをセットして使いまわすというのは決して綺麗な実装ではないと思ってます。それってconfigちゃいますし。

*2:PHP5.3まではtraitを使うことができないので、ここで説明しているコードは動きません。