PHP構築メモ: ライブラリのデモ用PHPアプリ

最近どんどんPHPの書きかたがわからなくなっていくので、自分の設計したコードをメモしていく。

フレームワークを使ったPHPアプリケーションの構成技法については比較的に知識が共有されやすいが、フレームワークに依存しないPHPの構成についてはまとまった資料がないように感じられるためユースケースを共有する。

## いままで書いた記事

* 「[インスパイヤされて掲示板を作りたくなった(1)](http://qiita.com/tadsan/items/cdbbb5b08591af2b110d)」 シリーズ
* 「レガシーなプロダクトの改善 フレームワークを利用できない環境でのライブラリ活用」 ([WEB+DB PRESS Vol.96](http://gihyo.jp/magazine/wdpress/archive/2017/vol96)所収)

## 諸元

|  |  |
|----|------|
| これは何？ | [Mastodon API Client/SDK for PHP](https://github.com/BaguettePHP/mastodon-api)の動作検証(と、概念実証)をするための簡単なPHPアプリケーション。 |
| 想定利用者 | PHP開発者 (Mastodon API/SDK利用者) |
| PHPバージョン | PHP 5.5+, 7+, HHVM |
| プラットフォーム | 特に規定しない (UNIX系OS+[ビルトインウェブサーバー](http://php.net/manual/ja/features.commandline.webserver.php))を想定 |

## 構成

ソースコードのリポジトリがSDKライブラリと同じものを共有するので、[`sample/`以下](https://github.com/BaguettePHP/mastodon-api/tree/development/sample)に配置。ただしライブラリの利用者がプロダクションにインストールするためには不要なファイルなので[`.gitattribute`に設定](https://github.com/BaguettePHP/mastodon-api/blob/development/.gitattributes)することで、パッケージには含まれないようにした。`composer.json`についてはSDK本体と分割することも可能だったが、開発用アプリケーションなので構成の簡便のために`require-dev`に含めた。

### ディレクトリ

```
sample/
├── README.md
├── cache
│   ├── pawoo.net.json
│   ├── qiitadon.com.json
│   └── session
│       └── sess_c599lo6g9jpnl2p6rkj4ckirv9
├── inc
│   ├── app.php
│   ├── bootstrap.php
│   ├── functions.php
│   ├── routes.php
│   └── variables.php
├── public
│   ├── favicon.ico
│   ├── index.php
│   └── robots.txt
└── view
    ├── 404.tpl.php
    ├── _login.tpl.php
    ├── acct.tpl.php
    ├── body.tpl.php
    └── index.tpl.php

5 directories, 18 files
```

### `inc/`

ここには5つのファイルがある。

| ファイル名 | 概要 |
|---------|------|
| [`functions.php`](https://github.com/BaguettePHP/mastodon-api/blob/development/sample/inc/functions.php) | Webサービスとしての汎用的なユーティリティ函数を定義 |
| [`apps.php`](https://github.com/BaguettePHP/mastodon-api/blob/development/sample/inc/app.php) | アプリケーションのための関数 |
| [`routes.php`](https://github.com/BaguettePHP/mastodon-api/blob/development/sample/inc/routes.php) | ルーティングごとの処理を*クロージャで*定義 |
| [`variables.php`](https://github.com/BaguettePHP/mastodon-api/blob/development/sample/inc/variables.php) | データをつっこむ謎コンテナ(謎) |
| [`bootstrap.php`](https://github.com/BaguettePHP/mastodon-api/blob/development/sample/inc/bootstrap.php) | 上記のファイルとかComposerのオートローダーをまとめて読み込んで、実行の前処理をする |

`functions.php`と`apps.php`の区別はいくらか曖昧に感じられるが、アプリケーションのドメインに関るもの*(ここでは、Mastodonのための処理)*を`app.php`に、それ以外のWebアプリケーションのための汎用処理(別の目的のアプリケーションに移植しても利用できる処理)に関しては`functions.php`に記述するものとして峻別した。

`routes.php`はルーティング定義を記述する。詳細は[シンプルなルーティングがしたかった](http://qiita.com/tadsan/items/bcaa14504d0ecdd9e096)を読んでほしい。`routes.php`を分割するアイディアは[Slimから拝借した](https://github.com/slimphp/Slim-Skeleton/blob/master/src/routes.php)。

`variables.php`はグローバル変数定義… などではなく、変数をまとめてぶちこんでおく`variable`クラスを定義する。[PSR-1 基本コーディング規約(日本語)](http://www.infiniteloop.co.jp/docs/psr/psr-1-basic-coding-standard.html)に則るならばクラス名はCamelCaseで書くべきなのだけれども、[ここはオレオレの世界なので](https://speakerdeck.com/uzulla/phperren-sheng-du-hahuremuwakuwozuo-tuteokou)完全に無視した。

せっかくなので名前空間についても言及しておくと、`app.php`で定義される関数は`app\`名前空間に属し、それ以外は基本的にトップレベル名前空間を利用した。

### `view/`

HTMLテンプレートにはPHPをそのまま利用した。スクリプト処理を記述する(HTMLを含まない)PHPファイルと区別するため、拡張子は`.tpl.php`とした。

PHPなどの継承概念を持たないテンプレートエンジンでページテンプレートを共通化するのは、以下のようなパターンがあるだろう。

 - **A**. 全ページのテンプレートからヘッダとフッタを`include`する
 - **B**. 共通ページテンプレートから固有のテンプレートを`include`する

これをコードにすると以下のようになる。

```html+php:A-page.tpl.php
<?php include isset($header_tpl) ? $header_tpl :  __DIR__ . '/header.php' ?>
<main>
    <!-- それぞれのページのメインコンテンツ -->
</main>
<?php include isset($header_tpl) ? $header_tpl :  __DIR__ . '/header.php' ?>
```

```html+php:B-body.tpl.php
<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <title><?php h(isset($title) ? $title : 'デフォルト') ?></title>
</head>
<body>
    <!-- 共通ヘッダ -->
<main>
    <!-- 固有のメインコンテンツのテンプレートを読み込む -->
    <?php isset($main_tpl) && include $main_tpl; ?>
</main>
<footer>
    <!-- 共通フッタ -->
</footer>
</html>
```

この**A**と**B**のパターンは「それぞれのテンプレートから共通部分を読み込む」「共通テンプレートから固有部分を読み込む」と、`include`の方向が反対であることを、おわかりいただけるだろうか。

特に大きな理由もあるかと言ったらないのだけれど、今回は**B**を採用することにした。サンプル用の小さなWebアプリケーションなので、例外的なヘッダやフッタのページもないため、こちらの方が利用しやすかった。

いくつかのページから読み込まれる可能性がある部分テンプレートは`_hoge.tpl.php`のように先頭に`_`を付けることで差別化した。

### 固有ページテンプレート

```html+php
<?php
/**
 * Template for index
 *
 * @author    USAMI Kenta <tadsan@zonu.me>
 * @copyright 2017 Baguette HQ
 * @license   https://www.gnu.org/licenses/gpl-3.0.html GPL-3.0
 */
/** @var $var variables */
?>
<h1>Mastodon Sample App</h1>

<ul>
    <?php foreach ($_SESSION['mastodons'] as $acct => $mastodon): ?>
        <li>
            <a href="<?= h(router()->makePath('acct', ['acct' => $acct])) ?>">
                <?= h($acct) ?>
            </a>
        </li>
    <?php endforeach; ?>
</ul>

<?php include __DIR__ . '/_login.tpl.php'; ?>
```

### 初期化

[`mastodon-api/bootstrap.php`](https://github.com/BaguettePHP/mastodon-api/blob/master/sample/inc/bootstrap.php)にある。

```bootstrap.php
call_user_func(function() {
    // 新規開発プロジェクトなのでエラーレベルは最初から最高にする
    error_reporting(-1);

    // 開発環境ではWhoopsを有効化
    if (!is_production()) {
        $whoops = new \Whoops\Run;
        $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler);
        $whoops->register();
    }

    // 設定情報を読み出す
    $dotenv = new Dotenv\Dotenv(dirname(__DIR__));
    $dotenv->load();
    $dotenv->required('MY_PHP_ENV');
    $dotenv->required('SERVICE_BASE_URL');

    // セッション設定
    session_save_path(realpath(__DIR__ . '/../cache/session/'));
    session_start();

    // セッション値の地ならし
    app\gc_session();
});
```

[filp/whoops](https://github.com/filp/whoops)を有効化してる程度で、特別なことはしてない。今回は雑に開発環境でのみ有効化することにしたが、本番環境でも利用する方法があるが、ここでは割愛する。 ~~[WEB+DB PRESS Vol.96](http://gihyo.jp/magazine/wdpress/archive/2017/vol96)には書いたし。~~

### エントリーポイント

[`mastodon-api/index.php`](https://github.com/BaguettePHP/mastodon-api/blob/master/sample/public/index.php)です。せっかくなので、略さずに書く。

```php
<?php

/**
 * Mastodon SampleApp core application file
 *
 * @author    USAMI Kenta <tadsan@zonu.me>
 * @copyright 2017 Baguette HQ
 * @license   https://www.gnu.org/licenses/gpl-3.0.html GPL-3.0
 */

require __DIR__ . '/../inc/bootstrap.php';
// ↑ __DIR__ をくっつけて、絶対ディレクトリがずれないようにします

// ここからはビルトインサーバー用
// http://php.net/manual/ja/features.commandline.webserver.php
if (php_sapi_name() === 'cli-server') {
    // ディレクトリトラバーサルの余地がないよう(念には念を入れて) .. が入ってたら糸冬了
    if (strpos($_SERVER['REQUEST_URI'], '..') !== false) {
        http_response_code(404);
        return true;
    }

    // ファイルが実在するなら、後のことはビルトインサーバーに任せた
    $path = __DIR__ . implode(DIRECTORY_SEPARATOR, explode('/', $_SERVER['REQUEST_URI']));
    if (is_file($path)) {
        return false;
    }
}

// ルーティング定義を読み込む
$routes = require(__DIR__ . '/../inc/routes.php');

router($router = new \Teto\Routing\Router($routes));

$action = $router->match($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

/**
 * @var int   $status  HTTPステータス
 * @var array $headers HTTPヘッダの連想配列
 * @var string|false $content
 */
list($status, $headers, $content) = call_user_func($action->value, $action);

http_response_code($status);
foreach ($headers as $name => $header) {
    // ヘッダーインジェクション対策はPHP側でやってくれるから、ここでは何もしないよ
    // http://php.net/manual/ja/function.header.php
    header("{$name}:{$header}");
}

if ($content !== null) {
    echo (string)$content;
}
```

#### ルーティング

`routing.php`は以下のようなファイルである。繰り返しになるが、詳細は[シンプルなルーティングがしたかった](http://qiita.com/tadsan/items/bcaa14504d0ecdd9e096)を読んでほしい。

```php
$routes = [];

// トップページ "/"
$routes['index'] = ['GET', '/', function (Action $action) {
    // Chrome Loggerで確認できてべんり
    // https://craig.is/writing/chrome-logger
    chrome_log()->info('Hello, World!');
    chrome_log()->info('session', $_SESSION);

    // 200 OK で index.tpl.php を展開して返すよ、の意味
    return [200, [], view('index')];
}];

// "/acct/tadsan@pawoo.net" とか "/acct/zo@friends.nico" みたいな
$routes['acct'] = ['GET', '/acct/:acct', function (Action $action) {
    chrome_log()->info('session', $_SESSION);

    $acct_input = $action->param['acct'];

    if (!isset($_SESSION['mastodons'][$acct_input])) {
        set_flash(['error' => "Not logged in: {$acct_input}"]);
        return [302, ['Location' => '/'], null];
    };

    return [200, [], view('acct', [
        'acct' => $acct_input,
    ])];
}, ['acct' => RE_ACCT]];

// 静的ファイルを返す
$routes['license'] = ['GET', '/license', function (Action $action) {
    $path = __DIR__ . '/../../LICENSE';
    // 200 OK でプレーンテキストファイルとして
    return [200, ['Content-Type' => 'text/plain;charset=UTF-8'], file_get_contents($path)];
}];

// ...

// どれにも該当しなかったときにだけ呼ばれる特別なアクション
$routes['#404'] = function (Action $action) {
    // 404 Not Found で 404.tpl.php を展開して返すよ、の意味
    return [404, [], view('404')];
};

return $routes;
```

この書き方をすることで、`hogehoge.php`のような格好悪いURLから卒業することができるスグレモノ。

#### テンプレート、再び

段取りが悪いな? さきほどは抽象的な話をしたが、こんどはもうすこし具体的に書く。

```html+php:index.tpl.html
<?php
/**
 * Template for index
 *
 * @author    USAMI Kenta <tadsan@zonu.me>
 * @copyright 2017 Baguette HQ
 * @license   https://www.gnu.org/licenses/gpl-3.0.html GPL-3.0
 */
/** @var $var variables */
?>
<h1>Mastodon Sample App</h1>

<ul>
    <?php foreach ($_SESSION['mastodons'] as $acct => $mastodon): ?>
        <li>
            <a href="<?= h(router()->makePath('acct', ['acct' => $acct])) ?>">
                <?= h($acct) ?>
            </a>
        </li>
    <?php endforeach; ?>
</ul>

<?php include __DIR__ . '/_login.tpl.php'; ?>
```

`h(router()->makePath('acct', ['acct' => $acct]))`これは何を意味するか。これは`$acct = "tadsan@pawoo.com"`であれば、`/acct/tadsan@pawoo.net`に展開される。これはさきほどルーティング定義した`'/acct/:acct'`から、逆に生成したものだ。

これはルーティング*(アクセスされたURLをパラメータに分解して該当する処理を探す)*の逆なので、**リバースルーティング**(処理の名前とパラメータをもとにアクセスさせるURLを組み立てる)とか呼ばれる。

当然だが、URLの構成情報は一度定義したのでリバースルーティングのための情報を再定義する必要はない。ルーティングの情報で十分だ。

## あとがき

今回は最小のオレオレフレームワークを作るつもりで簡単なPHPアプリを構築した(完成してないけど)。コード自体は短いが、自分の書いたコードよりも運用実績のあるフレームワークの方がいいって意見があるだろう。うんうん、完全に同感。

今回の記事には、これ見よがしなつっこみどころがあるので、それが見破れない人は既存のWebフレームワークを使った方がいいです、ってことになります。[Slim Framework](https://www.slimframework.com/)とか、僕も好きだよ。

今回の記事のような内容について世の中のPHPを書く人たちがどのような感想を抱くのか気になるので、Twitterでもコメントでもどこでもいいから書いてくれたのむ。