【2021/10/15 追記】
この記事は更新が停止されています。現在では筆者の思想が変化している面もありますので,過去の記事として参考程度にご覧ください。
本編
完成品イメージ
コード
<?php
/* 設定 */
define('DATA_FILENAME', 'test.log'); // データファイル
define('DISP_MAX', 10); // 1ページの最大表示数
define('LIMIT_SEC', 5); // 連続投稿を禁止する秒数
define('TOKEN_MAX', 10); // ワンタイムトークン蓄積最大数
define('SESSION_NAME', 'MiniBoard'); // セッションクッキーに用いる名前
date_default_timezone_set('Asia/Tokyo'); // タイムゾーン
mb_internal_encoding('UTF-8'); // 内部エンコーディング
/**
* HTML特殊文字をエスケープする関数
*/
function h($str) {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
/**
* RuntimeExceptionを生成する関数
* http://qiita.com/mpyw/items/6bd99ff62571c02feaa1
*/
function e($msg, Exception &$previous = null) {
return new RuntimeException($msg, 0, $previous);
}
/**
* 例外スタックのメッセージ部分を配列に変換する関数
* http://qiita.com/mpyw/items/6bd99ff62571c02feaa1
*/
function exception_to_array(Exception $e) {
do {
$msgs[] = $e->getMessage();
} while ($e = $e->getPrevious());
return array_reverse($msgs);
}
/* 変数の初期化 */
// リクエストパラメータをトリミングした後展開
foreach (array('name', 'email', 'text', 'token', 'page', 'submit') as $v) {
$$v = isset($_REQUEST[$v]) && is_string($_REQUEST[$v]) ? trim($_REQUEST[$v]) : '';
}
// ページ番号を1以上の整数になるように補正
$page = max(1, (int)$page);
/* セッションの初期化 */
session_name(SESSION_NAME); // セッション名を設定
@session_start(); // セッション開始
// セッション変数を初期化
if (!$_SESSION) {
$_SESSION = array(
'name' => '',
'email' => '',
'text' => '',
'token' => array(),
'prev' => null,
);
}
/* データファイルに関する処理 */
try {
// データファイルを読み書き両用でオープン
// (ポインタを先頭にセット, 未作成ならば新規作成)
if (!$fp = @fopen(DATA_FILENAME, 'a+b')) {
throw e('データファイルのオープンに失敗しました。', $e);
}
// データファイルを共有ロック
flock($fp, LOCK_SH);
// ファイルを読み取ってシリアルからの復元を試みる
$articles = @unserialize(stream_get_contents($fp)) ?: array();
// 送信ボタンが押された場合
if ($submit) {
try {
// セッション変数に書き込む情報をセット
$_SESSION['name'] = $name;
$_SESSION['email'] = $email;
$_SESSION['text'] = $text;
// ワンタイムトークンが直近指定個に含まれていなければ弾く
if (!isset($_SESSION['token'][$token])) {
throw e('フォームの有効期限が切れています。', $e);
}
// ワンタイムトークンを消費させる
unset($_SESSION['token'][$token]);
// 最後の投稿から指定秒経過していなければ弾く
if ($_SESSION['prev'] !== null) {
$diff = $_SERVER['REQUEST_TIME'] - $_SESSION['prev'];
if (($limit = LIMIT_SEC - $diff) > 0) {
throw e("投稿間隔が短すぎます。あと{$limit}秒ほどお待ちください。", $e);
}
}
// 名前をチェック
if (!$len = mb_strlen($name) or $len > 30) {
$e = e('名前は30字以下で入力してください。', $e);
}
// Eメールアドレスをチェック
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$e = e('有効なEメールアドレスを入力してください。', $e);
}
// 本文をチェック
if (!$len = mb_strlen($text) or $len > 140) {
$e = e('本文は140字以下で入力してください。', $e);
}
// 例外がここまでに1つでも発生していればスローする
if (!empty($e)) {
throw $e;
}
// 排他ロックに切り替える
flock($fp, LOCK_EX);
// 配列の先頭にデータを追加
array_unshift($articles, array(
'name' => $name,
'email' => $email,
'text' => $text,
'time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']),
));
// ファイルを空にする
ftruncate($fp, 0);
// 編集した配列をシリアル化して上書き
fwrite($fp, serialize($articles));
// セッションに時間を記録し、メッセージをセット
$_SESSION['prev'] = $_SERVER['REQUEST_TIME'];
// フォームの投稿内容をリセットする
$_SESSION['text'] = '';
// 正しい処理を行ったが、形式上例外としてスロー
throw e('書き込みました', $e);
} catch (Exception $e) { }
}
// 総件数をセット
$whole_count = count($articles);
// 総ページ数をセット
$page_count = ceil($whole_count / DISP_MAX);
// ページ番号にあった分だけ取り出す
$articles = array_slice($articles, ($page - 1) * DISP_MAX);
// 現在のページの件数をセット
$current_count = count($articles);
} catch (Exception $e) { }
// 後始末
if (!empty($fp)) {
flock($fp, LOCK_UN);
fclose($fp);
}
/* ワンタイムトークンを次の投稿のために準備 */
$_SESSION['token'] = array_slice(
array($token = sha1(mt_rand()) => 1) + $_SESSION['token'],
0,
TOKEN_MAX
);
/* ヘッダー送信 */
header('Content-Type: application/xhtml+xml; charset=utf-8');
?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>ミニ掲示板</title>
<style type="text/css"><![CDATA[
<!--
#wrapper {
width: 100%;
}
#header, #container, #footer {
width: 650px;
margin: 0px auto;
}
#header, #footer {
text-align: center;
}
#messages, #textarea, #articles {
width: 550px;
margin: -3px auto 0px;
border-top: 3px double brown;
padding: 28px;
}
#articles {
overflow: hidden;
padding-top: 0px;
}
.article {
margin-top: -1px;
border-top: 1px dotted brown;
padding: 19px;
}
.article_name {
font-size: 27px;
}
.article_text {
margin: 20px;
}
.article_time {
text-align: right;
font-size: 11px;
}
.page {
font-size: 11px;
text-align: right;
}
body {
background: antiquewhite;
}
h1 {
color: fuchsia;
}
textarea {
width: 100%;
height: 150px;
}
label {
display: block;
margin: 10px 10px;
}
-->
]]></style>
</head>
<body>
<div id="wrapper">
<div id="header">
<h1>~ミニ掲示板~</h1>
</div>
<div id="container">
<div id="textarea">
<form action="" method="post">
<label>名前: <input name="name" type="text" value="<?=h($_SESSION['name'])?>" /></label>
<label>Eメール: <input name="email" type="text" value="<?=h($_SESSION['email'])?>" /></label>
<label>本文<p><textarea name="text"><?=h($_SESSION['text'])?></textarea></p></label>
<label style="text-align:right;"><input type="submit" name="submit" value="投稿" /></label>
<label><input type="hidden" name="token" value="<?=h($token)?>" /></label>
</form>
</div>
<?php if (!empty($e)): ?>
<div id="messages">
<?php foreach (exception_to_array($e) as $msg): ?>
<div><?=h($msg)?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($articles)): ?>
<div id="articles">
<?php foreach ($articles as $article): ?>
<div class="article">
<div class="article_name"><a href="mailto:<?=h($article['email'])?>"><?=h($article['name'])?></a></div>
<div class="article_text"><pre><?=h($article['text'])?></pre></div>
<div class="article_time"><?=h($article['time'])?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div id="footer">
<div>
<?php if ($page > 1): ?>
<a href="?page=<?=$page-1?>">前</a> |
<?php endif; ?>
<a href="?">最新</a>
<?php if (!empty($page_count) and $page < $page_count): ?>
| <a href="?page=<?=$page+1?>">次</a>
<?php endif; ?>
</div>
<p class="page"><?php
if (empty($current_count)) {
echo 'まだ書き込みはありません';
} else {
printf('%d件中%d件目~%d件目(%dページ中%dページ目)を表示中',
$whole_count,
($tmp = ($page - 1) * DISP_MAX) + 1,
$tmp + $current_count,
$page_count,
$page
);
}
?></p>
</div>
</div>
</body>
</html>
工夫していること
- ファイルオープン回数を最小に抑えている。
- ファイル読み込み時には 共有ロック 、書き込み時にはさらに 排他ロック にすることにより、
ファイルの破損・スクリプトの誤作動を徹底的に防止している。 - ちまちまファイルで1行ずつテキスト管理を行うよりも、配列ごとシリアライズしてぶち込んだ方が
扱いラクじゃんということで後者を採用。但しデータサイズが膨大になってくるとそもそも
ファイル1つでの管理では追いつかなくなるため、データベースを使う必要性が出てくる。 - 例外をスタックを用いて処理している。
- ワンタイムトークンを用いてCSRF攻撃や連投を簡易的に防いでいる。
厳密にするにはIPアドレスでの判断を行うことになるが、ここでは割愛。
関連
$_GET, $_POSTなどを受け取る際の処理
[PHP] ファイルオープンモードに関するマニュアルの記述は間違っている
[MySQL版] ページング処理を採り入れた掲示板サンプル
まとめて例外をスローする小技
おまけ
データとスクリプトを1ファイルにまとめる荒業
__halt_compiler という(一応)関数を使えばこんな気持ち悪いことも出来てしまう。
<?php
/* 設定 */
define('DISP_MAX', 10); // 1ページの最大表示数
define('LIMIT_SEC', 5); // 連続投稿を禁止する秒数
define('TOKEN_MAX', 10); // ワンタイムトークン蓄積最大数
define('SESSION_NAME', 'MiniBoard'); // セッションクッキーに用いる名前
date_default_timezone_set('Asia/Tokyo'); // タイムゾーン
mb_internal_encoding('UTF-8'); // 内部エンコーディング
/**
* HTML特殊文字をエスケープする関数
*/
function h($str) {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
/**
* RuntimeExceptionを生成する関数
* http://qiita.com/mpyw/items/6bd99ff62571c02feaa1
*/
function e($msg, Exception &$previous = null) {
return new RuntimeException($msg, 0, $previous);
}
/**
* 例外スタックのメッセージ部分を配列に変換する関数
* http://qiita.com/mpyw/items/6bd99ff62571c02feaa1
*/
function exception_to_array(Exception $e) {
do {
$msgs[] = $e->getMessage();
} while ($e = $e->getPrevious());
return array_reverse($msgs);
}
/* 変数の初期化 */
// リクエストパラメータをトリミングした後展開
foreach (array('name', 'email', 'text', 'token', 'page', 'submit') as $v) {
$$v = isset($_REQUEST[$v]) && is_string($_REQUEST[$v]) ? trim($_REQUEST[$v]) : '';
}
// ページ番号を1以上の整数になるように補正
$page = max(1, (int)$page);
/* セッションの初期化 */
session_name(SESSION_NAME); // セッション名を設定
@session_start(); // セッション開始
// セッション変数を初期化
if (!$_SESSION) {
$_SESSION = array(
'name' => '',
'email' => '',
'text' => '',
'token' => array(),
'prev' => null,
);
}
/* データに関する処理 */
try {
// このファイル自身を読み書き両用でオープン
if (!$fp = @fopen(__FILE__, 'a+b')) {
throw e('データファイルのオープンに失敗しました。', $e);
}
// ファイルを共有ロック
flock($fp, LOCK_SH);
// ファイルポインタをデータ部まで移動
fseek($fp, __COMPILER_HALT_OFFSET__);
// データを読み取ってシリアルからの復元を試みる
$articles = @unserialize(stream_get_contents($fp)) ?: array();
// 送信ボタンが押された場合
if ($submit) {
try {
// セッション変数に書き込む情報をセット
$_SESSION['name'] = $name;
$_SESSION['email'] = $email;
$_SESSION['text'] = $text;
// ワンタイムトークンが直近指定個に含まれていなければ弾く
if (!isset($_SESSION['token'][$token])) {
throw e('フォームの有効期限が切れています。', $e);
}
// ワンタイムトークンを消費させる
unset($_SESSION['token'][$token]);
// 最後の投稿から指定秒経過していなければ弾く
if ($_SESSION['prev'] !== null) {
$diff = $_SERVER['REQUEST_TIME'] - $_SESSION['prev'];
if (($limit = LIMIT_SEC - $diff) > 0) {
throw e("投稿間隔が短すぎます。あと{$limit}秒ほどお待ちください。", $e);
}
}
// 名前をチェック
if (!$len = mb_strlen($name) or $len > 30) {
$e = e('名前は30字以下で入力してください。', $e);
}
// Eメールアドレスをチェック
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$e = e('有効なEメールアドレスを入力してください。', $e);
}
// 本文をチェック
if (!$len = mb_strlen($text) or $len > 140) {
$e = e('本文は140字以下で入力してください。', $e);
}
// 例外がここまでに1つでも発生していればスローする
if (!empty($e)) {
throw $e;
}
// 排他ロックに切り替える
flock($fp, LOCK_EX);
// 配列の先頭にデータを追加
array_unshift($articles, array(
'name' => $name,
'email' => $email,
'text' => $text,
'time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']),
));
// データ部を空にする
ftruncate($fp, __COMPILER_HALT_OFFSET__);
// 編集した配列をシリアル化して上書き
fwrite($fp, serialize($articles));
// セッションに時間を記録し、メッセージをセット
$_SESSION['prev'] = $_SERVER['REQUEST_TIME'];
// フォームの投稿内容をリセットする
$_SESSION['text'] = '';
// 正しい処理を行ったが、形式上例外としてスロー
throw e('書き込みました', $e);
} catch (Exception $e) { }
}
// 総件数をセット
$whole_count = count($articles);
// 総ページ数をセット
$page_count = ceil($whole_count / DISP_MAX);
// ページ番号にあった分だけ取り出す
$articles = array_slice($articles, ($page - 1) * DISP_MAX);
// 現在のページの件数をセット
$current_count = count($articles);
} catch (Exception $e) { }
// 後始末
if (!empty($fp)) {
flock($fp, LOCK_UN);
fclose($fp);
}
/* ワンタイムトークンを次の投稿のために準備 */
$_SESSION['token'] = array_slice(
array($token = sha1(mt_rand()) => 1) + $_SESSION['token'],
0,
TOKEN_MAX
);
/* ヘッダー送信 */
header('Content-Type: application/xhtml+xml; charset=utf-8');
?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>ミニ掲示板</title>
<style type="text/css"><![CDATA[
<!--
#wrapper {
width: 100%;
}
#header, #container, #footer {
width: 650px;
margin: 0px auto;
}
#header, #footer {
text-align: center;
}
#messages, #textarea, #articles {
width: 550px;
margin: -3px auto 0px;
border-top: 3px double brown;
padding: 28px;
}
#articles {
overflow: hidden;
padding-top: 0px;
}
.article {
margin-top: -1px;
border-top: 1px dotted brown;
padding: 19px;
}
.article_name {
font-size: 27px;
}
.article_text {
margin: 20px;
}
.article_time {
text-align: right;
font-size: 11px;
}
.page {
font-size: 11px;
text-align: right;
}
body {
background: antiquewhite;
}
h1 {
color: fuchsia;
}
textarea {
width: 100%;
height: 150px;
}
label {
display: block;
margin: 10px 10px;
}
-->
]]></style>
</head>
<body>
<div id="wrapper">
<div id="header">
<h1>~ミニ掲示板~</h1>
</div>
<div id="container">
<div id="textarea">
<form action="" method="post">
<label>名前: <input name="name" type="text" value="<?=h($_SESSION['name'])?>" /></label>
<label>Eメール: <input name="email" type="text" value="<?=h($_SESSION['email'])?>" /></label>
<label>本文<p><textarea name="text"><?=h($_SESSION['text'])?></textarea></p></label>
<label style="text-align:right;"><input type="submit" name="submit" value="投稿" /></label>
<label><input type="hidden" name="token" value="<?=h($token)?>" /></label>
</form>
</div>
<?php if (!empty($e)): ?>
<div id="messages">
<?php foreach (exception_to_array($e) as $msg): ?>
<div><?=h($msg)?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($articles)): ?>
<div id="articles">
<?php foreach ($articles as $article): ?>
<div class="article">
<div class="article_name"><a href="mailto:<?=h($article['email'])?>"><?=h($article['name'])?></a></div>
<div class="article_text"><pre><?=h($article['text'])?></pre></div>
<div class="article_time"><?=h($article['time'])?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div id="footer">
<div>
<?php if ($page > 1): ?>
<a href="?page=<?=$page-1?>">前</a> |
<?php endif; ?>
<a href="?">最新</a>
<?php if (!empty($page_count) and $page < $page_count): ?>
| <a href="?page=<?=$page+1?>">次</a>
<?php endif; ?>
</div>
<p class="page"><?php
if (empty($current_count)) {
echo 'まだ書き込みはありません';
} else {
printf('%d件中%d件目~%d件目(%dページ中%dページ目)を表示中',
$whole_count,
($tmp = ($page - 1) * DISP_MAX) + 1,
$tmp + $current_count,
$page_count,
$page
);
}
?></p>
</div>
</div>
</body>
</html><?php
/* ここから先データ部 */
__halt_compiler();