PHPオブジェクト指向実践

掲示板

クラス設計

掲示板を作ってみましょう。
以下のような仕様の簡易な掲示板です。

  • 新規投稿機能
  • 返信機能
  • 記事削除機能
  • データはCSV形式のテキストファイル

まずクラス設計ですが、
今回は返信や記事削除などの機能があり、少しクラス実装が大きくなりそうです。

ここはクラスを二つに分けることにします。
メインの処理ロジッククラスの他に、
データアクセス部分だけを集めたデータアクセスクラスを作成します。

また、今回はCSVの項目番号を定数として設定し、専用の項目番号クラスを作ってみます。

項目番号定数クラス

BbsCols.php
<?php

class BbsCols
{
    const NO = 0;
    const PARENT_NO = 1;
    const NAME = 2;
    const TITLE = 3;
    const MESSAGE = 4;
}

?>

今回、掲示板として必要な項目として以下の5項目とします。

  • 投稿番号
  • 親番号
  • 投稿者名
  • タイトル
  • 本文

投稿番号は投稿があるごとに設定される連番です。
親番号は、新規投稿の場合は0、返信の場合はその親となる記事の番号。
投稿者名・タイトル・本文はそのまま、フォームに入力された文字列です。

投稿一件に対し、この5項目をカンマでつなげた1行の文字列としてテキストファイルに保存します。
項目の順番がバラバラではシステムとして処理できませんから、
どの項目が何番目であるかというのを定数で設定し、それにしたがって保存、読込を行います。

この項目番号定数を一つのクラスとしてまとめてしまったのが、このBbsColsクラスです。
例えばBbsCols::NAMEで、数値の2が取得できます。

データアクセスクラス

掲示板の処理ロジックのうち、
データを直接読み書きする部分のみを抜き出してクラス化した物を作成します。

このように、役割ごとにクラスを分割するのは有効です。
特に今回の場合、将来的にデータの保存をテキストではなくデータベースにグレードアップする可能性も考え、データ読み書き部分だけ別クラスにしました。

こうすればメインロジックは変更せずに、データアクセスクラスだけをデータベース向けに改変することで対応が可能となります。

BbsDac.php
<?php

class BbsDac
{
    // データファイルパス
    protected $_dataPath;
    
    // コンストラクタ
    public function __construct($dataPath)
    {
        if (false == file_exists($dataPath)) {
            if (false == @touch($dataPath)) {
                throw new Exception('データファイルの作成に失敗しました');
            }
        }
        $this->_dataPath = $dataPath;
    }
    
    // データ取得
    public function getData()
    {
        $fp = fopen($this->_dataPath, 'r');
        $data = array();
        while ($row = fgetcsv($fp)) {
            $data[] = $row;
        }
        fclose($fp);
        
        $data = array_reverse($data);
        return $data;
    }
    
    // 登録用記事番号取得
    public function getNewNo()
    {
        $data = $this->getData();
        $no = 1;
        if (0 < count($data)) {
            $no = $data[0][BbsCols::NO];
            $no++;
        }
        return $no;
    }
 
    // 書き込み
    public function write($data)
    {
        $this->sanitize($data);
        $line = implode(',', $data) . "\r\n";
        
        $fp = fopen($this->_dataPath, 'a');
        fputs($fp, $line);
        fclose($fp);
    }
    
    // 書き込みデータ整形
    protected function sanitize(&$data)
    {
        foreach ($data as $index => $val) {
            $tmpData = $val;
            $tmpData = trim($tmpData);
            $tmpData = str_replace(',', '', $tmpData);
            if (BbsCols::MESSAGE == $index) {
                $tmpData = nl2br($tmpData);
            }
            $tmpData = str_replace("\r", '', $tmpData);
            $tmpData = str_replace("\n", '', $tmpData);
            $data[$index] = $tmpData;
        }
    }
    
