41
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[MySQL版] ページング処理を採り入れた掲示板サンプル

Last updated at Posted at 2013-10-25

【2021/10/15 追記】
この記事は更新が停止されています。現在では筆者の思想が変化している面もありますので,過去の記事として参考程度にご覧ください。

本編(MySQL編)

完成品イメージ

ss (2013-08-30 at 04.35.47).png

テーブル構造

CREATE TABLE mini_board(
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `name` varchar(30) NOT NULL,
  `email` varchar(255) NOT NULL,
  `text` varchar(140) NOT NULL,
  `time` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci

コード

<?php

/* 設定 */
// DSN(Data Source Name)
define('DB_DSN', 'mysql:dbname=test;host=127.0.0.1;charset=utf8');
define('DB_USER', 'root');               // ユーザー名
define('DB_PASS', '');                   // パスワード
define('SESSION_NAME', 'MiniBoard');     // セッションクッキーに用いる名前
define('DISP_MAX',  10);                 // 1ページの最大表示数
define('LIMIT_SEC', 5);                  // 連続投稿を禁止する秒数
define('TOKEN_MAX', 10);                 // ワンタイムトークン蓄積最大数
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($_POST[$v]) && is_string($_POST[$v]) ? trim($_POST[$v]) : '';
}
// ページ番号を1以上の整数になるように補正
$page = max(1, (int)$page);

/* セッションの初期化 */
session_name(SESSION_NAME); // セッション名を設定
@session_start();           // セッション開始
// セッション変数を初期化
if (!$_SESSION) {
    $_SESSION = array(
        'name'  => '',
        'email' => '',
        'text'  => '',
        'token' => array(),
        'prev'  => null,
    );
}

/* PDOでの処理 */
try {
    
    // 接続
    $pdo = new PDO(DB_DSN, DB_USER, DB_PASS);
    // プリペアドステートメントのエミュレーションを無効化
    $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
    // SQL実行でエラーが発生したときに例外をスローするように設定
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    // 送信ボタンが押された場合
    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;
            }
            // プリペアドステートメントを生成
            $stmt = $pdo->prepare(implode(' ', array(
                'INSERT',
                'INTO mini_board(`name`, `email`, `text`, `time`)',
                'VALUES(?, ?, ?, ?)',
            )));
            // 書き込みを実行
            $stmt->execute(array(
                $name,
                $email,
                $text,
                date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME'])
            ));
            // セッションに時間を記録し、メッセージをセット
            $_SESSION['prev'] = $_SERVER['REQUEST_TIME'];
            // フォームの投稿内容をリセットする
            $_SESSION['text'] = '';
            // 正しい処理を行ったが、形式上例外としてスロー
            throw e('書き込みました', $e);
        } catch (Exception $e) { }
    }
    // プリペアドステートメントを生成
    $stmt = $pdo->prepare(implode(' ', array(
        'SELECT',
        'SQL_CALC_FOUND_ROWS `name`, `email`, `text`, `time`',
        'FROM mini_board',
        'ORDER BY `id` DESC',
        'LIMIT ?, ?',
    )));
    // 値をバインド
    $stmt->bindValue(1, ($page - 1) * DISP_MAX, PDO::PARAM_INT);
    $stmt->bindValue(2, DISP_MAX, PDO::PARAM_INT);
    // 読み出しを実行
    $stmt->execute();
    // ページ番号にあった分だけ取り出す
    $articles = $stmt->fetchAll(PDO::FETCH_ASSOC);
    // 現在のページの件数をセット
    $current_count = count($articles);
    // 総件数をセット
    $whole_count = (int)$pdo->query('SELECT FOUND_ROWS()')->fetchColumn();
    // 総ページ数をセット
    $page_count = ceil($whole_count / DISP_MAX);
    
} catch (Exception $e) { }

/* ワンタイムトークンを次の投稿のために準備 */
$_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>

工夫していること

  • 例外をスタックを用いて処理している。
  • ページング処理に LIMIT句, SQL_CALC_FOUND_ROWSオプション, FOUND_ROWS関数
    を活用して、件数が増えても可能な限り負荷がかからないようにしている。
  • ワンタイムトークンを用いてCSRF攻撃や連投を簡易的に防いでいる。
    厳密にするにはIPアドレスでの判断を行うことになるが、ここでは割愛。

