最近どんどんPHPの書きかたがわからなくなっていくので、自分の設計したコードをメモしていく。
フレームワークを使ったPHPアプリケーションの構成技法については比較的に知識が共有されやすいが、フレームワークに依存しないPHPの構成についてはまとまった資料がないように感じられるためユースケースを共有する。
いままで書いた記事
- 「インスパイヤされて掲示板を作りたくなった(1)」 シリーズ
- 「レガシーなプロダクトの改善 フレームワークを利用できない環境でのライブラリ活用」 (WEB+DB PRESS Vol.96所収)
諸元
これは何? | Mastodon API Client/SDK for PHPの動作検証(と、概念実証)をするための簡単なPHPアプリケーション。 |
想定利用者 | PHP開発者 (Mastodon API/SDK利用者) |
PHPバージョン | PHP 5.5+, 7+, HHVM |
プラットフォーム | 特に規定しない (UNIX系OS+ビルトインウェブサーバー)を想定 |
構成
ソースコードのリポジトリがSDKライブラリと同じものを共有するので、sample/
以下に配置。ただしライブラリの利用者がプロダクションにインストールするためには不要なファイルなので.gitattribute
に設定することで、パッケージには含まれないようにした。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 |
Webサービスとしての汎用的なユーティリティ函数を定義 |
apps.php |
アプリケーションのための関数 |
routes.php |
ルーティングごとの処理をクロージャで定義 |
variables.php |
データをつっこむ謎コンテナ(謎) |
bootstrap.php |
上記のファイルとかComposerのオートローダーをまとめて読み込んで、実行の前処理をする |
functions.php
とapps.php
の区別はいくらか曖昧に感じられるが、アプリケーションのドメインに関るもの*(ここでは、Mastodonのための処理)*をapp.php
に、それ以外のWebアプリケーションのための汎用処理(別の目的のアプリケーションに移植しても利用できる処理)に関してはfunctions.php
に記述するものとして峻別した。
routes.php
はルーティング定義を記述する。詳細はシンプルなルーティングがしたかったを読んでほしい。routes.php
を分割するアイディアはSlimから拝借した。
variables.php
はグローバル変数定義… などではなく、変数をまとめてぶちこんでおくvariable
クラスを定義する。PSR-1 基本コーディング規約(日本語)に則るならばクラス名はCamelCaseで書くべきなのだけれども、ここはオレオレの世界なので完全に無視した。
せっかくなので名前空間についても言及しておくと、app.php
で定義される関数はapp\
名前空間に属し、それ以外は基本的にトップレベル名前空間を利用した。
view/
HTMLテンプレートにはPHPをそのまま利用した。スクリプト処理を記述する(HTMLを含まない)PHPファイルと区別するため、拡張子は.tpl.php
とした。
PHPなどの継承概念を持たないテンプレートエンジンでページテンプレートを共通化するのは、以下のようなパターンがあるだろう。
-
A. 全ページのテンプレートからヘッダとフッタを
include
する -
B. 共通ページテンプレートから固有のテンプレートを
include
する
これをコードにすると以下のようになる。
<?php include isset($header_tpl) ? $header_tpl : __DIR__ . '/header.php' ?>
<main>
<!-- それぞれのページのメインコンテンツ -->
</main>
<?php include isset($header_tpl) ? $header_tpl : __DIR__ . '/header.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
のように先頭に_
を付けることで差別化した。
固有ページテンプレート
<?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
にある。
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を有効化してる程度で、特別なことはしてない。今回は雑に開発環境でのみ有効化することにしたが、本番環境でも利用する方法があるが、ここでは割愛する。 WEB+DB PRESS Vol.96には書いたし。
エントリーポイント
mastodon-api/index.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
は以下のようなファイルである。繰り返しになるが、詳細はシンプルなルーティングがしたかったを読んでほしい。
$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から卒業することができるスグレモノ。
テンプレート、再び
段取りが悪いな? さきほどは抽象的な話をしたが、こんどはもうすこし具体的に書く。
<?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とか、僕も好きだよ。
今回の記事のような内容について世の中のPHPを書く人たちがどのような感想を抱くのか気になるので、Twitterでもコメントでもどこでもいいから書いてくれたのむ。