PHPプログラミング診断室

第2回HTMLとPHPの見事なハーモニー(治療編)

修正コード

第1回目で診断したPHPコードについて、問題点を治療した修正コードを書いてみましょう。ポイントとしては、指摘してきた問題点を中心に、コードの分離、同じ処理の共通化を行い、そして今どきのPHPの書き方をエッセンスとして加えています。クラス化やライブラリ、フレームワークの利用も考えられるのですが、元のコードから乖離しすぎないように、関数にまとめる方法で書きました。

では、治療後の修正コードです。

<?php
define('DB_DSN', 'pgsql:dbname=app');
define('DB_USER', 'vagrant');
define('DB_PASS', 'pass');

/**
 * PDOインスタンス生成
 *
 * @param string $dsn
 * @param string $user
 * @param string $pass
 * @return \PDO
 */
function createPdo($dsn = DB_DSN, $user = DB_USER, $pass = DB_PASS) {
    static $pdo = null;

    if (is_null($pdo)) {
      $pdo = new PDO($dsn, $user, $pass);
      $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    return $pdo;
}

/**
 * メールマガジンリスト取得
 *
 * @return array
 */
function selectMailMagazines() {
    $pdo = createPdo();
    $statement = $pdo->prepare('SELECT * FROM magazines');
    $statement->execute();
    $ret = $statement->fetchAll();

    if (empty($ret)) {
        return null;
    }

    $magazines = [];
    foreach ($ret as $v) {
        $magazines[$v['id']] = $v;
    }

    return $magazines;
}

/**
 * HTMLタグをエスケープ
 *
 * @param $value
 * @return string
 */
function escapeHtml($value) {
  return htmlspecialchars($value, ENT_QUOTES, 'EUC-JP');
}

/**
 * POST時処理
 *
 * @param mixed $frmEmail
 * @param mixed $frmMagazines
 * @param array $magazines
 * @return array $errorMessages
 * @throws PDOException
 */
function doPost($frmEmail, $frmMagazines, array $magazines) {
    $errorMessages = [];

    // 購読登録時、入力チェック
    if (empty($frmEmail) && strlen($frmEmail) == 0) {
        $errorMessages[] = 'メールアドレスを入力してください';
    } elseif (preg_match("/^[_\.\-]/", $frmEmail) || !preg_match("/(^[0-9A-Za-z_\.\-]+)@([^_\.\-])([0-9A-Za-z_\.\-]+)([^_\.\-])$/", $frmEmail)) {
        $errorMessages[] = 'メールアドレスの記入に誤りがあります';
    }

    // メールアドレス存在チェック
    $pdo = createPdo();
    $statement = $pdo->prepare('SELECT COUNT(*) FROM members WHERE lower(trim(email))=:email');
    $statement->execute([':email' => $frmEmail]);
    if ($statement->fetchColumn() > 0) {
        $errorMessages[] = 'すでにメールマガジン購読が完了しています';
        return $errorMessages;
    }

    // 購読メールマガジン入力チェック
    if (!is_array($frmMagazines)) {
        $errorMessages[] = '購読するメールマガジンを選択してください';
    } else {
        foreach ($frmMagazines as $id) {
            if (!isset($magazines[$id])) {
                $errorMessages[] = '購読するメールマガジンを選択してください';
                break;
            }
        }
    }

    if (!empty($errorMessages)) {
        return $errorMessages;
    }

    // 登録処理
    try {
        $now = new DateTime();

        $pdo->beginTransaction();

        $statement = $pdo->prepare('INSERT INTO members(email, created) VALUES(?, ?)');
        $statement->execute([$frmEmail, $now->format('Y-m-d H:i:s')]);
        $memberId = $pdo->lastInsertId('members_id_seq');

        $statement = $pdo->prepare('INSERT INTO member_magazines(member_id, magazine_id) VALUES(?, ?)');
        foreach ($frmMagazines as $magazineId) {
            $statement->execute([$memberId, $magazineId]);
        }

        $pdo->commit();

    } catch (\PDOException $e) {
        $pdo->rollback();
        $errorMessages[] = 'system error';
    }

    return $errorMessages;
}

/**
 * メイン処理
 */
$errorMessages = [];
$isComplete = false;
$frmEmail = null;
$frmMagazines = null;

$magazines = selectMailMagazines();
if (empty($magazines)) {
    $errorMessages[] = '購読できるメールマガジンがありません';
}

if (!empty($_POST)) {
    if (array_key_exists('frm_email', $_POST)) {
        $frmEmail = $_POST['frm_email'];
    }
    if (array_key_exists('frm_magazines', $_POST)) {
        $frmMagazines = $_POST['frm_magazines'];
    }
    $errorMessages = doPost($frmEmail, $frmMagazines, $magazines);

    if (empty($errorMessages)) {
        $isComplete = true;
    }
}

header('Content-Type: text/html; charset=EUC-JP');
?>
<html>
<head>
    <meta HTTP-EQUIV="CONTENT-TYPE" CONTENT="text/html;CHARSET=EUC-JP">
    <title></title>
</head>

<body>
<div align="center">
    <form method="POST" action="">

        <?php if (!empty($errorMessages)): ?>
            <br>
            <div align="center">
                <table border="0" cellpadding="5">
                    <tr>
                        <td>
                            <div style="color: #ff0000">
                                <?php foreach($errorMessages as $message): ?>
                                    <?php echo escapeHtml($message) ?><br>
                                <?php endforeach; ?>
                            </div>
                        </td>
                    </tr>
                    <tr>
                        <td align="center">
                            <input type="button" value="戻る" onclick="history.back()">
                        </td>
                    </tr>
                </table>
            </div>
        <?php else: ?>
            <table border="1" cellspacing="2" cellpadding="5" width="700">
                <tr>
                    <td>
                        <?php if ($isComplete): ?>
                            <div align="center">
                                <table border="0" cellspacing="2" cellpadding="5">
                                    <tr>
                                        <td align="center" colspan="2" nowrap>メールマガジン購読完了</td>
                                    </tr>
                                    <tr>
                                        <td align="center" nowrap>メールマガジン購読が完了しました。</td>
                                    </tr>
                                </table>
                            </div>
                        <?php else: ?>
                            <div align="center">
                                <table border="0" cellspacing="2" cellpadding="5">
                                    <tr>
                                        <td align="center" colspan="2" nowrap>メールマガジン購読</td>
                                    </tr>
                                    <tr>
                                        <td align="right" nowrap>email:</td>
                                        <td align="left" nowrap><input type="text" name="frm_email" size=30 maxlength=100 value="<?php echo escapeHtml($frmEmail) ?>"></td>
                                    </tr>
                                </table>
                            </div>
                            <?php if (!empty($magazines)): ?>
                                <?php if (count($magazines) == 1): ?>
                                    <input type="hidden" name="frm_magazines[]" value="<?php echo escapeHtml($magazines[0]['id']) ?>" />
                                <?php else: ?>
                                    <div>
                                        購読希望するマガジンを選択してください。
                                    </div>
                                    <table border="0" cellpadding="5">
                                        <?php foreach ($magazines as $magazine): ?>
                                            <?php $checked = isset($frmMagazines[$magazine['id']]) ? ' checked="checked"' : ''; ?>
                                            <tr>
                                                <td align="left">
                                                    <input id="magazine<?php echo escapeHtml($magazine['id']) ?>" type="checkbox" name="frm_magazines[]" value="<?php echo escapeHtml($magazine['id']) ?>"<?php echo $checked ?> />
                                                    <label for="magazine<?php echo escapeHtml($magazine['id']) ?>"><?php echo escapeHtml($magazine['name']) ?></label>
                                                </td>
                                            </tr>
                                        <?php endforeach; ?>
                                    </table>
                                <?php endif; ?>
                            <?php endif; ?>
                            <div align="center">
                                <input type="submit" value="購読">
                            </div>
                        <?php endif; ?>
                    </td>
                </tr>
            </table>
        <?php endif; ?>

    </form>
</div>
</body>
</html>

治療ポイント1. コードの分離

まず、コードの構成を、設定、ロジック、ビューの3段階に分離しました。こうして分離しておくことで、それぞれの役割が明確になり、見通しの良いコードとなります。また、別ファイルで同じ記述を共有したい場合(設定などは多くの場合そうなります⁠⁠、設定部分のみを別ファイルに切り出して、それぞれのPHPファイルでは設定ファイルをrequire_once文で読み込むということもやりやすくなります。

それぞれの部分について解説すると、設定ではdefine文を使って、設定値を定数として定義しています。こういった設定値はコードの中で変更するということはあまりないので、定数として定義しておくと「これは変更されない値だ」ということが明確になります。コードの中には数多くの値が登場するので、変更すべき値と変更しない値を区別するということも読みやすいコードを書くうえで大切になります。

ロジック部分では、まず関数定義を行い、そのあとにメインの処理を書いています。メイン処理は130行目から始まるのですが、わずか20行強と短くなっています。この中で、状態による分岐を行い、フォームからの投稿(POST)の場合は、doPost()関数を実行してます。元のコードでは、switch文にて条件分岐を行い、その中のcase文の中で実際の処理を記述していたのですが、ここでは条件分岐と実際の処理を分離することで、どのような状態があり、どのような流れになるのかがはっきりとわかるようになっています。

そして、最後がHTMLを出力するビューです。基本的にはHTMLの記述を行い、動的にHTMLの出力を行う個所のみ、必要なPHPを入れるようにしています。HTMLの出力はprint文をやめて、HTMLはそのままHTMLとして記述しています。元のコードよりかなり見やすくなったのではないでしょうか。

治療ポイント2. コードの簡素化

主にロジック部分になりますが、処理の見直しを行い、コードを簡素化していきました。

元のコードでは、$mode変数で実行する処理を分岐していたのですが、実際はこの$modeには2つの状態しかなく、それも「フォームを表示する場合」⁠フォームから値が送信されてくる場合」の2種類しかないため、$_POSTに値がない場合は前者、ある場合は後者として扱うことで$mode変数自体をなくしました。

ほかには、各処理を関数化することで、関数内に処理を閉じ込めています。たとえば、元のコードではswitch文とbreak文をうまく(?)組み合わせて、エラー発生時に処理から抜けるというコードが多数あったのですが、関数にしたことで、素直にreturn文で実装しています。

治療ポイント3. 今のPHPの書き方へ

2013年の今、このPHPコードを直すならということで、一部処理については、昨今のPHPでの書き方に変えています。

まずはDB処理について。今ではPDO(PHP Data Objects)を利用するのが一般的となっていますので、pg_*関数はPDOを使った書き方に変えています。SQL文を実行する際は、利便性とセキュリティを考慮してプリペアドステートメントを利用しています。これも今の定石と言ってよいでしょう。

ほかには、現在時刻を得るのにDateTimeクラスを、PDOでのエラーハンドリングにて例外を、また配列の定義にてarray()ではなく[](short array syntax)を利用しています。

治療ポイント4. Noticeへの対応

元のコードを実行すると、未定義定数の参照や連想配列に存在しないキーを参照したためにNoticeエラーが多数発生しています。Noticeエラーは警告が表示されるだけで、実行には影響がないのですが、こうした警告を無視することは、せっかくPHPが発した情報を無視することになり、本来修正すべき点を見落とすことになります。

修正コードでは、定数はちゃんとdefine文について定義し、配列ではarray_key_exists()関数にてキーの存在を確認してから参照するようにしています。

治療ポイント5. セキュリティへの配慮

SQLインジェクションやクロスサイトスクリプティング(XSS)といった攻撃に対する対応を行っています。SQLインジェクションに対してはプリペアードステートメントの利用、XSSに対してはhtmlspecialchars()関数によるエスケープを行っています。htmlspecialchars()関数の実行はビューの中では何度となく出てくるので、escapeHtml()関数にまとめてしまって、変数を出力する場面ではこの関数を呼ぶようにしています。

まとめ

第1回と第2回では、2002年とかなり古いPHPコードについて見てきました。2013年の今では新規でこういったコードを書くということはないと思うのですが、まだまだひっそりと稼働していて、いつこういったコードにお目にかかるやもしれません。

このコードはある意味すごいです。関数にまとめたり、ロジックとビューの分離を行わずによくこのコードを書いたと感心します。このコードを読んですぐに動作イメージを描けるのならば、それはそれですごいのですが、誰もが理解しやすいコードにしておくことで、不具合の発見も気づきやすく、変更にも強いコードとなります。

修正コードはあくまで一例で、まだまだ手を加えていくことができます。特にビューについては本来別画面となる内容(フォーム画面と完了画面)を1ファイルに混在させているので、見通しが悪くなっています。ほかにも以下に挙げるように、手を入れるべき個所はいくつかあるのですが、それらは読者への課題としてみたいと思います。

  • フォーム画面と完了画面とでファイルを分ける
  • 設定、ロジック、ビューでファイルを分ける
  • ライブラリやフレームワークの活用

初めての診断はいかがだったでしょうか。今後もこのような流れで、巷にある悩めるPHPコードを診断していきます。あなたの周りにあるPHPコードの投稿もお待ちしています。

おすすめ記事

記事・ニュース一覧