関連

$_GET, $_POSTなどを受け取る際の処理

[シリアル版] ページング処理を採り入れた掲示板サンプル

PHPでデータベースに接続するときのまとめ

まとめて例外をスローする小技

おまけ(SQLite編)

SQLiteではMySQLに存在しているようなページング処理用の機能を使うことが出来ないので、もう一度LIMIT句無しで全件をSELECTするしか方法が無い。尤も、これでもSQLiteでアクセスが捌ききれるような規模の掲示板であれば、もともとの構造の違いもあってMySQLよりも高速に動作することが期待できる。

<?php

/* 設定 */
define('DB_DSN', 'sqlite:./test.db');    // DSN(Data Source Name)
define('SESSION_NAME', 'MiniBoard');     // セッションクッキーに用いる名前
define('DISP_MAX',  10);                 // 1ページの最大表示数
define('LIMIT_SEC', 5);                  // 連続投稿を禁止する秒数
define('TOKEN_MAX', 10);                 // ワンタイムトークン蓄積最大数
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($_POST[$v]) && is_string($_POST[$v]) ? trim($_POST[$v]) : '';
}
// ページ番号を1以上の整数になるように補正
$page = max(1, (int)$page);

/* セッションの初期化 */
session_name(SESSION_NAME); // セッション名を設定
@session_start();           // セッション開始
// セッション変数を初期化
if (!$_SESSION) {
    $_SESSION = array(
        'name'  => '',
        'email' => '',
        'text'  => '',
        'token' => array(),
        'prev'  => null,
    );
}

/* PDOでの処理 */
try {
    
    // 接続
    $pdo = new PDO(DB_DSN);
    // プリペアドステートメントのエミュレーションを無効化
    $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
    // SQL実行でエラーが発生したときに例外をスローするように設定
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    // テーブルが未生成であれば生成する
    $pdo->exec(implode(' ', array(
        'CREATE TABLE IF NOT EXISTS',
        'mini_board(',
            implode(', ', array(
                'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL',
                'name TEXT NOT NULL',
                'email TEXT NOT NULL',
                'text TEXT NOT NULL',
                'time TEXT NOT NULL',
            )),
        ')',
    )));
    // 送信ボタンが押された場合
    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;
            }
            // プリペアドステートメントを生成
            $stmt = $pdo->prepare(implode(' ', array(
                'INSERT',
                'INTO mini_board(`name`, `email`, `text`, `time`)',
                'VALUES(?, ?, ?, ?)',
            )));
            // 書き込みを実行
            $stmt->execute(array(
                $name,
                $email,
                $text,
                date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME'])
            ));
            // セッションに時間を記録し、メッセージをセット
            $_SESSION['prev'] = $_SERVER['REQUEST_TIME'];
            // フォームの投稿内容をリセットする
            $_SESSION['text'] = '';
            // 正しい処理を行ったが、形式上例外としてスロー
            throw e('書き込みました', $e);
        } catch (Exception $e) { }
    }
    // プリペアドステートメントを生成
    $stmt = $pdo->prepare(implode(' ', array(
        'SELECT',
        '`name`, `email`, `text`, `time`',
        'FROM mini_board',
        'ORDER BY `id` DESC',
        'LIMIT ?, ?',
    )));
    // 値をバインド
    $stmt->bindValue(1, ($page - 1) * DISP_MAX, PDO::PARAM_INT);
    $stmt->bindValue(2, DISP_MAX, PDO::PARAM_INT);
    // 読み出しを実行
    $stmt->execute();
    // ページ番号にあった分だけ取り出す
    $articles = $stmt->fetchAll(PDO::FETCH_ASSOC);
    // 現在のページの件数をセット
    $current_count = count($articles);
    // 総件数をセット
    $whole_count = (int)$pdo->query('SELECT COUNT(*) FROM mini_board')->fetchColumn();
    // 総ページ数をセット
    $page_count = ceil($whole_count / DISP_MAX);
    
} catch (Exception $e) { }

/* ワンタイムトークンを次の投稿のために準備 */
$_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>
41
34
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?