PHP + SQLite(その2)
さて、ようやく本題です。PHPとSQLiteを組み合わせて、簡単なWebアプリケーション(ブログ)を作ってみましょう。
記事を投稿する
まず、POSTリクエストを送信するフォームを含むHTMLを作成します。あとでPHPスクリプトを書き足すので、ファイル名はedit.phpのように拡張子をphpにしておいてください。
<!DOCTYPE html>
<meta charset="utf-8">
<form method="POST">
<p>
<input name="title" placeholder="Title" size="50" required>
<p>
<textarea name="body" placeholder="Body" cols="50" rows="20" required></textarea>
<p>
<button type="submit">Post</button>
</form>
PHPを先頭に書き足します。メッセージを表示するp要素も追加しました。
<?php
$method = $_SERVER['REQUEST_METHOD'];
$msg = "";
if ($method === 'POST') {
$title = $_POST['title'];
$body = $_POST['body'];
$db = new SQLite3('db.sqlite3');
$db->exec('CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)');
$stmt = $db->prepare('INSERT INTO entries VALUES(NULL, :title, :body)');
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':body', $body, SQLITE3_TEXT);
$stmt->execute();
$msg = "Success";
}
?>
<!DOCTYPE html>
<meta charset="utf-8">
<p>
<?= $msg ?>
<form method="POST">
<p>
<input name="title" placeholder="Title" size="50" required>
<p>
<textarea name="body" placeholder="Body" cols="50" rows="20" required></textarea>
<p>
<button type="submit">Post</button>
</form>
これで、非常に機能は少ないですがDBにフォームの内容を追加する投稿ページを作ることができました。簡単に説明を書いておきます。
$_SERVERにはサーバ情報・HTTP通信関連のデータ(一部)が入っています。今回はリクエストメソッドを取り出し、このページにGETリクエストが送られたときはフォームのHTMLを送信する動作だけをして、POSTリクエストが送られたときは送信されたデータをDBに書き込む処理をするように分岐するために使っています。
$_POSTにはPOSTリクエストにより送られたapplication/x-www-form-urlencodedまたはmultipart/form-data形式のデータが文字列キーと文字列値からなる連想配列(キーと値からなるマップオブジェクト)として格納されています。ただし、データの名前(name属性)がkey[]のような形式の場合、値には配列として複数値が格納されます(この場合でも取り出すときは$_POST['key'])。また、ファイルの場合は$_FILESに格納されます。
記事一覧
<!DOCTYPE html>
<meta charset="utf-8">
<h2>タイトル1</h2>
<div>
<p>本文1
</div>
<h2>タイトル2</h2>
<div>
<p>本文2
</div>
...
このようなHTMLからなる閲覧ページをPHPで作ってみましょう。
<?php
$db = new SQLite3('db.sqlite3');
$db->exec('CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)');
$result = $db->query('SELECT * FROM entries');
?>
<!DOCTYPE html>
<meta charset="utf-8">
<?php while ($row = $result->fetchArray()): ?>
<h2><?= htmlspecialchars($row['title']) ?></h2>
<div>
<p><?= nl2br(htmlspecialchars($row['body'])) ?>
</div>
<?php endwhile; ?>
DBからの結果を取得し、すべての結果についてループするのにwhile-endwhileを使っています。同様の記法はPythonのWebフレームワークであるDjangoやFlask(Jinja2)のテンプレートでも使います。ループの条件式がやや読みにくいですが、$result->fetchArray()は次の結果(行)がないときFALSEを返し、whileは$rowに格納されたFALSEを評価してループを終了します。ただし、このままでは大量の記事がある際にインデックスページ(一覧ページ)が長く/重くなってしまうため、大抵の場合ページネーションをつけたり、タイトルだけ、またはタイトルと本文の一部だけで表示したりしますが、ここでは省略します。
htmlspecialcharsはHTMLの特殊文字をエスケープする組み込み関数です。限られた人しか編集できないブログのような、編集者が信頼できる場合(それからセッションCookieの切り分けなどを行っている場合)は必要ないかもしれませんが、記事のtitle、bodyにスクリプトや外部への呼び出しを行うようなHTMLタグを埋め込まれる、XSS脆弱性を塞ぐためにこうしています。似たような組み込み関数にstrip_tagsがあり、こちらはHTMLタグ/PHPスクリプトを文字列から除去します(許可するタグを指定することもできる)。
nl2brは文字列中の改行を<br>タグに変換する組み込み関数です(改行文字はそのまま保持されます)。
pタグは段落なので、2個以上の改行はpタグでくくる、のような処理をしたいところですが、ここでは省略します(Markdownライブラリなどを使うといいのでは)。
また、複数の箇所にDBへのアクセスやテーブルの初期化を書いているのはナンセンスな気がしますので、あとで修正します。
ファイル分離
database.php
<?php
$db = new SQLite3('db.sqlite3');
$db->exec('CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)');
投稿ページ(edit.php)
<?php
require_once __DIR__ . '/database.php';
$method = $_SERVER['REQUEST_METHOD'];
$msg = "";
if ($method === 'POST') {
$title = $_POST['title'];
$body = $_POST['body'];
$stmt = $db->prepare('INSERT INTO entries VALUES(NULL, :title, :body)');
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':body', $body, SQLITE3_TEXT);
$stmt->execute();
$msg = "Success";
}
?>
<!DOCTYPE html>
<meta charset="utf-8">
<p>
<?= $msg ?>
<form method="POST">
<p>
<input name="title" placeholder="Title" size="50">
<p>
<textarea name="body" placeholder="Body" cols="50" rows="20"></textarea>
<p>
<button type="submit">Post</button>
</form>
閲覧ページ(index.php)
<?php
require_once __DIR__ . '/database.php';
$result = $db->query('SELECT * FROM entries');
?>
<!DOCTYPE html>
<meta charset="utf-8">
<?php while ($row = $result->fetchArray()): ?>
<h2><?= htmlspecialchars($row['title']) ?></h2>
<div>
<p><?= nl2br(htmlspecialchars($row['body'])) ?>
</div>
<?php endwhile; ?>
PHPには他の言語におけるimport/include文に相当するものとして、require、require_once、include、include_onceが用意されています。requireとincludeの違いはエラー発生時にインクルード元の処理を続行するかどうかで、requireは処理を停止するエラーを発行し、includeは処理を続行します(エラーの発生は戻り値で判別可)。onceは同じスクリプトを二重にインクルードしません。
テンプレート
インクルード元のスコープから参照できる変数/関数/クラスにはインクルードされたスクリプトからもそのまま参照できます。また、出力を含むPHPスクリプトをインクルードしたとき(HTML部分やprintを含むような)、出力はそのまま反映されます。つまり、ループの中などにinclude/requireを含むPHPスクリプトを作成すればテンプレートになります。閲覧ページにおける記事のインデックスをテンプレート化してみましょう。
記事インデックステンプレート(templates/entry_index.php)
<h2><?= $entry['title_safe'] ?></h2>
<div>
<p><?= nl2br($entry['body_safe']) ?>
</div>
閲覧ページ(index.php)
<?php
require_once __DIR__ . '/database.php';
$result = $db->query('SELECT * FROM entries');
?>
<!DOCTYPE html>
<meta charset="utf-8">
<?php
while ($entry = $result->fetchArray()) {
$entry['title_safe'] = htmlspecialchars($entry['title']);
$entry['body_safe'] = htmlspecialchars($entry['body']);
include __DIR__ . '/templates/entry_index.php';
}
インクルードしたファイルの出力を文字列として受け取りたい場合、ob_start、ob_get_cleanような組み込み関数が利用できます。
そのほか細かな挙動については公式ドキュメントを参照してください。
タグ機能
これを入れると少し長くなってしまうかと思いましたが、RDBのRelationを体感できる題材な気がするのでタグ機能を追加してみます。
タグというのは、短い文字列で関連する記事と記事を結ぶことでアクセスをよくするものです。PHPとかSQLite3のようなキーワードを与えておくことで、簡単に検索性をよくできます。あるWebサイトの記事(すべて)のタグを並べて、タグの付けられた記事リストにアクセスできるようにしたタグリストをタグクラウドといったりします。
まず、データベース構造を更新します。タグのIDと名前を格納するテーブルtagsと、記事とタグを関連付けるためのテーブルentry2tagを追加しました(今回外部キー制約は使っていません)。
<?php
$db = new SQLite3('db.sqlite3');
$db->exec('CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)');
$db->exec('CREATE TABLE IF NOT EXISTS tags(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$db->exec('CREATE TABLE IF NOT EXISTS entry2tag(entry_id INTEGER, tag_id INTEGER)');
ある記事がN個のタグを持つとき、テーブルentry2tagはフィールドentry_idがその記事のidになっているレコードをN個格納し、このレコードがそれぞれ異なるtag_idを持つようにします。このようにすることでSELECT tag_id FROM entry2tag WHERE entry_id=123のようなSQL文により、ID123の記事に付けられたタグのリストを取り出せます。タグから記事リストを取り出すことも同様にできます。ただし、これをそのまま実装すると個々のタグ/記事の詳細を取り出す際に追加でSQL文を発行する必要が出てきてしまいますが、この部分を効率的にするSQL文内でのテーブルの結合/JOINについては省略します。また、タグの順番についても今回は省略します(順番を表すカラムを増やすなど)。
次に、,(カンマ)区切りでタグを入力するフィールドを投稿フォームに追加してみましょう(タグに,が使えなくなってしまいますが)。
<!DOCTYPE html>
<meta charset="utf-8">
<p>
<?= $msg ?>
<form method="POST">
<p>
<input name="title" placeholder="Title" size="50">
<p>
<input name="tags" placeholder="Tags (ex. A, B, C)" size="50">
<p>
<textarea name="body" placeholder="Body" cols="50" rows="20"></textarea>
<p>
<button type="submit">Post</button>
</form>
ちょっと長くなっていますが、PHPスクリプト部分です。なお、日本語のタグ名に対応させるためマルチバイト用の関数mb_splitを使っています。拡張機能mbstringを有効化しておいてください。
<?php
require_once __DIR__ . '/database.php';
$method = $_SERVER['REQUEST_METHOD'];
$msg = "";
if ($method === 'POST') {
$title = $_POST['title'];
$body = $_POST['body'];
$tags_comma = $_POST['tags'];
$tag_names = mb_split(',', $tags_comma);
$tag_names = array_map(trim, $tag_names);
$stmt = $db->prepare('INSERT INTO entries VALUES(NULL, :title, :body)');
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':body', $body, SQLITE3_TEXT);
$stmt->execute();
$rowid = $db->lastInsertRowid();
$stmt = $db->prepare('SELECT id FROM entries WHERE ROWID=:rowid');
$stmt->bindValue(':rowid', $rowid, SQLITE3_INTEGER);
$result = $stmt->execute();
$entry_id = $result->fetchArray()['id'];
$stmt_select = $db->prepare('SELECT id FROM tags WHERE name=:name');
$stmt_insert = $db->prepare('INSERT INTO tags VALUES(NULL, :name)');
$stmt_select_rowid = $db->prepare('SELECT id FROM tags WHERE ROWID=:rowid');
$tag_mapping = array();
foreach ($tag_names as $tag_name) {
if (count($tag_name) == 0) continue;
$stmt_select->bindValue(':name', $tag_name);
$result = $stmt_select->execute();
if ($tag = $result->fetchArray()) {
$tag_id = $tag['id'];
}
else {
$stmt_insert->bindValue(':name', $tag_name);
$stmt_insert->execute();
$rowid = $db->lastInsertRowid();
$stmt_select_rowid->bindValue(':rowid', $rowid, SQLITE3_INTEGER);
$result = $stmt_select_rowid->execute();
$tag_id = $result->fetchArray()['id'];
}
$tag_mapping[$tag_name] = $tag_id;
}
$stmt = $db->prepare('INSERT INTO entry2tag VALUES(:entry_id, :tag_id)');
foreach ($tag_mapping as $tag_name => $tag_id) {
$stmt->bindValue(':entry_id', $entry_id);
$stmt->bindValue(':tag_id', $tag_id);
$stmt->execute();
}
$msg = "Success";
}
mb_split関数はマルチバイト文字列を区切り文字で配列に分割する関数です。array_map関数は第1引数に渡された関数に配列の各要素を1つずつ引数として渡し、その戻り値の配列を返す関数です。trim関数は文字列の(この場合分割された各タグ名の)前後の空白文字を除去する関数です(カンマの前後にスペースがある場合に除去する)。
$db->lastInsertRowid()について触れます。DBのレコードにはフィールドidとは別にROWIDという隠れた値が割り当てられており、この関数は同DBコネクション(new SQLite)内で最後に追加したレコードのROWIDを返します。ROWIDはWHERE句の条件にも使えますので、これを利用して最後に追加した記事/タグのidを取得しています。なお、ドキュメントを見ると実はAUTO INCREMENT制約のついたフィールド、つまりidを追加することでROWIDがidと一致するような気がしますが、念のためROWIDからidを取得するようにしています。
array()は(連想)配列を作成する組み込み関数です。なお、PHPではリスト(添字配列)とマップ(連想配列)を型として区別しません。そのほか、配列について詳しくは公式ドキュメントを見てください。countは配列や文字列の長さを返す関数です。
foreach文は珍しい書き方をしていますが、配列の各要素についてループするものです。PHPでは$array as $valueや$array as $key => $valueのような書き方をします。
これ以外では、stmtオブジェクトを使いまわしている以外はこれまでの応用のはずです。
(タグを記事一覧に表示したり、タグを元に記事一覧を表示する部分を書くのを忘れていたので、また時間のあるときに追加します)
トランザクション
前節の長いPHPスクリプトを見てみます。大まかな流れとしては、次のようになっています。
- 記事を作成する
- タグを作成または取得する
- 記事とタグを関連付ける
ところでもし、2番や3番の処理をしている途中で、記述のミスやDBとの切断など何らかの原因でエラーが発生したらどうなるでしょうか? このとき、記事の作成だけがDBに反映され、タグは保存されないか、関連付けられない(または一部だけが関連付けられた)状態になってしまいます。また、現在は操作ごとに毎回DBへの書き込みを行っているため、パフォーマンスの面でもよくありません。
このような事態を防ぐ機能として、トランザクションというものがあります。一部のDBライブラリではこの機能がデフォルトで有効になっていて、明示的にDBへの変更を反映する操作をしなければならない場合があります(Pythonのsqlite3モジュールなど)。
それでは、投稿処理をするスクリプトにトランザクションを追加してみましょう。
<?php
require_once __DIR__ . '/database.php';
$method = $_SERVER['REQUEST_METHOD'];
$msg = "";
if ($method === 'POST') {
$title = $_POST['title'];
$body = $_POST['body'];
$tags_comma = $_POST['tags'];
$tag_names = mb_split(',', $tags_comma);
$tag_names = array_map(trim, $tag_names);
$db->exec('BEGIN');
$stmt = $db->prepare('INSERT INTO entries VALUES(NULL, :title, :body)');
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':body', $body, SQLITE3_TEXT);
$stmt->execute();
throw new Exception();
$rowid = $db->lastInsertRowid();
$stmt = $db->prepare('SELECT id FROM entries WHERE ROWID=:rowid');
$stmt->bindValue(':rowid', $rowid, SQLITE3_INTEGER);
$result = $stmt->execute();
$entry_id = $result->fetchArray()['id'];
$stmt_select = $db->prepare('SELECT id FROM tags WHERE name=:name');
$stmt_insert = $db->prepare('INSERT INTO tags VALUES(NULL, :name)');
$stmt_select_rowid = $db->prepare('SELECT id FROM tags WHERE ROWID=:rowid');
$tag_mapping = array();
foreach ($tag_names as $tag_name) {
if (count($tag_name) == 0) continue;
$stmt_select->bindValue(':name', $tag_name);
$result = $stmt_select->execute();
if ($tag = $result->fetchArray()) {
$tag_id = $tag['id'];
}
else {
$stmt_insert->bindValue(':name', $tag_name);
$stmt_insert->execute();
$rowid = $db->lastInsertRowid();
$stmt_select_rowid->bindValue(':rowid', $rowid, SQLITE3_INTEGER);
$result = $stmt_select_rowid->execute();
$tag_id = $result->fetchArray()['id'];
}
$tag_mapping[$tag_name] = $tag_id;
}
$stmt = $db->prepare('INSERT INTO entry2tag VALUES(:entry_id, :tag_id)');
foreach ($tag_mapping as $tag_name => $tag_id) {
$stmt->bindValue(':entry_id', $entry_id);
$stmt->bindValue(':tag_id', $tag_id);
$stmt->execute();
}
$db->exec('COMMIT');
$msg = "Success";
}
次の3行を追加しただけです。
<?php
$db->exec('BEGIN');
throw new Exception();
$db->exec('COMMIT');
残念ながらPHPのSQLite3クラスにはtransaction系の機能が実装されていないようなので、直接SQL文でトランザクションしています。ただし、PDOというDBシステム間の違いを吸収する機能には実装されているようです(SQLite3クラスとほとんど同じように使うことができます)。
BEGIN(BEGIN TRANSACTION)はトランザクションの開始、COMMITで変更の反映、ROLLBACKでcommitされていない変更の差し戻しができます。COMMITが呼ばれない場合は変更は反映されません。そのほか、トランザクションを入れ子にするための機能があったりします。
セキュリティ(アクセス制御)
ところで、ここまで作ってきたDBですが、実は直接ファイルとしてアクセスできてしまいます。ブラウザでhttp://localhost:8000/db.sqlite3のように直接URLを打ち込んで開いてみましょう。
まだ機能としては追加していませんが、将来的に下書き機能や認証機能を付けたとき、そのデータベースが誰でも自由に閲覧できてしまうというのは問題があります。この記事ではWebサーバのアクセス制御機能について後ほど取り扱います。
また、投稿スクリプトであるedit.phpは誰でも実行する(開く)ことができるため、掲示板のように誰でも投稿することができる状態です。これも今回の目的のブログとしてはふさわしくないでしょう。このようなアクセス制御はWebサーバ(HTTP)の認証機能を利用したり、アカウントとセッションを使った認証機能を追加することで行います。この記事ではWebサーバ(HTTP)の認証機能について後ほど取り扱います。
さらなる機能の追加
ブログとしては、投稿日を確認できたり、記事をあとから編集できたほうが便利でしょう。この記事では取り扱いませんが、これらの機能もここまでの内容を応用して、いくらかの追加の調査をすることである程度実装できるはずです。現在時刻(UTC)の取得にはtime関数(数値)、クエリパラメータの取得には$_GET(記事IDを渡す)が利用できます。また、フォームの初期値はinputタグではvalue属性、textareaタグではタグ内のテキストとして与えます(エスケープを忘れないようにしてください)。表示しないフォームフィールドは<input type="hidden">のように追加できます。