    // 削除
    public function delete($no)
    {
        $data = $this->getData();
        
        $writeData = array();
        if (0 < count($data)) {
            foreach ($data as $row) {
                if ($row[BbsCols::NO] != $no && $row[BbsCols::PARENT_NO] != $no) {
                    $writeData[] = implode(',', $row);
                }
            }
        }
        $writeText = '';
        if (0 < count($writeData)) {
            $writeText = implode("\r\n", $writeData) . "\r\n";
        }

        $fp = fopen($this->_dataPath, 'w');
        fputs($fp, $writeText);
        fclose($fp);
    }
}

?>

ほとんどのメソッドはメッセージボードと同じなので割愛して、ここで新しく出てきたもののみ、解説します。

まずsanitizeメソッドですが、
これは登録時のデータ整形処理をメソッドに切り出したものです。
メッセージボードの時より処理がたくさんあるので、別メソッドにしてみました。

次にdeleteですが、
これは文字通り、投稿削除のメソッドです。
処理内容としては、

一旦全てのデータを配列に取得し、
foreachでループしながら、投稿番号または親番号が引数で指定された番号と一致する行以外を、新たな別の配列に入れなおしていきます。

これにより、指定の番号または、親記事が指定番号である投稿データを除いた投稿データ配列が出来上がります。

そして保存ファイルを一旦クリアし、先ほど出来上がった投稿データを書き込みます。
これで指定番号の記事、または親番号が指定番号である記事データが削除されたことになります。

ロジッククラス

掲示板のメインロジックを実装するクラスです。
直接テキストファイルのデータ読み書きを行うのはデータアクセスクラスに任せているので、このクラスではデータアクセスクラスを使用してデータを取得し、取得したデータを加工するというのがメインになります。

データアクセスクラスのインスタンスはコンストラクタで生成してフィールドに保持しておきます。

BbsLogic.php
<?php

class BbsLogic
{
    // データアクセスクラスインスタンス
    protected $dac;
    
    // コンストラクタ
    public function __construct($dataPath)
    {
        $this->dac = new BbsDac($dataPath);
    }
    
    // 記事データ取得
    public function getThreadData()
    {
        // データ取得
        $data = $this->dac->getData();
        
        // 親記事配列生成
        $parents = array();
        foreach ($data as $row) {
            // 親記事番号が0のデータが親記事
            if (0 == $row[BbsCols::PARENT_NO]) {
                $parents[] = $row;
            }
        }
        
        // 親記事と子記事の階層を生成
        foreach ($parents as $idx => $parent) {
            $childs = array();
            foreach ($data as $row) {
                // 親記事自身の記事番号と一致する親記事番号を持つものがレス記事データ
                if ($parent[BbsCols::NO] == $row[BbsCols::PARENT_NO]) {
                    $childs[] = $row;
                }
            }
            $parents[$idx]['Childs'] = $childs;
        }
        
        // 記事配列を逆転して新しい記事順に並べる
        $parents = array_reverse($parents);
        
        return $parents;
    }
    
    // 記事書き込み
    public function write($post)
    {
        // 書き込み用データ生成
        $data[BbsCols::NO]  = $this->dac->getNewNo();
        $data[BbsCols::PARENT_NO] = $post['parent_no'];
        $data[BbsCols::NAME] = $post['name'];
        $data[BbsCols::TITLE] = $post['title'];
        $data[BbsCols::MESSAGE] = $post['message'];
        
        // 書き込み実行
        $this->dac->write($data);
    }
    
    // 記事削除
    public function delete($post)
    {
        $no = $post['no'];
        $this->dac->delete($no);
    }
}

?>

データ取得メソッドは少しややこしい感じになっています。

まず、データアクセスクラスを使用してテキストファイルより全データを取得します。
取得されるデータは2次元配列になっています。

次にこのデータより、親記事のデータのみを別の配列へコピーします。
親記事のデータとは、親記事番号が0であるデータのことです。

次にこの親記事配列をforeachでループ処理し、各親記事ごとにそのレス記事データを検索していきます。
ここで言う検索とは、テキストより取得している全データから、親記事番号が各親記事の記事番号と一致するものを探すということです。

