# 投稿内容を DB へ保存する

投稿用フォームからデータベースに投稿データを保存できる状態を目指します。

# 本セクションの流れ

  1. DB に接続する処理を実装する。
  2. 投稿したら DB へ保存する処理を実装する。
  3. insert_message.php の処理について。

# 1. DB に接続する処理を実装する。

ユーザが投稿したデータを実際にデータベースに保存するために、データベース接続を行うための処理を実装していきます。

~~/src ディレクトリと、~~/src/db_connect.php となるファイルを作成し以下のコードを記述します。

<?php
try {
  if (getenv('JAWSDB_URL')) {
    // Heorku (JawsDB)
    $_url = parse_url(getenv('JAWSDB_URL'));
    $dbHost = $_url['host'];
    $dbPort = $_url['port'];
    $dbName = ltrim($_url['path'], '/');
    $dbUser = $_url['user'];
    $dbPass = $_url['pass'];
  } else {
    // Other (Local development)
    $dbHost = getenv('DB_HOST');
    $dbPort = getenv('DB_PORT');
    $dbName = getenv('DB_NAME');
    $dbUser = getenv('DB_USER');
    $dbPass = getenv('DB_PASS');
  }
  $dsn = "mysql:dbname=$dbName;host=$dbHost:$dbPort";
  $dbh = new PDO($dsn, $dbUser, $dbPass);
} catch (PDOException $e) {
  echo 'データベース接続失敗:';
  echo $e->getMessage();
  exit();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

PHP のコードの始まりには必ず<?phpを書きます。(非推奨ですが、開始タグを <? のように記述することも可能です。また、ファイルの最後の終了タグは省略することが推奨されています。 終了タグの後に余分な空白や改行があると、予期せぬ挙動を引き起こす場合があるからです。)

PHP はファイルを解析して開始タグと終了タグ (<?php と ?>) を探します。 タグが見つかると、PHP はコードの実行を開始したり終了したりします。 このような仕組みにより、PHP を他のあらゆる形式のドキュメント中に 埋め込むことができるのです。つまり、開始タグと終了タグで囲まれている 箇所以外のすべての部分は、PHP パーサに無視されます。

引用:PHP 公式ドキュメント_タグ (opens new window)

# try-catch

try-catchはロジック中で生まれた例外(=想定外の挙動)を検知し、例外発生時の処理を書くことができます。

try {
    ロジック(処理)// 今回はデータベースへの接続処理を書いている
} catch (例外クラス名 変数) {
    例外処理 // 今回は接続失敗の時にエラーメッセージを表示する処理を書いている
}
1
2
3
4
5

今回実現したい処理は PHP からデータベースへの接続であるため、tryの中には以下のコードを記載しています。

  if (getenv('JAWSDB_URL')) {
  // Heorku (JawsDB)
  $_url = parse_url(getenv('JAWSDB_URL'));
    $dbHost = $_url['host'];
    $dbPort = $_url['port'];
    $dbName = ltrim($_url['path'], '/');
    $dbUser = $_url['user'];
    $dbPass = $_url['pass'];
  } else {
    // Other (Local development)
    $dbHost = getenv('DB_HOST');
    $dbPort = getenv('DB_PORT');
    $dbName = getenv('DB_NAME');
    $dbUser = getenv('DB_USER');
    $dbPass = getenv('DB_PASS');
  }
  $dsn = "mysql:dbname=$dbName;host=$dbHost:$dbPort";
  $dbh = new PDO($dsn, $dbUser, $dbPass);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

本番環境では Heorku にデプロイする際に JawsDB(Heroku 上で動く MySQL の 1 つ) を用います。 上記 if 文の 1 つ目の条件(3-8 行目)では、その JawsDB へ接続する際に必要なパラメータを各変数($dbHost$dbPort$dbName$dbUser$dbPass)に格納しています。 それぞれの変数は、URL をパースした連想配列である$_urlから値を取得しています。

本番環境と開発環境の違い

本番環境とは、ユーザーが実際にシステムを使う環境のことです。(デプロイされた環境)それに対し、システム開発を行う環境を「開発環境」といいます。 上記のコードように、実際の開発では、本番環境と開発環境で、設定を切り替えて開発を行うための実装をすることが多いです。

else 以降の条件(11-15 行目)では、開発環境において、 MySQL へ接続する際に必要なパラメータを各変数($dbHost$dbPort$dbName$dbUser$dbPass)に格納しています。 このプロジェクトでは、~/docker-compose.ymlに環境変数の設定を行っており、getenvを使って環境変数の値を取得しそれぞれの変数に格納しています。

環境変数とは

環境変数(environment variable)とは、コンピューターの基本動作や OS(オペレーティングシステム)の実行環境に関する設定データを格納した変数のことです。PHP に固有の枠組みではありません。アプリケーションプログラムでも、基本的な実行環境に関する設定情報を格納するために定義している場合があります。

PHP では、環境変数を利用する用途として以下のようなものがあります。

・データベースのアクセスキーや API のキーなどのような開示制限が必要な情報の保存
・環境設定の切り替え用フラグなど、環境ごとに変更が必要な情報の保存

if 文の中で DB 接続に必要な値を取得できたので、接続処理を行っています。

  $dsn = "mysql:dbname=$dbName;host=$dbHost:$dbPort";
  $dbh = new PDO($dsn, $dbUser, $dbPass);
1
2

2 行目で使用されているPDO「PHP Data Object」は、PHP 標準(5.1.0 以降)のデータベース接続クラスです。

PHP は標準で MySQL や PostgreSQL や SQLite など、色々なデータベースに接続するための関数が用意されています。

しかしデータベースの種類によって、接続方法を設定した場合、もし将来的に違うデータベースへの変更や移植もする場合には、全て書き換えなくてはいけません。

PDO を使えば、データベースのアクセス方法を抽象化したように、どのデータベースを利用する場合でもパラメータを変えるだけで、同じ関数を使うことができます。

PDO を用いた MySQL への接続例

// 例
<?php
$dbh = new PDO('mysql:host=localhost;dbname=test', $user, $pass);
?>
1
2
3
4

接続時になんらかのエラーが発生した場合、PDOException オブジェクトがスローされます。エラー処理を行いたい場合はこの例外を キャッチします。

PDO クラスに以下の引数を渡すことで PDO インスタンスを生成しています。

  • 第一引数:データソース名
    mysql:host=[データベースサーバーのホスト名]:dbname=[データベース名]の形式で記述

  • 第二引数:データベースのユーザー名

  • 第三引数:ユーザーのパスワード

引用:PHP 公式ドキュメント_PDO (opens new window)

上記の例のように、今回のコードでも同様に変数$dsn(変数名は任意だが、一般的に DSN「Data Source Name」:データベースとの接続を確立する際の設定情報 と同じ名前をつけています)に$dbName$dbHost$dbPortをつなげた文字列を格納。

変数$dsn(こちらも一般的に、データベースを扱う変数という意味でDBH = データベース ハンドルから命名しています。)が生成されたインスタンスになります。

# catch 以降(例外処理)

catch (PDOException $e) {
  echo 'データベース接続失敗:';
  echo $e->getMessage();
  exit();
}
1
2
3
4
5

例外の情報は PDOException に続けて書いた変数に格納されます。(今回の場合は $e

格納されたエラーメッセージを表示したい場合は$e->getMessage()とします。

ここではechoを使って、文字列を出力することでエラーが発生している事がわかるようにしています。

エラー発生時の画面

スクリーンショット 2022-07-23 18 25 51

echo と print の違い

どちらも文字列を出力する際に利用されます。 どちらも関数ではなく言語構造であるため、引数を括弧で括る必要はありません。ただ関数のように括弧で括って記述することも出来ます。 (関数と混同しないために下記のサンプルコードのように括弧()を付けずに利用するのがおすすめです。)

形式的に以下のような違いがあります。

  • echoは文ですがprintは式です
  • echoは複数の引数をとりますが、printは 1 つの引数をとります
  • <?php echo<?=のように省略して書くことができます。
// 複数出力できる
echo "aaa", "bbb", "ccc";

// 1つしか出力できない
print "aaa";
1
2
3
4
5

どちらを使えば良いのか...

echoは使い勝手が良いため、特に理由が無ければprintよりもechoを使う方が良いでしょう。

最後にexit()によって、現在のプログラムを終了させています。

このようにループの中や、条件によってプログラムを明確に終了させたいとき等に、exit が使用されることがあります。

exit('プログラムを終了します');のようにして、文字列を渡すと、その文字列を出力して処理を終了します。

例外処理の使い道

例外が実装されている言語ではエラーハンドリングに例外を使うのが一般的です。 エラーハンドリングというのは「エラーが発生した時の振る舞い方の定義」です。今回のようにデータベースへの接続処理ではエラーが発生し、意図しない挙動が生じうるため、エラーハンドリングとしてメッセージを表示し、実行を終了させるという例外処理を挟んでいます。

# 2. 投稿したら DB へ保存する処理を実装する。

~~/public/index.php を編集。

require_once(__DIR__ . '/../src/db_connect.php');

if (isset($_POST['action_type']) && $_POST['action_type']) {
  if ($_POST['action_type'] === 'insert') {
    require(__DIR__ . '/../src/insert_message.php');
  }
}
1
2
3
4
5
6
7

# PHP の処理

require_once(__DIR__ . '/../src/db_connect.php');

if (isset($_POST['action_type']) && $_POST['action_type']) {
  if ($_POST['action_type'] === 'insert') {
    require(__DIR__ . '/../src/insert_message.php');
  }
}
1
2
3
4
5
6
7
require_once(__DIR__ . '/../src/db_connect.php');
1

データベースへの接続を行うために、接続処理が記述されたdb_connect.phpを読み込みます。

PHP では、ライブラリや他の PHP ファイルを読み込み利用する時に、requireが用いられます。

require "ファイルのパス";
require("ファイルのパス");
1
2

require は上記のように2通りの書き方ができます。ファイルパスは、「絶対パス」「相対パス」のどちらでも指定することができます。

require_onceは、require と同じように使うことができますが、ファイルがすでに読み込まれている場合は再読み込みをしません。

ライブラリや設定ファイルなどが意図せずに2回 require されることによって、関数の再定義エラーや変数の書き換えなどが起こってしまうのを防ぐことができます。

今回は、そのファイルの存在するディレクトリを取得できる定数__DIR__を用いて、「絶対パス」を指定しています。

  • 3-7 行目
if (isset($_POST['action_type']) && $_POST['action_type']) {
  if ($_POST['action_type'] === 'insert') {
    require(__DIR__ . '/../src/insert_message.php');
  }
}
1
2
3
4
5

投稿内容入力フォームに対応する form 内のinputname="action_type"と指定します。

<input type="hidden" name="action_type" value="insert" />
1

そのため、投稿するボタンを押したら、$_POST[action_type]valueで指定した値であるinsertが格納されます。

$_GET$_POST は PHP であらかじめ定義されている変数で HTTP の通信を行う際にメソッドに対応する変数に値が格納されます。

引用:PHP 公式ドキュメント_定義済の変数 (opens new window)

1 つ目の if 文の条件にある、 isset($_POST['action_type']) では isset を用いて、変数が存在するかどうかを確認しています。

$POST['action_type'] では、空文字も含めてなにかしらの値が入ってるかどうかを確認しています。

論理積 && であるため、上記 2 つの条件が共に true の場合に true を返します。

仮に右側の条件のみだと、定義されていない変数に対して評価を行うためエラーが表示されてしまいます。そのため、isset を用いて、変数が存在するかどうかを先に確認しています。先に右が評価されて、false の場合は if 文の内容の処理は実行されません。

2 つ目の if 文で$_POST['action_type']の値を確認し、"insert"と一致した場合に insert_message.php を相対パスで読み込んで、投稿処理を実行させます。

PHP における`==`と`===`の違い

== は、型の相互変換をした後で $a が $b に等しい時に true を返します。

=== は、$a が $b に等しく、および同じ型である場合に true 。つまり、「データ型」まで含めた完全一致かを判定しています。

$a = 123;
$b = "123";
var_dump($a == $b);
var_dump($a === $b);
1
2
3
4

実行結果

bool(true)
bool(false)
1
2

モックアップの表示箇所のコードを削除

ここで、 雛形を作成する で作成したモックアップの表示箇所のコードは削除します。

モックアップ表示箇所 ~~/public/index.php







































 
 
 
 
 
 
 
 
 
 
 
 
 







~~

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="robots" content="noindex" />
    <title>ひとこと掲示板</title>
    <link rel="stylesheet" href="./assets/main.css" />
  </head>

  <body>
    <div class="page-cover">
      <p class="page-title">ひとこと掲示板</p>
      <hr class="page-divider" />
      <div class="form-cover">
        <form action="/" method="post">
          <div class="form-input-title">投稿者ニックネーム</div>
          <input
            type="text"
            name="author_name"
            maxlength="40"
            value=""
            class="input-author-name"
          />
          <div class="form-input-error"></div>
          <div class="form-input-title">投稿内容<small>(必須)</small></div>
          <textarea name="message" class="input-message"></textarea>
          <div class="form-input-error"></div>
          <input type="hidden" name="action_type" value="insert" />
          <button type="submit" class="input-submit-button">投稿する</button>
        </form>
      </div>
      <hr class="page-divider" />
      <div class="message-list-cover">
        <small> 1 件の投稿 </small>
        <div class="message-item">
          <div class="message-title">
            <div>イチロー</div>
            <small>2022-01-01 00:00:00</small>
            <div class="spacer"></div>
            <form action="/" method="post" style="text-align:right">
              <input type="hidden" name="id" value="" />
              <input type="hidden" name="action_type" value="delete" />
              <button type="submit" class="message-delete-button">削除</button>
            </form>
          </div>
          <p class="message-line">明けましておめでとうございます</p>
        </div>
      </div>
    </div>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

モックアップ削除後の ~~/public/index.php

~~

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="robots" content="noindex" />
    <title>ひとこと掲示板</title>
    <link rel="stylesheet" href="./assets/main.css" />
  </head>

  <body>
    <div class="page-cover">
      <p class="page-title">ひとこと掲示板</p>
      <hr class="page-divider" />
      <div class="form-cover">
        <form action="/" method="post">
          <div class="form-input-title">投稿者ニックネーム</div>
          <input
            type="text"
            name="author_name"
            maxlength="40"
            value=""
            class="input-author-name"
          />
          <div class="form-input-error"></div>
          <div class="form-input-title">投稿内容<small>(必須)</small></div>
          <textarea name="message" class="input-message"></textarea>
          <div class="form-input-error"></div>
          <input type="hidden" name="action_type" value="insert" />
          <button type="submit" class="input-submit-button">投稿する</button>
        </form>
      </div>
      <hr class="page-divider" />
      <div class="message-list-cover"></div>
    </div>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

モックアップ削除後表示

ひとことを投稿するフォーム

# 3. insert_message.phpの処理について

~~/public/index.php から読み込まれる ~~/src/insert_message.php を作成し、ユーザの入力情報をデータベースへ insert する処理を実装していきます。

<?php

/**
 * 両端の空白を除去する関数です。マルチバイトを含みます。
 * 参考 https://qiita.com/fallout/items/a13cebb07015d421fde3
 */
function mbTrim($pString)
{
    return preg_replace('/\A[\p{Cc}\p{Cf}\p{Z}]++|[\p{Cc}\p{Cf}\p{Z}]++\z/u', '', $pString);
}

// 入力値を確認する(投稿者)
$is_valid_auther_name = true;
$input_author_name = '';
if (isset($_POST['author_name'])) {
    $input_author_name = mbTrim(str_replace("\r\n", "\n", $_POST['author_name']));
} else {
    $is_valid_auther_name = false;
}

if ($is_valid_auther_name && mb_strlen($input_author_name) > 30) {
    $is_valid_auther_name = false;
}

// 入力値を確認する(投稿内容)
$is_valid_message = true;
$input_message = '';
if (isset($_POST['message'])) {
    $input_message = mbTrim(str_replace("\r\n", "\n", $_POST['message']));
} else {
    $is_valid_message = false;
}

if ($is_valid_message && $input_message === '') {
    $is_valid_message = false;
}

if ($is_valid_message && mb_strlen($input_message) > 1000) {
    $is_valid_message = false;
}

// 投稿をデータベースへ保存する処理
if ($is_valid_auther_name && $is_valid_message) {
    if ($input_author_name === '') {
        $input_author_name = '匿名さん';
    }

    // INSERT クエリを作成する
    // :author_name、:message はプレースホルダという。後で $stmt->bindValue を使用して値をセットするときのニックネームのようなもの。自分で決められる。
    $query = 'INSERT INTO posts (author_name, message) VALUES (:author_name, :message)';

    // SQL 実行の準備 (実行はされない)
    $stmt = $dbh->prepare($query);

    // プレースホルダに値をセットする
    $stmt->bindValue(':author_name', $input_author_name, PDO::PARAM_STR);
    $stmt->bindValue(':message', $input_message, PDO::PARAM_STR);

    // クエリを実行する
    $stmt->execute();
}

header('Location: /');
exit();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
  • 7-10 行目
function mbTrim($pString)
{
  return preg_replace('/\A[\p{Cc}\p{Cf}\p{Z}]++|[\p{Cc}\p{Cf}\p{Z}]++\z/u', '', $pString);
}
1
2
3
4

正規表現を用いて、両端の空白を除去する処理をしています。全角スペース等のマルチバイトにも対応しています。

preg_replaceは正規表現検索および置換を行う関数です。

PHP 公式ドキュメント_preg_replace (opens new window)

  • 13-19 行目
// 入力値を確認する(投稿者)
$is_valid_auther_name = true;
$input_author_name = '';
if (isset($_POST['author_name'])) {
  $input_author_name = mbTrim(str_replace("\r\n", "\n", $_POST['author_name']));
} else {
  $is_valid_auther_name = false;
}
1
2
3
4
5
6
7
8

ユーザが入力した投稿者名の文字列をチェックし、データの整形を行っています。

$is_valid_auther_nameには、文字列のチェック、整形が完了しているか否かを表すフラグ(true か false)が格納されます。 (isから始まる変数名は、正負の値が入る boolean 型であることが望ましいです。)

$input_author_name には、チェック、整形が完了し、最終的に投稿者名としてサーバへ送信される文字列が格納されます。

if 文の中では、変数$_POST['author_name']が存在する場合に、整形された文字列を $input_author_name に格納します。 ここでは、str_replace を用いて、OS によって異なる改行コードを統一しており、mbTrim では両端の空白を除去する処理をしています。

PHP 公式ドキュメント_str_replace (opens new window)

if 文の次の条件である変数$_POST['author_name']が存在する場合は、文字列のチェック、整形が完了していないため$is_valid_auther_name = false;とします。

  • 21-23 行目
if ($is_valid_auther_name && mb_strlen($input_author_name) > 30) {
  $is_valid_auther_name = false;
}
1
2
3

文字列のチェック、整形が完了しているかつ、整形された投稿者名文字列が 30 文字より大きい場合に $is_valid_auther_name = false; とします。

このように、入力データが適切に記述されているかどうかを検証することをバリデーションといいます。

PHP 公式ドキュメント_mb_strlen (opens new window)

バリデーションを行う理由

フォームに入力する値が必ずしも適切な値であるとは限りません。バリデーションを行うことで、正しい形式のデータのみを受け入れ可能にすることができます。 セキュリティの観点からもバリデーションは行うべきでしょう。 入力値のバリデーションでチェックされる項目例には以下のようなものがあります。

  • 必須項目があるか
  • 正しいデータ形式か
  • 不正な内容が含まれているか
  • 25-32 行目
// 入力値を確認する(投稿内容)
$is_valid_message = true;
$input_message = '';
if (isset($_POST['message'])) {
  $input_message = mbTrim(str_replace("\r\n", "\n", $_POST['message']));
} else {
  $is_valid_message = false;
}
1
2
3
4
5
6
7
8

投稿者名と同様に、入力された投稿内容の整形を行います。

  • 34-40 行目
if ($is_valid_message && $input_message === '') {
  $is_valid_message = false;
}

if ($is_valid_message && mb_strlen($input_message) > 1000) {
  $is_valid_message = false;
}
1
2
3
4
5
6
7

投稿者名のバリデーションと同様に、投稿内容が空文字の場合と、1000 文字より大きい場合に投稿内容のチェックフラグ $is_valid_message = false; とします。

  • 42-64 行目
// 投稿をデータベースへ保存する処理
if ($is_valid_auther_name && $is_valid_message) {
  if ($input_author_name === '') {
    $input_author_name = '匿名さん';
  }

  // INSERT クエリを作成する
  // :author_name、:message はプレースホルダという。後で $stmt->bindValue を使用して値をセットするときのニックネームのようなもの。自分で決められる。
  $query = 'INSERT INTO posts (author_name, message) VALUES (:author_name, :message)';

  // SQL 実行の準備 (実行はされない)
  $stmt = $dbh->prepare($query);

  // プレースホルダに値をセットする
  $stmt->bindValue(':author_name', $input_author_name, PDO::PARAM_STR);
  $stmt->bindValue(':message', $input_message, PDO::PARAM_STR);

  // クエリを実行する
  $stmt->execute();
}

header('Location: /');
exit();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

投稿をデータベースへ insert していきます。

if ($is_valid_auther_name && $is_valid_message) {
  if ($input_author_name === '') {
    $input_author_name = '匿名さん';
  }
1
2
3
4

投稿者名、投稿内容ともに、バリデーションが完了しており、投稿者名が空文字である場合は、「匿名さん」としています。

  // INSERT クエリを作成する
  // :author_name、:message はプレースホルダという。後で $stmt->bindValue を使用して値をセットするときのニックネームのようなもの。自分で決められる。
  $query = 'INSERT INTO posts (author_name, message) VALUES (:author_name, :message)';
1
2
3

データベースへデータを追加するために INSERT を行うためのクエリを作成しています。

(INSERT 文) テーブルに新しいデータを追加する

テーブルに含まれる特定のカラムを指定してデータを追加する場合は次のように記述します。カラム名の数と値の数は同じでなくてはなりません。

INSERT INTO tbl_name (カラム名1, カラム名2, ...) VALUES (value1, value2, ...);
1

ここでは、投稿されたデータが保存されるpostsテーブルの author_name, message カラムにデータを挿入するクエリを作成しています。

クエリにはプレースホルダを用いており、SQL 文の変数部分を「:」で始まる文字列で指定して記述しています。

プレースホルダを使ってバインド(後述する bindValue によって)すると、クエリとして解釈されず、あくまで「値」として扱われるため、変なものを注入した場合でもクエリとして実行されません。

このように、SQL 文の中に直接 PHP の変数を書かないことで、SQL インジェクションを防ぐことができます。

SQL インジェクションとは

開発者の想定をしていない SQL を組み立てて、データベースに対して実行させようとする攻撃方法です。

例えば、以下のような SQL を実行する PHP プログラムがあったとします。 「users」というテーブルにある「name」カラムから、ユーザーがフォームに入力した入力情報($name)と一致したデータを取り出すという内容です。

$query = "SELECT * FROM users WHERE name = '$name'";
1

ここで、悪意を持ったユーザが';DELETE FROM user--とフォームに入力した場合、以下のような SQL が作成できてしまいます。

$query = "SELECT * FROM users WHERE name = '';DELETE FROM users--";
1

この SQL は「;」によって SQL 文を終了させて、users テーブルから全テーブルを削除する(DELETE FROM users)といった内容になります。

末尾の「--」は SQL 上のコメントという意味なので、それ以降の文は無視されます。

これが SQL インジェクション(不正な SQL を「injection:注入」すること) です。ユーザー入力が伴う箇所は、SQL 文の中に直接 php の変数を書かずにデータベースに登録する仕組みが必要となります。

  // SQL 実行の準備 (実行はされない)
  $stmt = $dbh->prepare($query);
1
2

prepareとは、PDO で使用できるメソッドの 1 つで、SQL 文をセットして実行準備を行います。

prepare メソッドはプリペアドステートメントと呼ばれるものを利用するための関数です。 プリペアドステートメントとは、SQL 文を最初に用意しておいて、その後はクエリ内のパラメータの値だけを変更してクエリを実行できる機能のことです。 この機能を利用することでクエリの解析やコンパイル等にかかる時間は最初の一回だけで良くなり、より高速に実行することができます。

「SQL 文だけ作っておいて、値(変数など)を後から当てはめるイメージです。

PHP 公式ドキュメント_PDO::prepare (opens new window)

// プレースホルダに値をセットする
  $stmt->bindValue(':author_name', $input_author_name, PDO::PARAM_STR);
  $stmt->bindValue(':message', $input_message, PDO::PARAM_STR);

  // クエリを実行する
  $stmt->execute();
1
2
3
4
5
6

bindvalueを使用することで、プレースホルダーに値をバインドしています。 それぞれの引数の詳細は以下に示します。

  • param(第 1 引数)

    パラメータ ID。名前つきプレースホルダを使用する プリペアドステートメントの場合は、 :name 形式のパラメータ名となります。 疑問符プレースホルダを使用するプリペアドステートメントの場合は、 1 から始まるパラメータの位置となります。

  • value(第 2 引数)

    パラメータにバインドする値。

  • type(第 3 引数)

    パラメータに対して PDO::PARAM_* 定数 を使った明示的なデータ型を指定します。デフォルトでは、「PDO::PARAM_STR」がセットされています。今回は、文字列であるため、PDO::PARAM_STRを指定しています。

引用:PDOStatement::bindValue (opens new window)

最後に、execute()でクエリを実行しています。

header('Location: /');
exit();
1
2

header 関数は、HTTP ヘッダを送信する関数ですが、header(‘Location: 遷移先のURL’)とすることでリダイレクト処理を書くことができます。 リダイレクトは他にも、ログインチェックでエラーになった場合に、ログイン画面に遷移させるときによく使われます。

最後に、exitで処理を終了しています。 これは、ページがリダイレクトされた後に、PHP の処理が次に進んでしまうのを防ぐためです。

# 動作確認

フォームに適当な文字を入力して、「投稿する」ボタンを入力します。

スクリーンショット 2022-07-24 15 24 39

phpMyAdmin で posts テーブルを選択して、レコードを確認すると、無事 insert が完了していることが確認できます。

18

「投稿者ニックネーム」が未入力の場合は「匿名さん」としてデータが保存されることも確認します。

スクリーンショット 2022-07-24 15 34 57

19

# 最終的なコードとファイル構成

~~/public/index.php

<?php
require_once(__DIR__ . '/../src/db_connect.php');

if (isset($_POST['action_type']) && $_POST['action_type']) {
  if ($_POST['action_type'] === 'insert') {
    require(__DIR__ . '/../src/insert_message.php');
  }
}
?>
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex" />
  <title>ひとこと掲示板</title>
  <link rel="stylesheet" href="./assets/main.css" />
</head>

<body>
  <div class="page-cover">
    <p class="page-title">ひとこと掲示板</p>
    <hr class="page-divider" />
    <div class="form-cover">
      <form action="/" method="post">
        <div class="form-input-title">投稿者ニックネーム</div>
        <input type="text" name="author_name" maxlength="40" value="" class="input-author-name" />
        <div class="form-input-error">
        </div>
        <div class="form-input-title">投稿内容<small>(必須)</small></div>
        <textarea name="message" class="input-message"></textarea>
        <div class="form-input-error">
        </div>
        <input type="hidden" name="action_type" value="insert" />
        <button type="submit" class="input-submit-button">投稿する</button>
      </form>
    </div>
    <hr class="page-divider" />
    <div class="message-list-cover">
    </div>
  </div>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

~~/src/insert_message.php

<?php

/**
 * 両端の空白を除去する関数です。マルチバイトを含みます。
 * 参考 https://qiita.com/fallout/items/a13cebb07015d421fde3
 */
function mbTrim($pString)
{
    return preg_replace('/\A[\p{Cc}\p{Cf}\p{Z}]++|[\p{Cc}\p{Cf}\p{Z}]++\z/u', '', $pString);
}

// 入力値を確認する(投稿者)
$is_valid_auther_name = true;
$input_author_name = '';
if (isset($_POST['author_name'])) {
    $input_author_name = mbTrim(str_replace("\r\n", "\n", $_POST['author_name']));
} else {
    $is_valid_auther_name = false;
}

if ($is_valid_auther_name && mb_strlen($input_author_name) > 30) {
    $is_valid_auther_name = false;
}

// 入力値を確認する(投稿内容)
$is_valid_message = true;
$input_message = '';
if (isset($_POST['message'])) {
    $input_message = mbTrim(str_replace("\r\n", "\n", $_POST['message']));
} else {
    $is_valid_message = false;
}

if ($is_valid_message && $input_message === '') {
    $is_valid_message = false;
}

if ($is_valid_message && mb_strlen($input_message) > 1000) {
    $is_valid_message = false;
}

// 投稿をデータベースへ保存する処理
if ($is_valid_auther_name && $is_valid_message) {
    if ($input_author_name === '') {
        $input_author_name = '匿名さん';
    }

    // INSERT クエリを作成する
    // :author_name、:message はプレースホルダという。後で $stmt->bindValue を使用して値をセットするときのニックネームのようなもの。自分で決められる。
    $query = 'INSERT INTO posts (author_name, message) VALUES (:author_name, :message)';

    // SQL 実行の準備 (実行はされない)
    $stmt = $dbh->prepare($query);

    // プレースホルダに値をセットする
    $stmt->bindValue(':author_name', $input_author_name, PDO::PARAM_STR);
    $stmt->bindValue(':message', $input_message, PDO::PARAM_STR);

    // クエリを実行する
    $stmt->execute();
}

header('Location: /');
exit();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

~~/src/db_connect.php

<?php
try {
  if (getenv('JAWSDB_URL')) {
    // Heorku (JawsDB)
    $_url = parse_url(getenv('JAWSDB_URL'));
    $dbHost = $_url['host'];
    $dbPort = $_url['port'];
    $dbName = ltrim($_url['path'], '/');
    $dbUser = $_url['user'];
    $dbPass = $_url['pass'];
  } else {
    // Other (Local development)
    $dbHost = getenv('DB_HOST');
    $dbPort = getenv('DB_PORT');
    $dbName = getenv('DB_NAME');
    $dbUser = getenv('DB_USER');
    $dbPass = getenv('DB_PASS');
  }
  $dsn = "mysql:dbname=$dbName;host=$dbHost:$dbPort";
  $dbh = new PDO($dsn, $dbUser, $dbPass);
} catch (PDOException $e) {
  echo 'データベース接続失敗:';
  echo $e->getMessage();
  exit();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

ファイル構成

.
├── docker-compose.yml
├── php
│   └── Dockerfile
├── public
│   ├── assets
│   │   └── main.css
│   └── index.php
└── src
    ├── db_connect.php
    └── insert_message.php
1
2
3
4
5
6
7
8
9
10
11