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">
のように追加できます。