そして見つかったレス記事データは、親記事に「Childs」という名前で配列キーを作成し、そこに格納します。

これで親記事とレス記事が階層構造になります。

メインスクリプトの実装

bbs.php
<?php

require_once './class/bbs/BbsCols.php';
require_once './class/bbs/BbsLogic.php';
require_once './class/bbs/BbsDac.php';

try {
    $dataPath = './data/bbs.dat';
    $bbs = new @CBbsLogic@($dataPath);

    $parentNo = 0;
    $message = '';
    // 返信ボタンが押された場合
    if (true == isset($_POST['res'])) {
        // 返信投稿モードであることを示す文言
        $parentNo = $_POST['parent_no'];
        $message = sprintf('No.%sへの返信です', $parentNo);
    }    
    // 書き込みボタンが押された場合
    if (true == isset($_POST['write'])) {
        $bbs->write($_POST);
        header('Location: ' . $_SERVER['SCRIPT_NAME']);
        exit;
    }
    // 削除ボタンが押された場合
    if (true == isset($_POST['del'])) {
        $bbs->delete($_POST);
        header('Location: ' . $_SERVER['SCRIPT_NAME']);
        exit;
    }

    // 記事データ取得
    $data = $bbs->getThreadData();

} catch(@CException@ $e) {
    die($e->getMessage());
}

?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
div.messages {
    width:800px;
}
div.parent {
    border:1px solid #888888;
    margin:20px 0 0 0;
    padding:10px;
}
div.parent div.message {
    margin:10px 10px 10px 30px;
}
div.child {
    border-top:1px solid #888888;
    margin:10px 10px 0 30px;
    padding:10px;
}
div.child div.message {
    margin:10px 10px 10px 30px;
}
span.message {
    color:#FF0000;
}
form {
    display:inline;
    margin:0;
    padding:0;
}
</style>
</head>
<body>

<form method="post" action="">
<input type="text" name="name" /><br />
<input type="text" name="title" /><br />
<textarea name="message"></textarea><br />
<input type="hidden" name="parent_no" value="<?php echo $parentNo; ?>" />
<input type="submit" name="write" value="書き込む" />
<span class="message"><?php echo $message; ?></span>
</form>

<div class="messages">
<?php

foreach ($data as $row) {
    echo '<div class="parent">';
    echo '<div class="header">';
    echo sprintf(
        'No.%s %s 投稿者名:%s',
        $row[@CBbsCols@::NO],
        $row[@CBbsCols@::TITLE],
        $row[@CBbsCols@::NAME]
        );
    echo '<form method="post" action="">';
    echo '<input type="submit" name="res" value="返信" />';
    echo sprintf('<input type="hidden" name="parent_no" value="%s" />', $row[@CBbsCols@::NO]);
    echo '</form>';
    echo '<form method="post" action="">';
    echo '<input type="submit" name="del" value="削除" />';
    echo sprintf('<input type="hidden" name="no" value="%s" />', $row[@CBbsCols@::NO]);
    echo '</form>';
    echo '</div>';
    echo sprintf('<div class="message">%s</div>', $row[@CBbsCols@::MESSAGE]);
    foreach ($row['Childs'] as $child) {
        echo '<div class="child">';
        echo sprintf(
            '<div class="header">%s  %s</div>',
            $child[@CBbsCols@::TITLE],
            $child[@CBbsCols@::NAME]
            );
        echo sprintf('<div class="message">%s</div>', $child[@CBbsCols@::MESSAGE]);
        echo '</div>';
    }
    echo '</div>';
}

?>
</div>

</body>
</html>

上部のPHPコード部分でtry~catch構文を使用しています。
このように、メインロジックをtry~catchすることで、どこかで例外が投げられても、全てここに処理を集約できます。


ではフォルダ・ファイル構成を見てみましょう

public_html/
  ∟class/
    ∟bbs/
      ∟BbsCols.php  
      ∟BbsLogic.php
      ∟BbsDac.php
  ∟data/
    ∟bbs.dat
  ∟bbs.php