20201026のPHPに関する記事は26件です。

【Laravel】bladeで @(アットマーク)を文字列として出すにはどうすればよかったっけ

こういう名前の表示がしたい

@antk

なぜできないのか

bladeで@(アットマーク)を付けると @if@foreach といったショートカットとして認識されてしまう。なので

<div>@{{ $user_name }}</div>

このように書いてもうまくいかない。

<div>{{ @$user_name }}</div>

これもダメ。

<div>@ {{ $user_name }}</div>

空白を入れるとエラーは出ないが空白が出てしまう。
@ antk

解決

<div>{{ '@'.$user_name }}</div>

二重括弧(エスケープ)の中で文字列と変数を .で接続する。
. 無しだとエラーになります。

こんな超基礎的なことでつまづいた(;´Д`)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Youtuberが配信・動画投稿したらメールにて通知するプログラムを作ってみた

はじめに

私は結構Youtubeを見るようになり、好きなYoutuberから動画投稿の通知があれば気になって視聴していました。
しかし、ある日Youtubeからのメール通知がなくなっていたことに気づきました。

Youtubeがメール通知機能を2020年8月13日で終了

下記の通達通り、メール通知機能が終わってしまっていました...
https://support.google.com/youtube/thread/63268863?hl=ja

登録チャンネルの新しいアップロード動画に関するメール通知の変更

登録しているチャンネルの新しいアップロード、ライブ配信、プレミア公開に関する通知をメールで受け取る機能は、2020 年 8 月 13 日で終了します。この機能を設定している場合はご注意ください。モバイルの YouTube アプリまたはパソコンの Chrome ブラウザ経由の通知をオンにしている場合は、引き続き通知が届きます。

個人的にはメールから開いて動画を視聴することが多かったので、その機能が廃止されるとなると嫌になってしまいますので独自にメールで通知するプログラムを作成しました。

このプログラムでできること

  • 1つのプログラムにつき、1人のYoutuberの配信,動画投稿されたらプログラムに書かれてあるメールアドレス宛に動画URLが送信されるようになる。

  • crontabを使って定期的に投稿されたかチャンネルの投稿動画を確認する。

運用環境

環境
OS:CentOS7
プログラム言語:PHP7
データベース:MySQL
定期実行:crontab
サーバ実行環境:さくらVPS(安価なレンタルサーバでPHP7,cron,MySQLの機能が使える環境でもOKです。)
必要ツール:Youtube API
必要な情報:自分の持っているメールアドレス1〜2つ(現在も使用可能であること)

Youtube APIの登録・APIキーの取得

このプログラムを動かすためには、チャンネルの情報を取得する必要があるため、Youtube APIのAPIキーの取得が必須です。
以下の記事を参考にしてYoutube APIの登録を行いました。
https://blog.codecamp.jp/programming-api-youtube

最終的に、以下のようにAPIキーがコピーできる画面になりますので、赤枠の部分の情報をメモしてください
image.png

YoutubeのチャンネルページにあるURLを取得する

例としてHikakinTVさんのチャンネルIDを取得します。
チャンネルページにアクセスして、URLにある赤枠の部分の情報をメモしてください
image.png

MySQLでテーブル作成

MySQLを使い、現状の新しい動画があるか値を保存するデータベースとテーブルを作成します。
例として、CentOS7の端末上で操作しています。

データベース作成
mysql> CREATE DATABASE NEW_VIDEO_DATA;
mysql> use NEW_VIDEO_DATA
テーブル作成
mysql> create table new_video_check(videoid varchar(200) NOT NULL PRIMARY KEY, title varchar(200));
テーブル中身
mysql> describe new_video_check;
+---------+--------------+------+-----+---------+-------+
| Field   | Type         | Null | Key | Default | Extra |
+---------+--------------+------+-----+---------+-------+
| videoid | varchar(200) | NO   | PRI | NULL    |       |
| title   | varchar(200) | YES  |     | NULL    |       |
+---------+--------------+------+-----+---------+-------+

プログラム

PHPで作成したプログラムは以下の通りです。
コメントの部分の通りにメモした情報を記載してください。

newvideocheck.php
<?php

class NewVideoCheck {

    private function apikey() {
        /* メモしたAPIキーを記載 */
        $apikey = "XXXXXXXXXXXXXXXXX";
        return $apikey;
    }

    private function mail_send($videoid) {
        /* 送信元メールアドレスを記載(送信元,送信先のメールアドレスは同じでも良い) */
        $from = "hogehoge@yahoo.co.jp";

        mb_language("Japanese");
        mb_internal_encoding("UTF-8");

        /* 送信先メールアドレスを記載(送信元,送信先のメールアドレスは同じでも良い) */
        $to      = 'hogehoge@gmail.com';
        $subject = '新しい動画・配信されたよ!';
        $message = 'URL https://www.youtube.com/watch?v=' . $videoid;
        $headers = "From: ".$from."\r\n";
        /* メール送信 */
        mb_send_mail($to, $subject, $message, $headers);
    }


    public function mysql_connect() {
        /* Mysql接続(サーバのIPアドレス、ユーザ情報、パスワードを記載してDBにアクセス) */
        $IPADDR = isset($_SERVER['SERVER_ADDR'])?$_SERVER['SERVER_ADDR']:gethostbyname(gethostname());
        if($IPADDR == 'YYY.YYY.YYY.YYY'){
            $link = mysqli_connect('YYY.YYY.YYY.YYY','root','XXXXXXXX');
        }
        if(!$link){
            die("エラー:DBに接続できません");
        }

        /* テーブル選択 */
        $db_selected = mysqli_select_db($link, 'NEW_VIDEO_DATA');
        if (!$db_selected){
            die("エラー:テーブルに接続できません");
        }

        return $link;
    }

    public function video_info($link) {
        /* channelIdにはメモしたチャンネルIDを記載する */
        $view_info_url = "https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=UCZf__ehlCEBPop-_sldpBUQ&maxResults=1&order=date&type=video";
        /* YoutubeAPIキーを取得しURLに連結させる */    
        $view_info_url .= "&key=".$this->apikey();

        /* Youtube APIを使い動画の情報取得 */
        $json = file_get_contents($view_info_url);
        $json = mb_convert_encoding($json, 'UTF8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN');
        $arr = json_decode($json, true);
        $videoid = $arr["items"][0]["id"]["videoId"];
        $title = $arr["items"][0]["snippet"]["title"];
        $videoinfo[0] = $videoid;
        $videoinfo[1] = $title;

        /* テーブル内にある動画IDと比較し、違っていたらメールを送信 */
        $select_sql = "select * from new_video_check limit 1";
        $select = mysqli_query($link, $select_sql);
        $tbl = mysqli_fetch_array($select);
        $new_videoid = $tbl[0];
        if($videoid != $new_videoid) {
            $this->mail_send($videoid);
        }

        return $videoinfo; 
    }

    public function video_register($link, $videoinfo) {

        $delete_sql = "delete from new_video_check";
        $delete = mysqli_query($link, $delete_sql);

        $register_sql = "insert into new_video_check values('".$videoinfo[0]."', '".$videoinfo[1]."')";
        $register = mysqli_query($link, $register_sql);
    }
}

// Mysqlのデータベースへアクセスする
$newvideocheck = new NewVideoCheck();
$link = $newvideocheck->mysql_connect();

// チャンネル内の最新動画を調べる
$videoinfo = $newvideocheck->video_info($link);

// チャンネル内で最新動画あげたら最新動画を登録する
$newvideocheck->video_register($link, $videoinfo);

?>

cronを使って定期実行させる

作成したプログラムをサーバにアップロードさせてcronに定期実行させるようにします。
私の場合は1時間ごとにプログラムを動かすよう定期実行させています。

crontab
# 動画更新されたらメールで通知
00 */1 * * * /bin/php /tmp/batch/newvideocheck.php

動画投稿されたらメールが届くのを確認

以下の通りにHikakinTVのチャンネル内で動画投稿されたらメールで通知されました。
画像はGmailで確認しています。
image.png

問題点

1つのプログラムにつき1チャンネルの投稿を確認するので、複数のチャンネルでメール通知したいときはその分のデータベースとプログラムとcronへの記載が必要となる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel:419|PAGE EXPIREDエラーの解決方法

【概要】

1.結論

2.どのように記載していたか

3.そもそもcsrfとは何か

4.開発環境

1.結論

viewに@csrfを追記すればOK!

2.どのように記載していたか

hoge.blade.php
  <form action="/hoge/add" method="post">
  <table>
  @csrf #----❶
    <tr><th>name: </th><td><input type="text" name="name">
        </td></tr>
    <tr><th>mail: </th><td><input type="text" name="mail">
        </td></tr>
    <tr><th>age: </th><td><input type="text" name="age">
        </td></tr>
    <tr><th></th><td><input type="submit" name="send">
        </td></tr>
  </table>
  </form>

❶の"@csrf"の記載がないため、下記の画像のエラーになりました。
送信ボタンを押下してもコントローラーでトップページに戻るコーディングをしても(return redirect('/hoge'))トップページに遷移しませんでした。
スクリーンショット 2020-10-26 21.52.09.png

参考にさせていただいたURL
【Laravel5】たまに出てくる「the page has expired due to inactivity. please refresh and try again」を表示させない

3.そもそもcsrfとは何か

クロスサイトリクエストフォージェリの略で、Webアプリケーションの脆弱性を利用したサイバー攻撃になります。
ログインした利用のあるアカウントで攻撃用のスクリプトが仕込まれた罠サイトにアクセスすることで、csrf対策をしていないWebアプリケーションは勝手に第三者に弄られてしまいます。例えばSNSでコメントを投稿されたり通販サイトで商品を勝手に購入されたりといった被害があります。

4.開発環境

PHP 7.4.10
Laravel 8.9
Apache 2.4.41
Mysql 5.6.47
Sequl Pro 1.1.2

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

formタグは1つのまま、1つの要素クリックで複数の関連するnameだけを送信(post)させる方法

どうも7noteです。綺麗に書きたいがあまりちょっと無理やり作りました。

(※データを送信するだけならhtmlとjavascriptでできますが、受け取ってその後利用するにはphpの知識が必要です!)

例えば都道府県のように地域(関東とか)と都道府県名の2種類のデータを個別にpostさせたい時に使える方法です。

普通、複数のデータを送る時はinputのname属性を配列にする方法が一般的です。


一般的に複数のデータを送信する例)

<form action="./" method="post">
  <input type="checkbox" name="pref[]" value="tokyo">
  <input type="checkbox" name="pref[]" value="osaka" >
  <input type="submit" value="送信">
</form>

※ 受け取り側はpostを受け取れるようphpで作成してください。


この方法で、東京と大阪をpostすることはできましたが、同じ方法でエリアを送信してしまうと
pref['kanto', 'tokyo']のようにエリア名と都道府県名が1つの配列内に混ざって送信されてしまいます。

今回はこれを↓のように分けて送信する方法を書きます。

area ⇒ "kanto"
pref ⇒ "tokyo"

都道府県ごとに、エリアと都道府県情報をpostさせる方法

※jQueryを使用しています。

index.html
<form action="./" method="post" name="prefform">
  <input type="hidden" name="area" value="">
  <input type="hidden" name="pref" value="" >
</form>

<ul>
  <li area="kanto" pref="tokyo">東京</li>
  <li area="kanto" pref="kanagawa">神奈川</li>
  <li area="kansai" pref="osaka">大阪</li>
</ul>
script.js
$(function(){
  $("ul li").on("click", function(e){         // li要素がクリックされたとき
    area = $(this).attr("area");              // li要素のarea属性の値を取得
    pref = $(this).attr("pref");              // li要素のpref属性の値を取得
    $("#input_area").attr("value",area);      // inputのvalue値を変数areaに置き換え
    $("#input_pref").attr("value",pref);      // inputのvalue値を変数prefに置き換え
    document.forms.prefform.submit();         // submit(送信)する。
  });
});

とび先は、form要素のactionにURLやディレクトリを記述してください。

説明するほどのことはしていないのですが、クリックされた要素の属性値、それぞれprefとareaを取得します。
サブミット(送信)する前に、inputの中のvalue値に値を入れます。
値を入れた後、サブミットすることでエリアと都道府県をそれぞれ個別で送信することができます。
もしとび先の修正などが入った場合でも、formタグのactionを1箇所修正するだけ済むので、管理が楽になるかと。

まとめ

もっとよい方法や綺麗な書き方などあるかもしれませんが、ひとまずこれで十分の機能ははたせるかなと思います。
javascriptは使わず、htmlだけでもできなくはないですが、何度も何度もformタグやinputタグを書くとなるとソースが見にくく管理もしにくくなるので、何かしらで対応できるような作りの方がいいかなと思います。

おそまつ!

~ Qiitaで毎日投稿中!! ~
【初心者向け】HTML・CSSのちょいテク詰め合わせ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ラッコにはrenderAs()がわからない

こんばんわ、記事を投稿しない日はアルゴリズム問題を解いていますオッターです。

今回もよくわからなかった関数を調べてきました。
業務の中で

$this->RequestHandler->renderAs($this, 'json');

というコードを、apicontrollerの中のbeforeFilter()の中で見つけたんですよ。

renderAs()とは

我らがドキュメントさまはこうおっしゃっています。

renderAs(\Cake\Controller\Controller $controller, string $type, array $options)
ビュークラスが存在する場合はそれを設定するか、ビューのレイアウトとテンプレートパスを設定します。 これらの名前は、$ type入力パラメーターから派生しています。

MVCのViewだということはわかりますが、やはり具体的に何をしてくれるのかはわからない、、、

もう少し噛み砕いてみた。

ちょっと読みすすめると、以下のように書いてありました。

$this->RequestHandler->renderAs($this, 'ajax');
応答を「ajax」応答としてレンダリングします。

ほう?つまりレスポンスの形式を指定できる関数だ、というのがわかりますね。
では、こいつは?

$this->RequestHandler->renderAs($this, 'json');

json形式でHTTPレスポンスを返すようにしたということですね。

CakePHPのデフォルト設定では、ControllerはHTMLを返しますね。
でもそれだと、外部APIと連携してHTTP200をかえしたりできませんよね?
そのための、関数ってわけです。

あとは、このjsonなりajaxなりのbodyに必要なもの打ち込んで、関数を終了すればレスポンスをかえしてくれるってわけですね。

生わかりがないわけではないので、通信についてもっと勉強するぞ!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Heroku上にてPythonをPHP上で動かしてみた話

Qiita初投稿です。
今回は下手なところで3時間くらいつまずいた「Heroku上にてPythonをPHP上で動かす」方法を備忘録的に残したいと思います。
脳死で書いています。誤字脱字は温厚な目で見てくれると嬉しいです。

思い立ち

最初はWebページにスクレイピングの情報を掲載できればいいかなーとか思っていました。
でもスクレイピングってPythonで書けば簡単じゃーん!とかいって始まったのが地獄の門の入り口でした。

Pythonでスクレイピングする

取り合えずトップニュースを取得すればいいかなと思い、以下のコードを記述しました。

# coding: utf-8

import requests
from bs4 import BeautifulSoup

# 現在のトップニュースのページIDを取得する
toppage_url = "https://news.yahoo.co.jp/"
html = requests.get(toppage_url)
soup = BeautifulSoup(html.content, "html.parser")
topic_element = soup.select_one("li.topicsListItem")
news_link = topic_element.find("a").get("href")
news_id = int(str(news_link).replace("https://news.yahoo.co.jp/pickup/",""))

# ニュースのトピック情報を取得する
news_url = f"https://news.yahoo.co.jp/pickup/{news_id}"
html = requests.get(news_url)
soup = BeautifulSoup(html.content, "html.parser")
news_element = soup.select_one("p.pickupMain_articleSummary")
print(news_element.text)

PHPでexec()を利用しPythonの出力を取得する

execでコマンドラインのコマンドを実行し、出力を変数に入れることができます。PHPを以下のように書きます。

<?php header("Content-type: text/html; charset=utf-8"); ?>
<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>Python-PHP</title>
</head>
<body>
<?php
    exec("export LANG=ja_JP.UTF-8");
    exec('python news.py', $output);
    echo '<p>',$output[0],'</p>';
    return false;
?>
</body>
</html>

重要なことは、exec("export LANG=ja_JP.UTF-8");の部分。
※日本語が出力される場合、utf-8を明示しないと文字化けすることがあります。日本語嫌い。
1行目はとりあえずレスポンスヘッダにも書いたほうがいいかなーとか思って付けました。必要性はわからないです

文字コードのお話

先ほどからちょくちょくでているUTF-8。今回の苦戦の1つ目になります。
PHPは基本的に文字コードはUTF-8で書くべきとされています。
Pythonの出力もutf-8で統一する必要があり、最初の頃はそこで詰まっていました。(Python側がShift-JISだった)

いざHerokuへDeploy!のはずがエラーの嵐

さあできた!デプロイして様子を見よう!とデプロイしてURLを開いてみると…

\\ 現在Herokuはリクエストを処理できません //

はい。ここでエラーです。デプロイには成功してるのにナンデ状態になりました。
結論から申し上げますと、Heroku上でPHPとPythonの両方を同時に動かす場合、2つのBuildPackが必要になります。
e4d1a639c4938b5f126d5891784286ff.png
こんな感じで設定しましょう。今回はWebページがメインのため、順番はPHP->Pythonの順になります。

これで大丈夫だろう!こんどこそ!とデプロイしようとすると、

\\ Deploy failed //

うーんこの。今度は何・・・とlogを漁ると、以下のような内容がありました。

-----> App not compatible with buildpack: https://buildpack-registry.s3.amazonaws.com/buildpacks/heroku/python.tgz
       More info: https://devcenter.heroku.com/articles/buildpacks#detection-failure
 !     Push failed

これを検索すると、Pythonアプリケーションのために必要となるファイルが無いとのこと。
それじゃあ作成しましょう。今回のPythonのコードに必要なものを詰めて以下のとおり:

#requirement.txt
beautifulsoup4==4.9.1
requests==2.24.0
#runtime.txt
python-3.7.7

これでデプロイするとうまく行きました。

完走した乾燥

文字コードとHerokuの環境構築に悩まされた一日を過ごしました。日本語嫌い。
ちなみにXAMPPでテスト環境を作ってみたらHerokuよりも文字コードに関して悩まされました。
今回はとりあえず表示に成功したのでよしとします。

参考資料

BuildPackが必要であることを知らされたサイト
https://stackoverflow.com/questions/12126439/run-python-and-php-in-a-single-heroku-app-procfile

PythonのHeroku上での実行環境を整えるために必要なファイルを知ったサイト
https://teratail.com/questions/258801

(密かに起きてたエラー対処に役立ったサイト)
デプロイ後にcode=H14 desc="No web processes running"
https://qiita.com/rebi/items/efd1c36f0a9e46222d80

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【⑥ランキングページを作る】

6. ランキング表示ページ


<< 前の記事 【⑤結果表示ページを作る】
5. 結果表示ページ
  5-1. 無効なアクセスの拒否
  5-2. 回答時間の算出
  5-3. 各変数への格納
  5-4. 保存する回答時間とカウント数の上限設定
  5-5. 正解/不正解による表示メッセージの分岐
  5-6. ランキングへの登録
   5-6-1. ランキングテーブルの構成
   5-6-2. ランキングへの登録

最後に、回答時間ランキングを表示するページを実装します。

ランキングページ.png

ranking.php
<?php

// 難易度がPOSTされている場合は変数に格納
if (isset($_POST['show_method'])) {
    $select = $_POST['show_method'];
} else {
    $select = 'all';
}

// db接続
require_once('db_connect.php');

// 選択された表示形式によってSQL文を分岐
$sql = 'SELECT * FROM rankings ';
switch ($select) {
    case 'difficult':
        $sql .= "WHERE difficulty = '難しい(漢字)' ORDER BY time LIMIT 10";
        break;
    case 'easy':
        $sql .= "WHERE difficulty = '易しい(絵文字)' ORDER BY time LIMIT 10";
        break;
    case 'all':
        $sql .= 'ORDER BY time LIMIT 10';
        break;
}
$stmt = $dbh->query($sql);
$players = $stmt->fetchAll();

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="style.css">
        <title>間違い探し ランキング</title>
    </head>
    <body>
        <h1>回答時間ランキング</h1>
        <form method="POST">
            <select name="show_method" size="1">
                <option value="all" <?php if ($select === 'all') echo 'selected' ?>>全て</option>
                <option value="easy" <?php if ($select === 'easy') echo 'selected' ?>>易しい</option>
                <option value="difficult" <?php if ($select === 'difficult') echo 'selected' ?>>難しい</option>
            </select>
            <input type="submit" value="表示">
        </form>
        <div class="table">
            <table class="s-tbl">
                <thead>
                    <tr>
                        <th>順位</th>
                        <th>名前</th>
                        <th>難易度</th>
                        <th>回答時間</th>
                        <th>リセット回数</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($players as $key => $player): ?>
                    <tr>
                        <td><?= ++$key; ?></td>
                        <td><?= $player['name']; ?></td>
                        <td><?= $player['difficulty']; ?></td>
                        <td><?= $player['time']; ?></td>
                        <td><?= $player['reset']; ?></td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <p>
            <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        </p>
    </body>
</html>

6-1. 表示切り替え機能

ranking.php
// 難易度がPOSTされている場合は変数に格納
if (isset($_POST['show_method'])) {
    $select = $_POST['show_method'];
} else {
    $select = 'all';
}

// db接続
require_once('db_connect.php');

// 選択された表示形式によってSQL文を分岐
$sql = 'SELECT * FROM rankings ';
switch ($select) {
    case 'difficult':
        $sql .= "WHERE difficulty = '難しい(漢字)' ORDER BY time LIMIT 10";
        break;
    case 'easy':
        $sql .= "WHERE difficulty = '易しい(絵文字)' ORDER BY time LIMIT 10";
        break;
    case 'all':
        $sql .= 'ORDER BY time LIMIT 10';
        break;
}
$stmt = $dbh->query($sql);
$players = $stmt->fetchAll();

後述する<select>タグによって難易度を「全て」「易しい」「難しい」の選択肢の中から選べるようになっています。

if-else文によって選択されていた場合はそのvalueの値を、選択されていない場合(=初回訪問時)にはall$selectに格納させています。

そして、DB接続設定ファイルを呼び出し、$select内の値によってSQL文が変更されるようにswitch構文を用いています。

どの選択肢においてもSELECT * FROM rankingsは共通しているので、一旦$sqlにこの文を格納してから、条件分岐に応じたSQL文を結合代入演算子.=によって追加しています。

PHPマニュアル「PHP: 文字列演算子 - Manual

その後、query()メソッドによってSQL文を実行し、返ってきたPDOStatementオブジェクトを$stmtに格納し、次いでfetchAll()メソッドによって結果を$players配列として格納しています。

PHPマニュアル「PHP: PDOStatement::fetchAll - Manual

db_connect.php」でフェッチ形式をPDO::FETCH_ASSOCと指定したので、対象テーブルのカラム名がキーとなった配列として格納されています。

6-2. 入力値の保持 - <select>タグ

ranking.php
        <form method="POST">
            <select name="show_method" size="1">
                <option value="all" <?php if ($select === 'all') echo 'selected' ?>>全て</option>
                <option value="easy" <?php if ($select === 'easy') echo 'selected' ?>>易しい</option>
                <option value="difficult" <?php if ($select === 'difficult') echo 'selected' ?>>難しい</option>
            </select>
            <input type="submit" value="表示">
        </form>

start.php」のtextタイプやradioタイプの<input>タグと同じように、<select>タグでも入力値が保持されるようにしています。

これは$selecteの値に応じてselected属性を対象の<option>タグ内にechoさせることで実現できます。

6-3. ランキングの表示

6-3-1. テーブルの表示

ranking.php
        <div class="table">
            <table class="s-tbl">
                <thead>
                    <tr>
                        <th>順位</th>
                        <th>名前</th>
                        <th>難易度</th>
                        <th>回答時間</th>
                        <th>リセット回数</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($players as $key => $player): ?>
                    <tr>
                        <td><?= ++$key; ?></td>
                        <td><?= $player['name']; ?></td>
                        <td><?= $player['difficulty']; ?></td>
                        <td><?= $player['time']; ?></td>
                        <td><?= $player['reset']; ?></td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>

find_the_mistake.php」と同様に、foreach構文によって配列となっている$playersのデータを<td>タグ内で出力しています。

foreach構文には、

  • foreach (array_expression as $value)
  • foreach (array_expression as $key => $value)

の2種類の構文があり、今回は後者を用いることで配列のインデックス番号$keyを取得し、順位を表現しています。

PHPマニュアル「PHP: foreach - Manual

順位は1から始まりますが、配列のインデックス番号は0から始まるので、プレインクリメント(前置加算子)<?= ++$key; ?>を用いることで、+1させてから出力しています1

PHPマニュアル「PHP: 加算子/減算子 - Manual

6-3-2. テーブルデザイン

style.css
.table {
  margin-top: 10px;
}
.s-tbl {
  border-collapse: collapse;
}
.s-tbl th, .s-tbl td {
  border: 1px solid #000;
  padding: 0.5em;
}
.s-tbl tr:nth-child(even) {
  background: #eee;
}
.s-tbl tr:hover {
  background: #ffffe0;
}

テーブルのCSSに関しては下記の記事から引っ張ってきたものに、少しだけ手を加えました。

ご参考までに。

いつか誰かの役に立つかもしれないweb制作屋の備忘録
css tableで背景色を交互に変える方法


<< 前の記事 【⑤結果表示ページを作る】
5. 結果表示ページ
  5-1. 無効なアクセスの拒否
  5-2. 回答時間の算出
  5-3. 各変数への格納
  5-4. 保存する回答時間とカウント数の上限設定
  5-5. 正解/不正解による表示メッセージの分岐
  5-6. ランキングへの登録
   5-6-1. ランキングテーブルの構成
   5-6-2. ランキングへの登録


  1. インクリメントデクリメントにはそれぞれプレ(前置)ポスト(後置)があり、それぞれ2連続の加算子/減算子が前と後に付きます($aへの加算なら++$a$a++)。これらは「変数返す前に加算/減算する」か「変数を返した後に加算/減算する」かといった違いがあり、今回のコードでポストインクリメント($key++)を用いてしまうとechoされた後に+1されてしまい、意味をなさなくなります。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【⑤結果表示ページを作る】

5. 結果表示ページ


<< 前の記事 【④DB接続設定ファイルを作る】
4. DB接続設定
  4-1. PDOオブジェクトの属性
   4-1-1. フェッチ形式の指定
   4-1-2. エラーモードの設定
    4-1-2-1. エラーモードの違いによるエラー文の違い
   4-1-3. エミュレーションの設定
    4-1-3-1. プリペアドステートメント
    4-1-3-2. エミュレーション
  4-2. 例外発生時の処理
   4-2-1. HTTPヘッダの送信
   4-2-2. 処理の中断とエラーメッセージの表示
  4-3. DBの切断について


次の記事 >> 【⑥ランキングページを作る】
6. ランキング表示ページ
  6-1. 表示切り替え機能
  6-2. 入力値の保持 - タグ
  6-3. ランキングの表示
   6-3-1. テーブルの表示
   6-3-2. テーブルデザイン

ここではプレイヤーの回答の正解/不正解選択した難易度回答時間リセット回数を表示するページを実装します。
結果ページ.png

result.php
<?php

// セッションの開始
session_start();

// 無効なアクセスの拒否
if (empty($_SESSION) || empty($_POST)) {
    header('Location:start.php');
    exit();
}

// 回答時間を算出
$end_time = microtime(true);
$start_time = $_SESSION['start_time'];
$time = sprintf('%05.2f', $end_time - $start_time) . '秒';

// 回答を変数に格納
$answer = $_POST['answer'];

// セッション変数を変数に格納
$name = $_SESSION['name'];
$difficulty = $_SESSION['difficulty'];
$permission = $_SESSION['permission'];
$correct = $_SESSION['correct'];
$count = sprintf('%02d', $_SESSION['count']) . '回';

// セッションの放棄
$_SESSION = [];
setcookie(session_name(), '', time() - 1, '/');
session_destroy();

// 回答時間が100秒以上の場合は値を上書き
if ($time > 100) {
    $time = '100秒以上';
}

// リセット回数が100回以上の場合は値を上書き
if ($count > 100) {
    $count = '100回以上';
}

// 正解・不正解によるメッセージの分岐
if (html_entity_decode($correct) === $answer) {
    $result = '正解です!';
} else {
    $result = '不正解です。。。';
}

// 正解かつ許可されていた場合のみDBに登録
if ($result === '正解です!' && $permission === '許可する') {

    // db接続
    require_once('db_connect.php');

    // 新規登録処理
    $sql = 'INSERT INTO rankings (name, difficulty, time, reset) VALUES (?, ?, ?, ?)';
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$name, $difficulty, $time, $count]);
}

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1><small>結果は...<?= $result ?></small></h1>
        <h2><small>難易度: <?= $difficulty; ?></small></h2>
        <h2><small>回答時間: <?= $time; ?></small></h2>
        <h2><small>リセット回数: <?= $count; ?></small></h2>
        <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        <button type="button" onclick="location.href='ranking.php'">ランキングページへ</button></a>
    </body>
</html>

セッションの開始や放棄はこれまでのファイルと同様です。

5-1. 無効なアクセスの拒否

result.php
// 無効なアクセスの拒否
if (empty($_SESSION) || empty($_POST)) {
    header('Location:start.php');
    exit();
}

find_the_mistake.php」ではセッション変数のみ存在確認していましたが、
ここでは押されたボタンのvalue属性の値POSTされているはずなので、
その値がない場合もスタートページへリダイレクトさせています。

5-2. 回答時間の算出

result.php
// 回答時間を算出
$end_time = microtime(true);
$start_time = $_SESSION['start_time'];
$time = sprintf('%05.2f', round($end_time - $start_time, 2)) . '秒';

開始時間を記録したのと同様に、
microtime()を用いて終了時間$end_timeへ格納しています。

次いで、開始時間をセッション変数から$start_timeへ格納し、
sprintf()の第2引数として「終了時間 - 開始時間」の引き算1の結果を渡しています。

sprintf()は第2引数の文字列を、
第1引数で指定したフォーマットの文字列に変換する関数で、
%05.2fは「小数第2位まで0埋めした5桁の浮動小数点数」を意味します。

これらの各文字は指定子といい、次のような書式で記述します。
[%][符号指定子][パディング指定子][アライメント指定子][表示幅指定子][精度指定子][型指定子]

今回は符合指定子とアライメント指定子以外を下記のように指定しています。

  • %・・・フォーマットとして認識させるためのパーセント文字2
  • 0・・・パディング指定子 指定した桁数に満たない部分を埋める記号(今回は0埋め)
  • 5・・・表示幅指定子 表示する最低桁数
  • .2・・・精度指定子 小数点以下の最低表示桁数
  • f・・・型指定子 引数を浮動小数点数として表示する

詳細に関しては下記もご確認下さい。

PHPマニュアル「PHP: sprintf - Manual
Let'sプログラミング ~初心者の方を対象としたプログラミングの総合学習サイト~
指定の形式にフォーマット(sprintf) - 文字列関数 - PHP関数

変換後、最後尾には文字「秒」を連結させ、変数$timeへ格納しています。

これで回答時間が算出できました。

5-3. 各変数への格納

result.php
// 回答を変数に格納
$answer = $_POST['answer'];

// セッション変数を変数に格納
$name = $_SESSION['name'];
$difficulty = $_SESSION['difficulty'];
$permission = $_SESSION['permission'];
$correct = $_SESSION['correct'];
$count = sprintf('%02d', $_SESSION['count']) . '回';

POSTされた値はプレイヤーの回答として$answerに格納しています。

またこのページからの望ましくないページ遷移を防ぐため3
スタートページと同様にセッションを放棄させます。

問題表示ページのアクセス制限は$_SESSIONが定義されているかどうかで判定しているので、結果ページへやってきた時点でセッションを放棄させることによりよって問題表示ページヘ戻ることは出来なくなります。

これにより、PHPのスクリプト終了時点でセッション変数は初期化されているので、`タグ内で出力させるためには別の変数に格納しておく必要があります。

リセット回数は上記「回答時間の算出」で用いたsprintf()を用いて、「0埋めした2桁の小数値」のフォーマットに変換してから$countに格納しています。

5-4. 保存する回答時間とカウント数の上限設定

result.php
// 回答時間が100秒以上の場合は値を上書き
if ($time > 100) {
    $time = '100秒以上';
}

// リセット回数が100回以上の場合は値を上書き
if ($count > 100) {
    $count = '100回以上';
}

ここまでのコードでは回答時間とリセット回数に上限は存在しません。

このままでも構わないのですが、

  • ランキングページのレイアウト崩れ
  • 不必要に大きな値が表示されること

を事前に防ぐために、今回は制限を追加します。

具体的には、DBに保存される回答時間・カウント数がそれぞれ

  • 100秒以上
  • 100回以上

の場合に定型文に置き換わるようにしています。

5-5. 正解/不正解による表示メッセージの分岐

result.php
// 正解・不正解によるメッセージの分岐
if (html_entity_decode($correct) === $answer) {
    $result = '正解です!';
} else {
    $result = '不正解です。。。';
}

最も初歩的な条件分岐です。

注意点として、選択された文字が絵文字だった場合
$answerに格納された値(=POSTされた値)はブラウザへの出力を経ている為、
HTMLエンティティとしてではなく絵文字そのものとなっています。

一方、$correctに格納された値(=セッション変数として保持されていた値)は、
ブラウザへの出力はないままなのでHTMLエンティティのまま格納されています。

この為、正解かどうか(=文字列が一致しているかどうか)を判定する為には、

  • セッション変数として保持されていた値をデコードする
  • POSTされた値をHTMLエンティティ化する

のどちらかを実行する必要があります。今回は前者を採用しています。

PHPマニュアル「PHP: html_entity_decode - Manual

後者の場合は下記関数が使えます。

PHPマニュアル「PHP: htmlentities - Manual

5-6. ランキングへの登録

プレイヤーの回答が正解かつランキングへの登録が許可されている場合のみ、DBへ保存します。

result.php
// 正解かつ許可されていた場合のみDBに登録
if ($result === '正解です!' && $permission === '許可する') {

    // db接続
    require_once('db_connect.php');

    // 新規登録処理
    $sql = 'INSERT INTO rankings (name, difficulty, time, reset) VALUES (?, ?, ?, ?)';
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$name, $difficulty, $time, $count]);
}

5-6-1. ランキングテーブルの構成

まず先に、データを保存するランキングテーブルを構築する必要があります。詳しい方法については「MySQL テーブル作成」などで検索すればたくさんヒットすると思いますので、ここでは割愛します。

create_rankings_table.sql
CREATE TABLE `rankings` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL,
  `difficulty` varchar(10) NOT NULL,
  `time` varchar(10) NOT NULL,
  `reset` varchar(15) NOT NULL,
  PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

一般的なテーブル構造となっています。各カラムの概要は以下です。

  • id・・・主キー/符号なし/オートインクリメント属性が指定されたidカラム
  • name・・・入力された名前を保存する可変長文字列/20字までのカラム
  • difficulty・・・選択された難易度を保存する可変長文字列/10字までのカラム
  • time・・・回答時間を保存する可変長文字列/10字までのカラム
  • reset・・・リセット回数を保存する可変長文字列/15字までのカラム

いわゆる「寿司ビール問題」を回避する為に、文字セットutf8mb4を、照合順序utf8mb4_binをデフォルトとしています。

Qiita「寿司ビール問題① 初心者→中級者へのSTEP20/25 - Qiita」 by @kamohicokamo さん

また脚注1.でも少し触れましたが、一部の絵文字の文字数が正常にカウントされません(2倍にカウントされる)ので、nameカラムの文字数上限はバリデーションで制限した10文字の2倍の20文字としてます。

テーブル名やカラム名の命名規則に関しては下記記事が参考になります。

Qiita「データベースオブジェクトの命名規約 - Qiita」 by @genzouw さん

5-6-2. ランキングへの登録

result.php
    // db接続
    require_once('db_connect.php');

    // 新規登録処理
    $sql = 'INSERT INTO rankings (name, difficulty, time, reset) VALUES (?, ?, ?, ?)';
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$name, $difficulty, $time, $count]);

require_once()でDB接続設定用ファイル「db_connect.php」を呼び出してDB接続した後、INSERT文を用いてrankingsテーブルにデータを挿入しています。

SQL文では疑問符プレースホルダーを用いたSQL文を記述し、prepare()メソッドによってDBサーバーにプリペアドステートメントとしてSQL文を渡しています(今回のDB接続設定では静的プレースホルダーを選択している為)。

PHPマニュアル「PHP: PDO - Manual
PHPマニュアル「PHP: PDO::prepare - Manual

prepare()メソッドはPDOStatementオブジェクトを返すのでそれを$stmtに格納し、次いでPDOStatementクラスのexecute()メソッドによって、

  • execute()の引数に渡された値とプレースホルダーのバインド
  • SQL文の実行

を行っています。

PHPマニュアル「PHP: PDOStatement - Manual
PHPマニュアル「PHP: PDOStatement::execute - Manual

bindValue()メソッドを使用せずにexecute()の引数に値を配列として渡す場合の注意点として、NULL以外はすべてPDO::PARAM_STR扱いになるというのがあります。

今回のテーブルはidカラム以外はすべて可変長文字列カラムですので問題ありませんが、テーブルのカラムが文字列以外の型の場合execute()の引数はにし、prepare()execute()の間でbindValue()メソッドを使用した方がいいです。

Qiita「PHPでデータベースに接続するときのまとめ - PDO::prepare → PDOStatement::execute の2ステップでクエリを実行する - Qiita」 by @mpyw さん


<< 前の記事 【④DB接続設定ファイルを作る】
4. DB接続設定
  4-1. PDOオブジェクトの属性
   4-1-1. フェッチ形式の指定
   4-1-2. エラーモードの設定
    4-1-2-1. エラーモードの違いによるエラー文の違い
   4-1-3. エミュレーションの設定
    4-1-3-1. プリペアドステートメント
    4-1-3-2. エミュレーション
  4-2. 例外発生時の処理
   4-2-1. HTTPヘッダの送信
   4-2-2. 処理の中断とエラーメッセージの表示
  4-3. DBの切断について


次の記事 >> 【⑥ランキングページを作る】
6. ランキング表示ページ
  6-1. 表示切り替え機能
  6-2. 入力値の保持 - タグ
  6-3. ランキングの表示
   6-3-1. テーブルの表示
   6-3-2. テーブルデザイン


  1. microtime()のタイムスタンプはUnixエポックからの経過マイクロ秒数となっています。このため「新しい方(ここでは$end_time)」から「古い方($start_time)」を引いた値が経過時間のマイクロ秒数となります。 

  2. これがないと各文字が指定子として認識されなくなります。また、この文字自体は指定子ではありません。 

  3. 例えば「このページからもう一度問題表示ページへ戻る」などです。個人的には「問題表示ページから結果ページへは一方通行としたい」と考えたためこのような実装となりました。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【④DB接続設定ファイルを作る】

4. DB接続設定


<< 前の記事 【③問題表示ページを作る】
3. 問題表示ページの実装
  3-1. 無効なアクセスの拒否
  3-2. リセット回数の計測
  3-3. 問題用文字配列の設定
   3-3-1. 文字ペア配列の選択
   3-3-2. 文字ペア配列及び文字ペアのシャッフル
   3-3-3. 正解文字と不正解文字配列の設定
    3-3-3-1. 正解文字の設定
    3-3-3-2. 不正解文字配列の設定
   3-3-4. 問題用文字配列の生成
  3-4. 開始時刻の記録
  3-5. 選択肢の描写


次の記事 >> 【⑤結果表示ページを作る】
5. 結果表示ページ
  5-1. 無効なアクセスの拒否
  5-2. 回答時間の算出
  5-3. 各変数への格納
  5-4. 保存する回答時間とカウント数の上限設定
  5-5. 正解/不正解による表示メッセージの分岐
  5-6. ランキングへの登録
   5-6-1. ランキングテーブルの構成
   5-6-2. ランキングへの登録

この記事ではDB接続設定ファイルdb_connect.php」を作成します。必要となったところでこのファイルを呼び出すことで、いちいちPDOオブジェクトの属性などを記述しなくても済みます。

db_connect.php
<?php

// 定数定義
const PDO_DSN = 'mysql:host=localhosts;dbname=[FILTERED];charset=utf8mb4';
const USERNAME = '[FILTERED]';
const PASSWORD = '[FILTERED]';

// DB接続
try {
    $dbh = new PDO(PDO_DSN, USERNAME, PASSWORD, [
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
} catch (PDOException $e) {
    header('Content-Type: text/plain; charset=UTF-8', true, 500);
    exit('DB接続に失敗しました' . '<br>' . PHP_EOL . $e->getMessage());
}

冒頭でも述べましたが、DB構築については本記事では扱いません。
接続にはPDOオブジェクトを使用しています。

PDOのインスタンスを作成する為にはPDO()を用いて

  • 第1引数・・・DSN(必須)
  • 第2引数・・・ユーザーネーム(任意)
  • 第3引数・・・パスワード(任意)
  • 第4引数・・・PDOオブジェクトの属性(任意)

を渡す必要があります。

今回のコードでは第1〜第3引数を定数としてを定義してますが、変数定義や引数に直接渡す書き方でも問題ありません。

また、DBにはプレイヤーが入力した名前を保存します。このため絵文字を取り扱う可能性があるので、文字コードはutf8ではなくマルチバイト対応のuft8mb4を指定しています。

コピペする際は、[FILTERED]を適切な値に置き換えて下さい。

PHPマニュアル「PHP: 接続、および接続の管理 - Manual
PHPマニュアル「PHP: PDO::__construct - Manual

4-1. PDOオブジェクトの属性

属性に関する公式リファレンスは以下を参照願います。
PHPマニュアル「PHP: PDO::setAttribute - Manual

また下記Qiita記事が体系的にまとめられており、とても参考になります。ぜひ一読を!

Qiita「PHPでデータベースに接続するときのまとめ - Qiita」 by @mpyw さん
Qiita「【PHP超入門】クラス~例外処理~PDOの基礎 - Qiita」 by @7968 さん

4-1-1. フェッチ形式の指定

db_connect.php
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC

PDO::FETCH_ASSOC: は、結果セットに 返された際のカラム名で添字を付けた配列を返します。
引用元:PHPマニュアル「PHP: PDOStatement::fetch - Manual

SQL文で得られた結果をフェッチする際の形式を指定しています。
カラム名がキーとなった配列で返ってくるので直感的に操作しやすいです。
この形式がもっともスタンダードな気がします。

4-1-2. エラーモードの設定

db_connect.php
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION

エラーモードは3種類あり、PDO::ERRMODE_EXCEPTIONはエラー発生時にPDOException例外を発生させます。

また、エラーコードだけでなくその関連情報も返してくれるようにもなります。

どれに設定するかは開発する物や状況によると思いますが、今回はPDO::ERRMODE_EXCEPTIONを選択しました。

4-1-2-1. エラーモードの違いによるエラー文の違い

以下は本ゲームの「result.php」の56行目において、テーブルのカラムとは異なるカラム名を指定したINSERT文を実行した際のエラー文の比較です。

  • PDO::ERRMODE_SILENT
Fatal error: Uncaught Error: Call to a member function execute() on boolean in /path/to/result.php:58 Stack trace: #0 {main} thrown in /path/to/result.php on line 58
  • PDO::ERRMODE_WARNING
Warning: PDO::prepare(): SQLSTATE[42S22]: Column not found: 1054 Unknown column 'namae' in 'field list' in /path/to/result.php on line 57

Fatal error: Uncaught Error: Call to a member function execute() on boolean in /path/to/result.php:58 Stack trace: #0 {main} thrown in /path/to/result.php on line 58
  • PDO::ERRMODE_EXCEPTION
Fatal error: Uncaught PDOException: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'namae' in 'field list' in /path/to/result.php:57 Stack trace: #0 /path/to/result.php(57): PDO->prepare('INSERT INTO ran...') #1 {main} thrown in /path/to/result.php on line 57

デフォルトのPDO::ERRMODE_SILENTでは単に「SQL文を実行するexecute()でエラーが起こった」と表示されるだけであるのに対し、PDO::ERRMODE_WARNINGPDO::ERRMODE_EXCEPTIONでは「指定されたカラムが見つからない」とより具体的なエラー文となっています。

また、PDO::ERRMODE_WARNINGではE_WARNINGメッセージが追加されており、PDO::ERRMODE_EXCEPTIONでは例外がPDOExceptionクラスになっているという違いもあります。

PHPマニュアル「エラーおよびエラー処理 - Manual

4-1-3. エミュレーションの設定

db_connect.php
        PDO::ATTR_EMULATE_PREPARES => false

プリペアドステートメントエミュレーションに関する設定です。

4-1-3-1. プリペアドステートメント

直訳すると「準備された記述」になりますが、一文で言うと
後から値を入れる部分を別の文字・単語などで仮置きしたSQL文
かなと思います。

プリペアドステートメントを用いる利点は以下の2つです。

  • 後から入れる値のみを変更しながら、何度も使用できる
  • SQLインジェクション対策になる

下記記事もご参照ください。

Qiita「【PHP超入門】クラス~例外処理~PDOの基礎 - プリペアドステートメント - Qiita」 by @7968 さん

4-1-3-2. エミュレーション

truefalse(ONかOFF)かによってプリペアドステートメントの挙動が変化します。

上述のプリペアドステートメントにおいて、仮置きする文字・単語のことをプレースホルダーといい、

  • 疑問符プレースホルダー
  • 名前つきプレースホルダー

の2種類があり、今回の記事では前者に統一しています。

さらにプレースホルダー自体にも

  • 静的プレースホルダー
  • 動的プレースホルダー

の2種類のタイプがあり、エミュレーションをfalseとすることで静的プレースホルダーが用いられるようになります。

静的プレースホルダーを選択する理由としては、よりセキュアである為です。

こちらに関しては下記記事が参考になります。

Qiita「【PHP超入門】クラス~例外処理~PDOの基礎 - 静的プレースホルダと動的プレースホルダ - Qiita」 by @7968 さん

また、エミュレーションのON/OFFによる挙動の違いに関しては下記記事が参考になります。

Qiita「PHPでデータベースに接続するときのまとめ - エミュレーションに関するまとめ - Qiita」 by @mpyw さん

更に、下記質問もご参考までに。

Teratail「`PDO::ATTR_EMULATE_PREPARES => false`は必要か?

4-2. 例外発生時の処理

db_connect.php
} catch (PDOException $e) {
    header('Content-Type: text/plain; charset=UTF-8', true, 500);
    exit('DB接続に失敗しました' . PHP_EOL . $e->getMessage() . PHP_EOL);
}

catch()を用いることで、発生した例外を捕捉することができます。

PHPマニュアル「PHP: 例外(exceptions) - Manual

PDOException発生した例外のクラス名で、$e発生した例外のクラスから作成したインスタンスを代入する変数となっています。

4-2-1. HTTPヘッダの送信

エラーメッセージを表示する際、webブラウザにエラーメッセージを「単なるテキストである」と解釈してもらうため、header()を用いてMINEタイプを設定しています。

また、第3引数にはHTTPレスポンスステータスコードを指定し、
サーバー側のエラーであることを明示しています。

MDN web docs「MIME タイプ (IANA メディアタイプ) - HTTP | MDN
MDN web docs「HTTP レスポンスステータスコード - HTTP | MDN

4-2-2. 処理の中断とエラーメッセージの表示

続くexit()によって、

  • 自作のエラー文
  • 発生した例外に関するエラーメッセージ

を出力させ、後続の処理を中断させています。

このファイルを呼び出しているということは、DBのアクセスを必要とする処理を行うはずなので、接続に失敗した場合には後続の処理も失敗する可能性が高いためです。

PHPマニュアル「PHP: exit - Manual

例外に関するエラーメッセージは、$e->getMessage()によってインスタンスのgetMessage()メソッドにアクセスすることで取得しています。

PHPマニュアル「PHP: Error::getMessage - Manual

PHP_EOLPHPの定義済み定数で、プラットフォームの行末文字を意味します(EOLは"End Of Line"の略です)。

この定数は、OSを自動判定して行末文字を選定してくれますので、サーバーのOSを気にすることなく改行して表示させることができます。

PHPマニュアル「PHP: 定義済みの定数 - Manual

4-3. DBの切断について

PDOインスタンスを格納した変数にNULLを代入することで、DBから切断させることができます(今回の場合なら$dbh = NULL)。

接続を閉じるには、他から 参照されていないことを保障することでオブジェクトを破棄する 必要があります。それには、オブジェクトを保持している変数に対して NULL を代入します。
引用元:PHPマニュアル「PHP: 接続、および接続の管理 - Manual

しかし、

明示的にこれを行わなかった場合は、スクリプトの終了時に自動的に 接続が閉じられます。
引用元:PHPマニュアル「PHP: 接続、および接続の管理 - Manual

とも書かれており、あえて記述する必要はないかと思われます。

下記もご参照下さい。

Qiita「PHPでデータベースに接続するときのまとめ - データベース接続の切断 - Qiita」 by @mpyw さん


<< 前の記事 【③問題表示ページを作る】
3. 問題表示ページの実装
  3-1. 無効なアクセスの拒否
  3-2. リセット回数の計測
  3-3. 問題用文字配列の設定
   3-3-1. 文字ペア配列の選択
   3-3-2. 文字ペア配列及び文字ペアのシャッフル
   3-3-3. 正解文字と不正解文字配列の設定
    3-3-3-1. 正解文字の設定
    3-3-3-2. 不正解文字配列の設定
   3-3-4. 問題用文字配列の生成
  3-4. 開始時刻の記録
  3-5. 選択肢の描写


次の記事 >> 【⑤結果表示ページを作る】
5. 結果表示ページ
  5-1. 無効なアクセスの拒否
  5-2. 回答時間の算出
  5-3. 各変数への格納
  5-4. 保存する回答時間とカウント数の上限設定
  5-5. 正解/不正解による表示メッセージの分岐
  5-6. ランキングへの登録
   5-6-1. ランキングテーブルの構成
   5-6-2. ランキングへの登録

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【③問題表示ページを作る】

3. 問題表示ページの実装


<< 前の記事 【②スタートページを作る】
2. スタートページの実装
  2-1. セッション
   2-1-1. セッションの開始
   2-1-2. セッションの放棄
  2-2. バリデーション
   2-2-1. 無効な送信の拒否
   2-2-2. 名前のバリデーション
    2-2-2-1. 半角スペースのみ無効
    2-2-2-2. 文字数
    2-2-2-3. 空白文字/制御文字無効
   2-2-3. エラーメッセージの表示
   2-2-4. 入力値の保持 - タグ
  2-3. 問題表示ページへの遷移


次の記事 >> 【④DB接続設定ファイルを作る】
4. DB接続設定
  4-1. PDOオブジェクトの属性
   4-1-1. フェッチ形式の指定
   4-1-2. エラーモードの設定
    4-1-2-1. エラーモードの違いによるエラー文の違い
   4-1-3. エミュレーションの設定
    4-1-3-1. プリペアドステートメント
    4-1-3-2. エミュレーション
  4-2. 例外発生時の処理
   4-2-1. HTTPヘッダの送信
   4-2-2. 処理の中断とエラーメッセージの表示
  4-3. DBの切断について

この記事ではゲームのコアとなる問題を生成するページを実装します。

難易度「易しい」の問題例
問題ページ_易しい.png
難易度「難しい」の問題例
問題ページ_難しい.png

find_the_mistake.php
<?php

// セッションの再開
session_start();

// 無効なアクセスの拒否
if (empty($_SESSION)) {
    header('Location:start.php');
    exit();
}

// リセット回数の計測開始
if (isset($_SESSION['count'])) {
    $_SESSION['count']++;
} else {
    $_SESSION['count'] = 0;
}

// ターゲット配列の設定
if ($_SESSION['difficulty'] === '難しい(漢字)') {
    $chars = [
        ['猫', '描'],
        ['犬', '大'],
        ['幸', '辛'],
        ['白', '臼'],
        ['矢', '失'],
        ['力', '刀'],
        ['防', '妨'],
        ['土', '士'],
        ['卵', '卯'],
        ['巨', '臣'],
        ['寒', '塞'],
        ['旅', '族'],
        ['車', '東'],
        ['釘', '針']
    ];
} else {
    $chars = [
        ['&#x1f415;', '&#x1f408;'],
        ['&#x1f405;', '&#x1f406;'],
        ['&#x1f98e;', '&#x1f40d;'],
        ['&#x1f433;', '&#x1f42c;'],
        ['&#x1f339;', '&#x1f337;'],
        ['&#x1f34a;', '&#x1f34b;'],
        ['&#x1f34e;', '&#x1f351;'],
        ['&#x1f955;', '&#x1f336;'],
        ['&#x1f96f;', '&#x1f95e;'],
        ['&#x1f358;', '&#x1f359;'],
        ['&#x1f341;', '&#x1f342;'],
        ['&#x1f332;', '&#x1f333;'],
        ['&#x1f47b;', '&#x1f47d;'],
        ['&#x1f396;', '&#x1f3c5;']
    ];
}

// ターゲット配列のシャッフル
shuffle($chars);
for ($i = 0; $i < count($chars); $i++) {
    shuffle($chars[$i]);
}

// 正解と選択対象配列を設定
$correct = $chars[0][0];
$_SESSION['correct'] = $correct;
for ($j = 0; $j <= 19; $j++) {
    for ($k = 0; $k <= 19; $k++) {
        $targets[$j][$k] = $chars[0][1];
    }
}

// 正解のターゲットを選択対象配列に1つだけ挿入
$key = range(0, 19);
$key1 = array_rand($key);
$key2 = array_rand($key);
$targets[$key1][$key2] = $correct;

// 開始時刻を記録
$_SESSION['start_time'] = microtime(true);

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1><?= $correct; ?>を見つけよう!</h1>
        <p>
            <small>難易度: <?= $_SESSION['difficulty']; ?></small>
            <button type="button" onclick="location.href='find_the_mistake.php'">分かるか!(リセット)</button>
            <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        </p>
        <form action="result.php" method="POST">
            <table>
                <?php foreach ($targets as $target): ?>
                <tr>
                    <?php for ($l = 0; $l < count($target); $l++): ?>
                    <td><input type="submit" name="answer" value="<?= $target[$l]; ?>"></td>
                    <?php endfor; ?>
                </tr>
                <?php endforeach; ?>
            </table>
        </form>
    </body>
</html>

3-1. 無効なアクセスの拒否

find_the_mistake.php
// 無効なアクセスの拒否
if (empty($_SESSION)) {
    header('Location:start.php');
    exit();
}

不正な画面遷移とエラーを防ぐために、
start.php」で設定したセッション変数がない場合は、
スタートページにリダイレクトされるようにしています。

3-2. リセット回数の計測

このゲームでは、問題が難しい場合にリセットできる機能を実装しており、
リセットボタンが押されると問題生成ページが再描写されます。

この時、セッション変数にcountをキーとしたリセット回数を記録させています。

セッション変数とすることで、結果ページでもリセット回数を取り扱えるようになります。

find_the_mistake.php
// リセット回数の計測開始
if (isset($_SESSION['count'])) {
    $_SESSION['count']++;
} else {
    $_SESSION['count'] = 0;
}
  • 既に設定されていた場合はインクリメント
  • それ以外では0を設定

とすることでリセット回数が正しくカウントされるようにしています。

3-3. 問題用文字配列の設定

find_the_mistake.php
// ターゲット配列の設定
if ($_SESSION['difficulty'] === '難しい(漢字)') {
    $chars = [
        ['猫', '描'],
        ['犬', '大'],
        ['幸', '辛'],
        ['白', '臼'],
        ['矢', '失'],
        ['力', '刀'],
        ['防', '妨'],
        ['土', '士'],
        ['卵', '卯'],
        ['巨', '臣'],
        ['寒', '塞'],
        ['旅', '族'],
        ['車', '東'],
        ['釘', '針']
    ];
} else {
    $chars = [
        ['&#x1f415;', '&#x1f408;'],
        ['&#x1f405;', '&#x1f406;'],
        ['&#x1f98e;', '&#x1f40d;'],
        ['&#x1f433;', '&#x1f42c;'],
        ['&#x1f339;', '&#x1f337;'],
        ['&#x1f34a;', '&#x1f34b;'],
        ['&#x1f34e;', '&#x1f351;'],
        ['&#x1f955;', '&#x1f336;'],
        ['&#x1f96f;', '&#x1f95e;'],
        ['&#x1f358;', '&#x1f359;'],
        ['&#x1f341;', '&#x1f342;'],
        ['&#x1f332;', '&#x1f333;'],
        ['&#x1f47b;', '&#x1f47d;'],
        ['&#x1f396;', '&#x1f3c5;']
    ];
}

// ターゲット配列のシャッフル
shuffle($chars);
for ($i = 0; $i < count($chars); $i++) {
    shuffle($chars[$i]);
}

// 正解と選択対象配列を設定
$correct = $chars[0][0];
$_SESSION['correct'] = $correct;
for ($j = 0; $j <= 19; $j++) {
    for ($k = 0; $k <= 19; $k++) {
        $targets[$j][$k] = $chars[0][1];
    }
}

// 正解のターゲットを選択対象配列に1つだけ挿入
$key = range(0, 19);
$key1 = array_rand($key);
$key2 = array_rand($key);
$targets[$key1][$key2] = $correct;

3-3-1. 文字ペア配列の選択

選択された難易度によって漢字か絵文字かを分岐させます。

find_the_mistake.php
// ターゲット配列の設定
if ($_SESSION['difficulty'] === '難しい(漢字)') {
    $chars = [
        ['猫', '描'],
        ['犬', '大'],
        ['幸', '辛'],
        ['白', '臼'],
        ['矢', '失'],
        ['力', '刀'],
        ['防', '妨'],
        ['土', '士'],
        ['卵', '卯'],
        ['巨', '臣'],
        ['寒', '塞'],
        ['旅', '族'],
        ['車', '東'],
        ['釘', '針']
    ];
} else {
    $chars = [
        ['&#x1f415;', '&#x1f408;'],
        ['&#x1f405;', '&#x1f406;'],
        ['&#x1f98e;', '&#x1f40d;'],
        ['&#x1f433;', '&#x1f42c;'],
        ['&#x1f339;', '&#x1f337;'],
        ['&#x1f34a;', '&#x1f34b;'],
        ['&#x1f34e;', '&#x1f351;'],
        ['&#x1f955;', '&#x1f336;'],
        ['&#x1f96f;', '&#x1f95e;'],
        ['&#x1f358;', '&#x1f359;'],
        ['&#x1f341;', '&#x1f342;'],
        ['&#x1f332;', '&#x1f333;'],
        ['&#x1f47b;', '&#x1f47d;'],
        ['&#x1f396;', '&#x1f3c5;']
    ];
}

正解・不正解のペア関係を維持するため1に、
多次元配列として変数$charsに格納しています。

絵文字は&XXX;という形式で記述されていますが、
これはHTMLエンティティと呼ばれるもので、
これにより見えない文字や標準キーボードでは入力が難しい文字を容易に取り扱えます

また、viエディタ上でのバグを防ぐこともできます2

MDN web docs「Entity (エンティティ) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN


今回用いた各エンティティとwebページ上で表示される絵文字の対応はこちら
&#x1f415; => 「?」と &#x1f408; => 「?」
&#x1f405; => 「?」と &#x1f406; => 「?」
&#x1f98e; => 「?」と &#x1f40d; => 「?」
&#x1f433; => 「?」と &#x1f42c; => 「?」
&#x1f339; => 「?」と &#x1f337; => 「?」
&#x1f34a; => 「?」と &#x1f34b; => 「?」
&#x1f34e; => 「?」と &#x1f351; => 「?」
&#x1f955; => 「?」と &#x1f336; => 「?」
&#x1f96f; => 「?」と &#x1f95e; => 「?」
&#x1f358; => 「?」と &#x1f359; => 「?」
&#x1f341; => 「?」と &#x1f342; => 「?」
&#x1f332; => 「?」と &#x1f333; => 「?」
&#x1f47b; => 「?」と &#x1f47d; => 「?」
&#x1f396; => 「?」と &#x1f3c5; => 「?」

絵文字のHTMLエンティティを調べるには下記サイトが参考になります。

Let's EMOJI「Unicode 13.0 絵文字 (Unicode 13.0 Emoji) | Let's EMOJI

3-3-2. 文字ペア配列及び文字ペアのシャッフル

難易度がどちらの場合でも、正解・不正解のペアは14組あるので、
正解・不正解関係は保ったままでこれらをシャッフルし、問題にランダム性を付与します。
これによりリセットする度に異なる問題が表示されるようになります。

find_the_mistake.php
// ターゲット配列のシャッフル
shuffle($chars);
for ($i = 0; $i < count($chars); $i++) {
    shuffle($chars[$i]);
}

配列のシャッフルにはshuffle()が使えます。

shuffle()は「シャッフルされた配列」ではなく、
シャッフルが成功したかどうかの真偽値」を返しますので、
変数に格納する必要はありません。

PHPマニュアル「PHP: shuffle - Manual

まず、文字ペアの多次元配列を格納した$charsをシャッフルし、
14組の文字ペアの並び(=多次元配列の第1層)」をランダムにします。

続いて、for構文を用いて再帰的にシャッフルすることで、
文字ペアの中での並び(=多次元配列の第2層)」もランダムにします3

こうすることで「ペアの中でいつもどちらかが正解/不正解」となってしまうのを防ぎ、
ゲーム性を保てます。

正解・不正解のペアが14組あり、ペアの中でも正解・不正解が入れ替わることから、
各難易度毎に14 × 2 = 28通りの問題を生成できます(ゲーム全体では56通り)。

最初に変数$charsに格納した文字ペアの多次元配列にペアを加えることで、
更にパターンを増やすこともできます。

3-3-3. 正解文字と不正解文字配列の設定

ランダムにシャッフルされた文字ペア配列から1文字だけ選択して正解の文字とします。
次いで、「正解の文字とペアになっている文字」を用いて不正解文字の配列を生成します。

find_the_mistake.php
// 正解と選択対象配列を設定
$correct = $chars[0][0];
$_SESSION['correct'] = $correct;
for ($j = 0; $j <= 19; $j++) {
    for ($k = 0; $k <= 19; $k++) {
        $targets[$j][$k] = $chars[0][1];
    }
}

3-3-3-1. 正解文字の設定

$correct正解とする文字を代入しています。
文字ペアの多次元配列を再帰的にシャッフルしたので、
$chars[0][0]には、
28(14 × 2)文字からランダムに選択された1文字
が格納されています。

結果ページを生成するファイルで正解/不正解の判定を行うので、
セッション変数にもcorrectをキーとして正解文字を格納しています。

3-3-3-2. 不正解文字配列の設定

次いで、正解文字と対になる不正解文字を、
$targets20 × 20の多次元配列として格納しています。

対になる文字は$charsの正解文字と同じ階層にある異なるキーの値のはずなので、
今回のコードでは$chars[0][1]が該当します。

この不正解文字を、変数$j,$k0 ~ 19の範囲で2重の繰り返し処理を回す中で、
$targetsに対しキーを[$j][$k]とした値とすることで、多次元配列を生成しています4

これで正解文字と不正解文字配列の設定が完了しました。

3-3-4. 問題用文字配列の生成

不正解文字配列のランダムな1つの値のみを正解文字に置き換えることで、
問題用の文字配列を作り出します。

find_the_mistake.php
// 正解のターゲットを選択対象配列に1つだけ挿入
$key = range(0, 19);
$key1 = array_rand($key);
$key2 = array_rand($key);
$targets[$key1][$key2] = $correct;

まず、range()を用いて値が0 ~ 19の範囲の整数を値としてもつ配列を生成し、
$keyに格納しています。

その後、array_rand()によって$keyの配列からランダムにキーを取り出します。
これを2回繰り返してそれぞれ$key1$key2に格納することで、
1 ~ 19の範囲内のランダムな整数を選択しました。

後はこの$key1$key2を不正解文字配列$targetsキーとして指定することで、
多次元配列の中のランダムな1つの値」を指定しています5

PHPマニュアル「PHP: range - Manual
PHPマニュアル「PHP: array_rand - Manual

これでようやく問題用の文字配列が完成しました!

3-4. 開始時刻の記録

回答時間を計測する為に問題が表示される直前に開始時刻を記録します。

find_the_mistake.php
// 開始時刻を記録
$_SESSION['start_time'] = microtime(true);

選択された文字の正解/不正解判定と同様に、結果ページで判定するので、
セッション変数として保存しています。

microtime()現在のタイムスタンプをマイクロ秒まで返す関数で、

  • 引数を指定しない場合はmsec sec形式の文字列
  • trueを指定した場合はfloat型のマイクロ秒

を、それぞれ返します。

PHPマニュアル「PHP: microtime - Manual

結果ページでは引き算で回答時間を算出するので、
計算しやすいタイムスタンプ形式となるように引数にはtrueを指定しています。

3-5. 選択肢の描写

問題用の文字配列を、繰り返し処理によって描写し、20×20のボタンを表示させます。

find_the_mistake.php
        <form action="result.php" method="POST">
            <table>
                <?php foreach ($targets as $target): ?>
                <tr>
                    <?php for ($l = 0; $l < count($target); $l++): ?>
                    <td><input type="submit" name="answer" value="<?= $target[$l]; ?>"></td>
                    <?php endfor; ?>
                </tr>
                <?php endforeach; ?>
            </table>
        </form>

<form>タグの中でsubmitタイプの<input>タグを繰り返し表示させることで、
どのボタンを押しても「result.php」へ回答がPOSTされるようになっています。

不正解用文字配列を生成させた時と同様に、
2重の繰り返し処理によって多次元配列の値を表示させます。

まず<?php foreach ($targets as $target): ?>によって
第1層の繰り返し処理します。
繰り返しの対象を<tr>タグで囲んでおり、ボタン群の行の繰り返しに相当します。

上記foreach構文の中で更に繰り返し処理
<?php for ($l = 0; $l < count($target); $l++): ?>
を実行することで、第2層を表示させます6
こちらは<td>タグで囲っており、各行内の列の繰り返しに相当します。

これらの繰り返しにより、
「20個の<td>タグ内のsubmitボタン」

「それぞれ20個の<tr>タグで囲われた」
計400個のボタン群を表示させることができます。


<< 前の記事 【②スタートページを作る】
2. スタートページの実装
  2-1. セッション
   2-1-1. セッションの開始
   2-1-2. セッションの放棄
  2-2. バリデーション
   2-2-1. 無効な送信の拒否
   2-2-2. 名前のバリデーション
    2-2-2-1. 半角スペースのみ無効
    2-2-2-2. 文字数
    2-2-2-3. 空白文字/制御文字無効
   2-2-3. エラーメッセージの表示
   2-2-4. 入力値の保持 - タグ
  2-3. 問題表示ページへの遷移


次の記事 >> 【④DB接続設定ファイルを作る】
4. DB接続設定
  4-1. PDOオブジェクトの属性
   4-1-1. フェッチ形式の指定
   4-1-2. エラーモードの設定
    4-1-2-1. エラーモードの違いによるエラー文の違い
   4-1-3. エミュレーションの設定
    4-1-3-1. プリペアドステートメント
    4-1-3-2. エミュレーション
  4-2. 例外発生時の処理
   4-2-1. HTTPヘッダの送信
   4-2-2. 処理の中断とエラーメッセージの表示
  4-3. DBの切断について


  1. 「間違い探し」なので単に「ランダムに文字を選択させる」だと簡単に見分けがつく文字同士が表示される可能性があり、ゲーム性が損なわれます。この為「似た文字のペア」を設定する必要があります。 

  2. viやvimで直接絵文字を打つとカーソルの位置がおかしくなったり謎の空白が出現するなど、めちゃくちゃバグります。vimにはあまり詳しくないので、回避できる策があるなら教えて欲しいです... 

  3. 多次元配列に対してshuffle()を用いた場合、最上位層のみシャッフルされます。再帰的にシャッフルするには別途for構文やforeach構文などの繰り返し処理を用いる必要があります。 

  4. 文字ペアの多次元配列のシャッフル時にfor構文で$iを使用したので、今回はアルファベット順に$j, $kとしています。$i$j, 実際は、$kのどちらかは同じでも問題ありません(for構文の最初の式で初期化される為。$j$kは同時に使用するので分ける必要あり)が、ややこしいので筆者はこのやり方を好んでいます。 

  5. array_rand()ランダムなキーを返すことに注意してください。今回の場合、$key1$key2に格納されているのは$key値ではなくキーです。$keyの配列のキーと値が全く同じである為ややこしいですが、キーにインデックス番号以外が用いられた配列を扱う際は注意が必要です。 

  6. 脚注4.と同じように、カウンター変数はアルファベット順に$lとしています。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【②スタートページを作る】

2. スタートページの実装


<< 前の記事 【①ゲームの概要】
1. 間違い探しゲームの概要
  1-1. ゲームの全体像
  1-2. 開発環境及びファイルの全体像
   1-2-1. 開発・動作確認済み環境
   1-2-2. 作成するファイル
    1-2-2-1. メインファイル
    1-2-2-2. サブファイル
  1-3. ソースコード
   1-3-1. メインファイルのソースコード
   1-3-2. サブファイルのソースコード
  1-4. 参考にさせて頂いた記事・サイトの一覧


次の記事 >> 【③問題表示ページを作る】
3. 問題表示ページの実装
  3-1. 無効なアクセスの拒否
  3-2. リセット回数の計測
  3-3. 問題用文字配列の設定
   3-3-1. 文字ペア配列の選択
   3-3-2. 文字ペア配列及び文字ペアのシャッフル
   3-3-3. 正解文字と不正解文字配列の設定
    3-3-3-1. 正解文字の設定
    3-3-3-2. 不正解文字配列の設定
   3-3-4. 問題用文字配列の生成
  3-4. 開始時刻の記録
  3-5. 選択肢の描写

まず、最初にプレイヤーが訪れることになるページを生成する「start.php」を実装します。

スタート画面.png

start.php
<?php

// セッションの開始
session_start();

// 送信されたデータの検証
if (isset($_POST['name'], $_POST['difficulty'], $_POST['permission'])) {

    // 変数への代入
    $name = $_POST['name'];
    $difficulty = $_POST['difficulty'];
    $permission = $_POST['permission'];

    // 名前のバリデーション
    if (empty(trim($name))) {
        $error_msg = '名前に空白は無効です';
    } elseif (mb_strlen($name, 'UTF-8') > 10) {
        $error_msg = '名前は10字以内にしてください';
    } elseif ($name !== preg_replace('/\A[\p{C}\p{Z}]++|[\p{C}\p{Z}]++\z/u', '', $name)) {
        $error_msg = '名前の前後に空白文字や制御文字を含めないで下さい';
    }

    // セッション変数への格納と問題表示ページへの遷移
    if (empty($error_msg)) {
        $_SESSION['name'] = $name;
        $_SESSION['difficulty'] = $difficulty;
        $_SESSION['permission'] = $permission;
        header('Location:find_the_mistake.php');
        exit();
    }
}

// セッションの放棄
$_SESSION = [];
setcookie(session_name(), '', time() - 1, '/');
session_destroy();

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1>間違い探し</h1>
        <p>20×20個のボタンが表示されるので、指定されたボタンを見つけ出して押しましょう!</p>
        <?php if (isset($error_msg)): ?>
        <hr>
        <ul>
            <li><?= $error_msg; ?></li>
        </ul>
        <hr>
        <?php endif; ?>
        <form method="POST">
            <label>名前
                <small> (10字以内)</small><br>
                <input type="text" name="name" required value="<?php if (isset($name)) echo $name;?>">
            </label>
            <p>
                <span>難易度</span><br>
                <label>
                    <input type="radio" name="difficulty" value="易しい(絵文字)" required <?php if (empty($difficulty) || isset($difficulty) && $difficulty === '易しい(絵文字)') echo 'checked';?>>
                    易しい(絵文字)
                </label>
                <label>
                    <input type="radio" name="difficulty" value="難しい(漢字)" <?php if (isset($difficulty) && $difficulty === '難しい(漢字)') echo 'checked';?>>
                    難しい(漢字)
                </label>
            </p>
            <p>
                <span>ランキングへの登録</span><br>
                <label>
                    <input type="radio" name="permission" value="許可しない" required <?php if (empty($permission) || isset($permission) && $permission === '許可しない') echo 'checked';?>>
                    許可しない
                </label>
                <label>
                    <input type="radio" name="permission" value="許可する" <?php if (isset($permission) && $permission === '許可する') echo 'checked';?>>
                    許可する
                </label>
            </p>
            <input type="submit" value="問題に挑戦!(時間計測が開始されます)">
        </form>
        <p>
            <button type="button" onclick="location.href='ranking.php'">ランキングページへ</button>
        </p>
    </body>
</html>

2-1. セッション

ページ間を移動しても名前や難易度などの値を保持するために、セッションを利用します。
$_SESSION定義済みのスーパーグローバル関数で、ページ間で遷移しても値を保持させることができます。

これは 'スーパーグローバル' あるいは自動グローバル変数と呼ばれるものです。 スクリプト全体を通してすべてのスコープで使用することができます。
引用元:PHPマニュアル「PHP: $_SESSION - Manual

start.php
// セッションの開始
session_start();

2-1-1. セッションの開始

セッションを利用するにはsession_start()を記述します。
注意点として、こちらの関数は「ブラウザに何かを出力する前に」呼び出す必要があります。

クッキーに基づくセッションを使用している場合、ブラウザに何か出力を行う前に session_start() をコールする必要があります。
引用元:PHPマニュアル「PHP: session_start - Manual

この為、安全を見て<?php ?>タグ内の最上部に記述しています。
また、理由については下記質問が参考になります。

Teratail「なぜsession_startより前に何も出力があってはいけない?

2-1-2. セッションの放棄

<?php ?>タグ内の後半でセッションを放棄しています。

start.php
// セッション変数の初期化
$_SESSION = [];
setcookie(session_name(), '', time() - 1, '/');
session_destroy();

後述のバリデーションに引っ掛からなかった場合には
header()で別ページに遷移させ、exit()で後続処理を停止させていますが、
このページはデフォルトでセッションを放棄させるようにしています。

これによって

  • バリデーションに引っ掛かった
  • 初回のアクセスだった

場合にはセッションが残らないようになっています。

各コードの意味は以下です。

  • $_SESSION = []
    • セッション変数を全て初期化します。
  • setcookie(session_name(), '', time() - 1, '/');
    • セッション開始時にセッションIDクッキーに保存されるので、これも削除します。
  • session_destroy()
    • セッションに登録されたデータを全て破棄します。

PHPマニュアル「PHP: session_destroy - Manual

2つ目のsetcookie()関数ですが、

  • 第1引数 : クッキーの名前
  • 第2引数 : クッキーの
  • 第3引数 : クッキーの有効期限
  • 第4引数 : サーバー上でクッキーを有効としたいパス

となっています。

第1引数にsession_name()関数によって取得した現在のセッション名(デフォルトではPHPSESSID)、
第3引数にtime()関数による現在のUnixタイムスタンプから- 1した過去のタイムスタンプ(=有効期限切れ)
第4引数に/を指定することでサーバードメイン配下の全てのパス
を指定し,cookieを実質的に削除しています。

PHPマニュアル「PHP: setcookie - Manual
PHPマニュアル「PHP: session_name - Manual
PHPマニュアル「PHP: time - Manual

これらの処理はバリデーションを通過した場合には、
先述のexit()で処理が止められるので実行されません。

2-2. バリデーション

start.php
// 送信されたデータの検証
if (isset($_POST['name'], $_POST['difficulty'], $_POST['permission'])) {

    // 変数への代入
    $name = $_POST['name'];
    $difficulty = $_POST['difficulty'];
    $permission = $_POST['permission'];

    // 名前のバリデーション
    if (empty(trim($name))) {
        $error_msg = '名前に空白は無効です';
    } elseif (mb_strlen($name, 'UTF-8') > 10) {
        $error_msg = '名前は10字以内にしてください';
    } elseif ($name !== preg_replace('/\A[\p{C}\p{Z}]++|[\p{C}\p{Z}]++\z/u', '', $name)) {
        $error_msg = '名前の前後に空白文字や制御文字を含めないで下さい';
    }

    // 中略

}

2-2-1. 無効な送信の拒否

start.php
if (isset($_POST['name'], $_POST['difficulty'], $_POST['permission'])) {

    // 中略

}

まず、$_POST存在確認がされてから、値の検証されるようにしています。
これは、開発者ツールなどでフロントのフォームが改竄された場合の送信を無効とするためです。

2-2-2. 名前のバリデーション

start.php
    // 名前のバリデーション
    if (empty(trim($name))) {
        $error_msg = '名前に空白は無効です';
    } elseif (mb_strlen($name, 'UTF-8') > 10) {
        $error_msg = '名前は10字以内にしてください';
    } elseif ($name !== preg_replace('/\A[\p{C}\p{Z}]++|[\p{C}\p{Z}]++\z/u', '', $name)) {
        $error_msg = '名前の前後に空白文字や制御文字を含めないで下さい';
    }

入力された名前に対し、

  • 半角スペースのみ無効
  • 10文字以内
  • 前後に空白文字/制御文字を含めない

のバリデーションをかけています。

2-2-2-1. 半角スペースのみ無効

trim()関数はスペースを取り除く関数で、その結果が空だった場合にエラーメッセージを格納しています。

文字列の先頭および末尾にあるホワイトスペースを取り除く
引用元:PHPマニュアル「PHP: trim - Manual

ただし、こちらの関数は全角スペースには対応していないので更にバリデーションをかけます。

2-2-2-2. 文字数

mb_strlen()によって文字数をカウントし、
カウントが10を超えている場合にエラーメッセージを設定しています。

日本語にはマルチバイト文字なので、通常のstrlen()ではなく
マルチバイト対応mb_strlen()を用いています1

2-2-2-3. 空白文字/制御文字無効

名前の前後に空白文字や制御文字が含まれている場合を検知しています。
具体的には、「対象の文字列を空文字に変換する前と後の文字列が等しくない場合」を検知しています。
全角スペースのみもこちらで弾けます。

文字数判定の後にこの判定を入れているのは、
preg_replace()正規表現を使った重い処理であり、
文字数判定を後にしてしまうと何万文字と言う文字を送りつけられた場合に、
サーバーがダウンしてしまう可能性があるためです(ReDoS攻撃対策)。

これらについては下記参考記事もご参照下さい。

Qiita「【PHP】マルチバイト(全角スペース等)対応のtrim処理 - Qiita」 by @fallout さん
Qiita「正規表現の落とし穴(ReDoS - Regular Expressions DoS) - Qiita」 by @prograti さん

2-2-3. エラーメッセージの表示

start.php
        <?php if (isset($error_msg)): ?>
        <hr>
        <ul>
            <li><?= $error_msg; ?></li>
        </ul>
        <hr>
        <?php endif; ?>

エラーメッセージがある場合のみ、
<hr>タグで囲った中で<ul>タグ<li>タグを用いて
$error_msgに格納されたエラーメッセージを出力しています。

スタート画面_エラーメッセージ.png

2-2-4. 入力値の保持 - <input>タグ

start.php
        <form method="POST">
            <label>名前
                <small> (10字以内)</small><br>
                <input type="text" name="name" required value="<?php if (isset($name)) echo $name;?>">
            </label>
            <p>
                <span>難易度</span><br>
                <label>
                    <input type="radio" name="difficulty" value="易しい(絵文字)" required <?php if (empty($difficulty) || isset($difficulty) && $difficulty === '易しい(絵文字)') echo 'checked';?>>
                    易しい(絵文字)
                </label>
                <label>
                    <input type="radio" name="difficulty" value="難しい(漢字)" <?php if (isset($difficulty) && $difficulty === '難しい(漢字)') echo 'checked';?>>
                    難しい(漢字)
                </label>
            </p>
            <p>
                <span>ランキングへの登録</span><br>
                <label>
                    <input type="radio" name="permission" value="許可しない" required <?php if (empty($permission) || isset($permission) && $permission === '許可しない') echo 'checked';?>>
                    許可しない
                </label>
                <label>
                    <input type="radio" name="permission" value="許可する" <?php if (isset($permission) && $permission === '許可する') echo 'checked';?>>
                    許可する
                </label>
            </p>
            <input type="submit" value="問題に挑戦!(時間計測が開始されます)">
        </form>

データがPOSTされた場合、

  • $name
  • $difficulty
  • $permission

に入力値が格納されているので、
条件分岐で変数が定義されていた場合

  • textタイプの<input>タグではvalue属性に変数の値
  • radioタイプの<input>タグではchecked属性

をそれぞれechoすることで、
バリデーションで弾かれてページが再読み込みされた場合でも、
入力値を保持させることができます。

また、

  • $difficulty
  • $permission

が空だった場合(=初回訪問時)は、

  • 難易度「易しい(絵文字)」
  • ランキングへの登録「許可しない」

デフォルトで選択されるようになっています。

2-3. 問題表示ページへの遷移

start.php
    // セッション変数への格納と問題表示ページへの遷移
    if (empty($error_msg)) {
        $_SESSION['name'] = $name;
        $_SESSION['difficulty'] = $difficulty;
        $_SESSION['permission'] = $permission;
        header('Location:find_the_mistake.php');
        exit();
    }

先程のバリデーションをパスした場合は、
$error_msgにエラーメッセージが格納されないので、
この変数が空の場合は「バリデーションをパスした」ものと判断しています。

以降のページで名前と難易度とランキング登録への認否は使用するので、
$_SESSIONキーを設定して代入しています。

その後、header()によって次のファイルへと遷移させ、後続の処理をexit()により停止させています2


<< 前の記事 【①ゲームの概要】
1. 間違い探しゲームの概要
  1-1. ゲームの全体像
  1-2. 開発環境及びファイルの全体像
   1-2-1. 開発・動作確認済み環境
   1-2-2. 作成するファイル
    1-2-2-1. メインファイル
    1-2-2-2. サブファイル
  1-3. ソースコード
   1-3-1. メインファイルのソースコード
   1-3-2. サブファイルのソースコード
  1-4. 参考にさせて頂いた記事・サイトの一覧


次の記事 >> 【③問題表示ページを作る】
3. 問題表示ページの実装
  3-1. 無効なアクセスの拒否
  3-2. リセット回数の計測
  3-3. 問題用文字配列の設定
   3-3-1. 文字ペア配列の選択
   3-3-2. 文字ペア配列及び文字ペアのシャッフル
   3-3-3. 正解文字と不正解文字配列の設定
    3-3-3-1. 正解文字の設定
    3-3-3-2. 不正解文字配列の設定
   3-3-4. 問題用文字配列の生成
  3-4. 開始時刻の記録
  3-5. 選択肢の描写


  1. 実は少々不具合が残っており、「☺️」や「❤️」の場合、mb_stlen()は正しくカウントしてくれません(2倍にカウントされてしまう)。「?」や「?」は正しくカウントしてくれるのに謎です... 

  2. 後続の処理にセッションの放棄があるので、処理を止めないとせっかくセッションに保存した情報が初期化されてしまいます。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML・PHP・MySQLだけで作る間違い探しゲーム【①ゲームの概要】

1. 間違い探しゲームの概要

20×20のボタンの中から指定された文字が表示されたボタンを見つける、シンプルな間違い探しゲームです!解くのが簡単とは言っていない

以下の6つの記事で解説しています。


HTML・PHP・MySQLだけで作る間違い探しゲーム【①ゲームの概要】(本記事)
HTML・PHP・MySQLだけで作る間違い探しゲーム【①ゲームの概要】

1. 間違い探しゲームの概要
  1-1. ゲームの全体像
  1-2. 開発環境及びファイルの全体像
   1-2-1. 開発・動作確認済み環境
   1-2-2. 作成するファイル
    1-2-2-1. メインファイル
    1-2-2-2. サブファイル
  1-3. ソースコード
   1-3-1. メインファイルのソースコード
   1-3-2. サブファイルのソースコード
  1-4. 参考にさせて頂いた記事・サイトの一覧


HTML・PHP・MySQLだけで作る間違い探しゲーム【②スタートページを作る】
HTML・PHP・MySQLだけで作る間違い探しゲーム【②スタートページを作る】

2. スタートページの実装
  2-1. セッション
   2-1-1. セッションの開始
   2-1-2. セッションの放棄
  2-2. バリデーション
   2-2-1. 無効な送信の拒否
   2-2-2. 名前のバリデーション
    2-2-2-1. 半角スペースのみ無効
    2-2-2-2. 文字数
    2-2-2-3. 空白文字/制御文字無効
   2-2-3. エラーメッセージの表示
   2-2-4. 入力値の保持 - タグ
  2-3. 問題表示ページへの遷移


HTML・PHP・MySQLだけで作る間違い探しゲーム【③問題表示ページを作る】
HTML・PHP・MySQLだけで作る間違い探しゲーム【③問題表示ページを作る】

3. 問題表示ページの実装
  3-1. 無効なアクセスの拒否
  3-2. リセット回数の計測
  3-3. 問題用文字配列の設定
   3-3-1. 文字ペア配列の選択
   3-3-2. 文字ペア配列及び文字ペアのシャッフル
   3-3-3. 正解文字と不正解文字配列の設定
    3-3-3-1. 正解文字の設定
    3-3-3-2. 不正解文字配列の設定
   3-3-4. 問題用文字配列の生成
  3-4. 開始時刻の記録
  3-5. 選択肢の描写


HTML・PHP・MySQLだけで作る間違い探しゲーム【④DB接続設定ファイルを作る】
HTML・PHP・MySQLだけで作る間違い探しゲーム【④DB接続設定ファイルを作る】

4. DB接続設定
  4-1. PDOオブジェクトの属性
   4-1-1. フェッチ形式の指定
   4-1-2. エラーモードの設定
    4-1-2-1. エラーモードの違いによるエラー文の違い
   4-1-3. エミュレーションの設定
    4-1-3-1. プリペアドステートメント
    4-1-3-2. エミュレーション
  4-2. 例外発生時の処理
   4-2-1. HTTPヘッダの送信
   4-2-2. 処理の中断とエラーメッセージの表示
  4-3. DBの切断について


HTML・PHP・MySQLだけで作る間違い探しゲーム【⑤結果表示ページを作る】
HTML・PHP・MySQLだけで作る間違い探しゲーム【⑤結果表示ページを作る】

5. 結果表示ページ
  5-1. 無効なアクセスの拒否
  5-2. 回答時間の算出
  5-3. 各変数への格納
  5-4. 保存する回答時間とカウント数の上限設定
  5-5. 正解/不正解による表示メッセージの分岐
  5-6. ランキングへの登録
   5-6-1. ランキングテーブルの構成
   5-6-2. ランキングへの登録


HTML・PHP・MySQLだけで作る間違い探しゲーム【⑥ランキングページを作る】
HTML・PHP・MySQLだけで作る間違い探しゲーム【⑥ランキングページを作る】

6. ランキング表示ページ
  6-1. 表示切り替え機能
  6-2. 入力値の保持 - タグ
  6-3. ランキングの表示
   6-3-1. テーブルの表示
   6-3-2. テーブルデザイン

まず、どのようなものなのか概要を説明します。
(サーバー構築やDB構築に関しては本記事群では扱いません)

1-1. ゲームの全体像

初期画面にアクセスすると、

  • 名前の入力欄
  • 難易度の選択ラジオボタン
  • ランキングへの登録認否の選択ラジオボタン

が表示されます。
スタート画面.png

後で解説しますが、名前入力欄には

  • 空白文字のみ無効
  • 10文字以内
  • 名前の前後に空白文字/制御文字無効

のバリデーションを入れており、引っかかった場合はエラーが表示されます。
スタート画面_エラーメッセージ.png
適切な名前を入力し、「送信」をクリックすると選択した難易度に応じた問題が表示されます。
問題ページ_易しい.png
問題ページ_難しい.png
正解・不正解を問わず、ボタンをクリックすると結果表示ページへ遷移します。
結果には

  • 正解か不正解か
  • 回答に要した時間
  • 難易度
  • リセット回数

が表示されます。

結果ページ.png
結果表示ページの「ランキングページへ」をクリックすると回答時間ランキング表示画面へ遷移します。
ランキングページ.png
ランキングは難易度ごとに切り替えることもできます。

1-2. 開発環境及びファイルの全体像

1-2-1. 開発・動作確認済み環境

  • PHP 7.1.29 (cli)
  • MySQL Ver 14.14 Distrib 5.6.44

1-2-2. 作成するファイル

メインとなる5つと補助的な2つの合計7つです。そこまで数が多くないのとファイル名のみ(相対パス)で参照できるようにする為、全て同一ディレクトリ内に配置します。

1-2-2-1. メインファイル

  • start.php
    • プレイヤーに名前/難易度/ランキング登録認否を選択してもらうページを生成するファイルです。
  • find_the_mistake.php
    • 問題を作成・表示するファイルです。
    • 20×20の文字が書かれたボタンを出現させ、その中に1つだけ正解を紛れ込ませます。
  • db_connect.php
    • DB接続設定用のファイルです。
    • 下記result.phpとranking.phpで使用します。
  • result.php
    • 回答が正解していたかどうかを示す結果画面です。
    • 回答時間とリセット回数をカウントする機能を実装し、その結果も示します。
  • ranking.php
    • ランキングを表示するファイルです。
    • ここでは難易度別にランキングを表示切り替えできる機能も実装します。

1-2-2-2. サブファイル

  • create_ranking_table.sql
    • ランキングデータを保存するテーブルを作成するSQL文を記述したSQLファイルです。
  • style.css
    • ランキングページのテーブルの見た目を整えるCSSファイルです。

1-3. ソースコード

GitHubで見たい方はこちらからどうぞ。

1-3-1. メインファイルのソースコード

start.php
<?php

// セッションの開始
session_start();

// 送信されたデータの検証
if (isset($_POST['name'], $_POST['difficulty'], $_POST['permission'])) {

    // 変数への代入
    $name = $_POST['name'];
    $difficulty = $_POST['difficulty'];
    $permission = $_POST['permission'];

    // 名前のバリデーション
    if (empty(trim($name))) {
        $error_msg = '名前に空白は無効です';
    } elseif (mb_strlen($name, 'UTF-8') > 10) {
        $error_msg = '名前は10字以内にしてください';
    } elseif ($name !== preg_replace('/\A[\p{C}\p{Z}]++|[\p{C}\p{Z}]++\z/u', '', $name)) {
        $error_msg = '名前の前後に空白文字や制御文字を含めないで下さい';
    }

    // セッション変数への格納と問題表示ページへの遷移
    if (empty($error_msg)) {
        $_SESSION['name'] = $name;
        $_SESSION['difficulty'] = $difficulty;
        $_SESSION['permission'] = $permission;
        header('Location:find_the_mistake.php');
        exit();
    }
}

// セッションの放棄
$_SESSION = [];
setcookie(session_name(), '', time() - 1, '/');
session_destroy();

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1>間違い探し</h1>
        <p>20×20個のボタンが表示されるので、指定されたボタンを見つけ出して押しましょう!</p>
        <?php if (isset($error_msg)): ?>
        <hr>
        <ul>
            <li><?= $error_msg; ?></li>
        </ul>
        <hr>
        <?php endif; ?>
        <form method="POST">
            <label>名前
                <small> (10字以内)</small><br>
                <input type="text" name="name" required value="<?php if (isset($name)) echo $name;?>">
            </label>
            <p>
                <span>難易度</span><br>
                <label>
                    <input type="radio" name="difficulty" value="易しい(絵文字)" required <?php if (empty($difficulty) || isset($difficulty) && $difficulty === '易しい(絵文字)') echo 'checked';?>>
                    易しい(絵文字)
                </label>
                <label>
                    <input type="radio" name="difficulty" value="難しい(漢字)" <?php if (isset($difficulty) && $difficulty === '難しい(漢字)') echo 'checked';?>>
                    難しい(漢字)
                </label>
            </p>
            <p>
                <span>ランキングへの登録<small>(正解だった場合のみ)</small></span><br>
                <label>
                    <input type="radio" name="permission" value="許可しない" required <?php if (empty($permission) || isset($permission) && $permission === '許可しない') echo 'checked';?>>
                    許可しない
                </label>
                <label>
                    <input type="radio" name="permission" value="許可する" <?php if (isset($permission) && $permission === '許可する') echo 'checked';?>>
                    許可する
                </label>
            </p>
            <input type="submit" value="問題に挑戦!(時間計測が開始されます)">
        </form>
        <p>
            <button type="button" onclick="location.href='ranking.php'">ランキングページへ</button>
        </p>
    </body>
</html>
find_the_mistake.php
<?php

// セッションの再開
session_start();

// 無効なアクセスの拒否
if (empty($_SESSION)) {
    header('Location:start.php');
    exit();
}

// リセット回数の計測開始
if (isset($_SESSION['count'])) {
    $_SESSION['count']++;
} else {
    $_SESSION['count'] = 0;
}

// ターゲット配列の設定
if ($_SESSION['difficulty'] === '難しい(漢字)') {
    $chars = [
        ['猫', '描'],
        ['犬', '大'],
        ['幸', '辛'],
        ['白', '臼'],
        ['矢', '失'],
        ['力', '刀'],
        ['防', '妨'],
        ['土', '士'],
        ['卵', '卯'],
        ['巨', '臣'],
        ['寒', '塞'],
        ['旅', '族'],
        ['車', '東'],
        ['釘', '針']
    ];
} else {
    $chars = [
        ['&#x1f415;', '&#x1f408;'],
        ['&#x1f405;', '&#x1f406;'],
        ['&#x1f98e;', '&#x1f40d;'],
        ['&#x1f433;', '&#x1f42c;'],
        ['&#x1f339;', '&#x1f337;'],
        ['&#x1f34a;', '&#x1f34b;'],
        ['&#x1f34e;', '&#x1f351;'],
        ['&#x1f955;', '&#x1f336;'],
        ['&#x1f96f;', '&#x1f95e;'],
        ['&#x1f358;', '&#x1f359;'],
        ['&#x1f341;', '&#x1f342;'],
        ['&#x1f332;', '&#x1f333;'],
        ['&#x1f47b;', '&#x1f47d;'],
        ['&#x1f396;', '&#x1f3c5;']
    ];
}

// ターゲット配列のシャッフル
shuffle($chars);
for ($i = 0; $i < count($chars); $i++) {
    shuffle($chars[$i]);
}

// 正解と選択対象配列を設定
$correct = $chars[0][0];
$_SESSION['correct'] = $correct;
for ($j = 0; $j <= 19; $j++) {
    for ($k = 0; $k <= 19; $k++) {
        $targets[$j][$k] = $chars[0][1];
    }
}

// 正解のターゲットを選択対象配列に1つだけ挿入
$key = range(0, 19);
$key1 = array_rand($key);
$key2 = array_rand($key);
$targets[$key1][$key2] = $correct;

// 開始時刻を記録
$_SESSION['start_time'] = microtime(true);

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1><?= $correct; ?>を見つけよう!</h1>
        <p>
            <small>難易度: <?= $_SESSION['difficulty']; ?></small>
            <button type="button" onclick="location.href='find_the_mistake.php'">分かるか!(リセット)</button>
            <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        </p>
        <form action="result.php" method="POST">
            <table>
                <?php foreach ($targets as $target): ?>
                <tr>
                    <?php for ($l = 0; $l < count($target); $l++): ?>
                    <td><input type="submit" name="answer" value="<?= $target[$l]; ?>"></td>
                    <?php endfor; ?>
                </tr>
                <?php endforeach; ?>
            </table>
        </form>
    </body>
</html>
db_connect.php
<?php

// 定数定義
const PDO_DSN = 'mysql:host=localhost;dbname=[FILTERED];charset=utf8mb4';
const USERNAME = '[FILTERED]';
const PASSWORD = '[FILTERED]';

// DB接続
try {
    $dbh = new PDO(PDO_DSN, USERNAME, PASSWORD, [
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
} catch (PDOException $e) {
    header('Content-Type: text/plain; charset=UTF-8', true, 500);
    exit('DB接続に失敗しました' . PHP_EOL . $e->getMessage() . PHP_EOL);
}
result.php
<?php

// セッションの開始
session_start();

// 無効なアクセスの拒否
if (empty($_SESSION) || empty($_POST)) {
    header('Location:start.php');
    exit();
}

// 回答時間を算出
$end_time = microtime(true);
$start_time = $_SESSION['start_time'];
$time = sprintf('%05.2f', $end_time - $start_time) . '秒';

// 回答を変数に格納
$answer = $_POST['answer'];

// セッション変数を変数に格納
$name = $_SESSION['name'];
$difficulty = $_SESSION['difficulty'];
$permission = $_SESSION['permission'];
$correct = $_SESSION['correct'];
$count = sprintf('%02d', $_SESSION['count']) . '回';

// セッションの放棄
$_SESSION = [];
setcookie(session_name(), '', time() - 1, '/');
session_destroy();

// 回答時間が100秒以上の場合は値を上書き
if ($time > 100) {
    $time = '100秒以上';
}

// リセット回数が100回以上の場合は値を上書き
if ($count > 100) {
    $count = '100回以上';
}

// 正解・不正解によるメッセージの分岐
if (html_entity_decode($correct) === $answer) {
    $result = '正解です!';
} else {
    $result = '不正解です。。。';
}

// 正解かつ許可されていた場合のみDBに登録
if ($result === '正解です!' && $permission === '許可する') {

    // db接続
    require_once('db_connect.php');

    // 新規登録処理
    $sql = 'INSERT INTO rankings (name, difficulty, time, reset) VALUES (?, ?, ?, ?)';
    $stmt = $dbh->prepare($sql);
    $stmt->execute([$name, $difficulty, $time, $count]);
}

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>間違い探し</title>
    </head>
    <body>
        <h1><small>結果は...<?= $result ?></small></h1>
        <h2><small>難易度: <?= $difficulty; ?></small></h2>
        <h2><small>回答時間: <?= $time; ?></small></h2>
        <h2><small>リセット回数: <?= $count; ?></small></h2>
        <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        <button type="button" onclick="location.href='ranking.php'">ランキングページへ</button>
    </body>
</html>
ranking.php
<?php

// 難易度がPOSTされている場合は変数に格納
if (isset($_POST['show_method'])) {
    $select = $_POST['show_method'];
} else {
    $select = 'all';
}

// db接続
require_once('db_connect.php');

// 選択された表示形式によってSQL文を分岐
$sql = 'SELECT * FROM rankings ';
switch ($select) {
    case 'difficult':
        $sql .= "WHERE difficulty = '難しい(漢字)' ORDER BY time LIMIT 10";
        break;
    case 'easy':
        $sql .= "WHERE difficulty = '易しい(絵文字)' ORDER BY time LIMIT 10";
        break;
    case 'all':
        $sql .= 'ORDER BY time LIMIT 10';
        break;
}
$stmt = $dbh->query($sql);
$players = $stmt->fetchAll();

?>
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="style.css">
        <title>間違い探し ランキング</title>
    </head>
    <body>
        <h1>回答時間ランキング</h1>
        <form method="POST">
            <select name="show_method" size="1">
                <option value="all" <?php if ($select === 'all') echo 'selected' ?>>全て</option>
                <option value="easy" <?php if ($select === 'easy') echo 'selected' ?>>易しい</option>
                <option value="difficult" <?php if ($select === 'difficult') echo 'selected' ?>>難しい</option>
            </select>
            <input type="submit" value="表示">
        </form>
        <div class="table">
            <table class="s-tbl">
                <thead>
                    <tr>
                        <th>順位</th>
                        <th>名前</th>
                        <th>難易度</th>
                        <th>回答時間</th>
                        <th>リセット回数</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($players as $key => $player): ?>
                    <tr>
                        <td><?= ++$key; ?></td>
                        <td><?= $player['name']; ?></td>
                        <td><?= $player['difficulty']; ?></td>
                        <td><?= $player['time']; ?></td>
                        <td><?= $player['reset']; ?></td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <p>
            <button type="button" onclick="location.href='start.php'">スタートページへ</button>
        </p>
    </body>
</html>

1-3-2. サブファイルのソースコード

create_rankings_table.sql
CREATE TABLE `rankings` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL,
  `difficulty` varchar(10) NOT NULL,
  `time` varchar(10) NOT NULL,
  `reset` varchar(15) NOT NULL,
  PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
style.css
.table {
  margin-top: 10px;
}
.s-tbl {
  border-collapse: collapse;
}
.s-tbl th, .s-tbl td {
  border: 1px solid #000;
  padding: 0.5em;
}
.s-tbl tr:nth-child(even) {
  background: #eee;
}
.s-tbl tr:hover {
  background: #ffffe0;
}

1-4. 参考にさせて頂いた記事・サイトの一覧

もちろん、公式マニュアル! RTFM!
PHPマニュアル「PHP: PHP マニュアル - Manual

PDOによるDB接続に関して体系的に書かれており非常に勉強になりました
Qiita「PHPでデータベースに接続するときのまとめ - Qiita」 by @mpyw さん

クラスの基礎から例外処理までとても丁寧に解説されておりとても参考になりました
Qiita「【PHP超入門】クラス~例外処理~PDOの基礎 - Qiita」 by @7968 さん

エミュレータのON/OFFに関する疑問が解決されました
Teratail「`PDO::ATTR_EMULATE_PREPARES => false`は必要か?

フロントに関する事なら外せないドキュメント
MDN web docs「開発者向けのウェブ技術 | MDN

ずっと疑問に思っていたことが解消されました
Teratail「なぜsession_startより前に何も出力があってはいけない?

正規表現について新たな知見を得られました
Qiita「【PHP】マルチバイト(全角スペース等)対応のtrim処理 - Qiita」 by @fallout さん

ReDoSに関してとても勉強になりました
Qiita「正規表現の落とし穴(ReDoS - Regular Expressions DoS) - Qiita」 by @prograti さん

絵文字のHTMLエンティティを調べるのに活用させて頂きました
Let's EMOJI「Unicode 13.0 絵文字 (Unicode 13.0 Emoji) | Let's EMOJI

sprintfに関してとても詳細に解説されています
Let'sプログラミング ~初心者の方を対象としたプログラミングの総合学習サイト~
指定の形式にフォーマット(sprintf) - 文字列関数 - PHP関数

文字コードと照合順序に関しとても勉強になりました
Qiita「寿司ビール問題① 初心者→中級者へのSTEP20/25 - Qiita」 by @kamohicokamo さん

この記事のおかげで命名の迷いがなくなりました
Qiita「データベースオブジェクトの命名規約 - Qiita」 by @genzouw さん

テーブルのデザインの参考にさせていただきました
いつか誰かの役に立つかもしれないweb制作屋の備忘録
css tableで背景色を交互に変える方法

どの記事・ページもとても参考になりました!
この場を借りて感謝を申し上げます?


次の記事 >> 【②スタートページを作る】
2. スタートページの実装
  2-1. セッション
   2-1-1. セッションの開始
   2-1-2. セッションの放棄
  2-2. バリデーション
   2-2-1. 無効な送信の拒否
   2-2-2. 名前のバリデーション
    2-2-2-1. 半角スペースのみ無効
    2-2-2-2. 文字数
    2-2-2-3. 空白文字/制御文字無効
   2-2-3. エラーメッセージの表示
   2-2-4. 入力値の保持 - タグ
  2-3. 問題表示ページへの遷移

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPのjson_encode/json_decodeはそのまま使わない方が良い

PHPで配列をJSON形式の文字列に変換する際に利用するjson_encode、またはJSON形式の文字列を配列に変換するjson_decodeの標準関数はそのまま使わないほうが良いと、という投稿です。
PHP玄人の皆さんには常識かと思われますが、意外と知られていない気がしたので共有です。

そのまま使うとどうなるのか

ドキュメントを参照すると次のように説明されています

json_encode

成功した場合に、JSON エンコードされた文字列を返します。 失敗した場合に FALSE を返します。

json_decode

json でエンコードされたデータを、適切な PHP の型として返します。 true、false および null はそれぞれ TRUE、FALSE そして NULL として返されます。 json のデコードに失敗したり エンコードされたデータが再帰制限を超えているなどの場合、NULL を返します。

失敗しても例外にはならず、それぞれfalsenullが返却されます。
これをちゃんとハンドリングして実装していれば問題ないですが、うっかり失敗していたら例外になってくれるんじゃないかと淡い期待をしていると意図しないコードになっている可能性があります。(ちゃんと静的解析してれば問題ないんじゃないですかね)

エラーだった場合、原因が知りたい

エンコード、デコードでエラーがあった場合はjson_last_errorで直近のエラーを調べることができます。
また、json_last_error_msgでエラーメッセージが確認できます。

それ、Guzzleのヘルパー関数が使えるよ

GuzzleといえばPHPでHTTP APIをコールする際にはなくてはならないライブラリ(個人の感想)ですが、このライブラリに用意されているラッパー関数を使うとこの辺のハンドリングが施されています。

\GuzzleHttp\json_encode@7.2.0
function json_encode($value, int $options = 0, int $depth = 512): string
{
    return Utils::jsonEncode($value, $options, $depth);
}

public static function jsonEncode($value, int $options = 0, int $depth = 512): string
{
    $json = \json_encode($value, $options, $depth);
    if (\JSON_ERROR_NONE !== \json_last_error()) {
        throw new InvalidArgumentException('json_encode error: ' . \json_last_error_msg());
    }

    /** @var string */
    return $json;
}
\GuzzleHttp\json_decode@7.2.0
function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
    return Utils::jsonDecode($json, $assoc, $depth, $options);
}

public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
    $data = \json_decode($json, $assoc, $depth, $options);
    if (\JSON_ERROR_NONE !== \json_last_error()) {
        throw new InvalidArgumentException('json_decode error: ' . \json_last_error_msg());
    }

    return $data;
}

失敗していたら例外になってくれるんじゃないか
という淡い期待に答えてくれます。

追記

PHP玄人さんたちが色々教えてくれたので追記

PHP 7.3以降の場合

json_encode/json_decodeのオプションにJSON_THROW_ON_ERRORを指定することでJsonExceptionを発生させることが出来ます。
単純にこれをラップして使うだけでも良さそう。

SafePHPを使う

thecodingmachine/safe
そもそもGuzzleはHTTP APIをコールする際に利用するライブラリなので、こういう専用のライブラリを使うべきですね。こんなライブラリがあるのを知らなかったですw

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

composer2.xでLaravelが動かなくなったら

第1の問題

composer2.xがリリースされ、次第にcomposerの2系がビルドの際に落ちてくるようになりました。
その際、以下のようなエラーが出てきたかもしれません。

> @php artisan package:discover --ansi

In PackageManifest.php line 122:

  Undefined index: name

解決するには

Laravelのパッチバージョンを上げれば解決します。

いくつかのpull requestが各バージョンに対して行われています
https://github.com/laravel/framework/pull/32310

なぜこうなったのか

これはcomposer2.xvendor/composer/installed.jsonの形式が少し変わったせいです。

今まではpackagesがjson配列で定義されているだけでした。

[
    {
        "name": "asm89/stack-cors",
        "version": "v2.0.1",
        "version_normalized": "2.0.1.0",
        ...
    },
...
}

こちらが変更されpackagesというキーが上につくようになりました。

{
    "packages": [
        {
            "name": "barryvdh/laravel-debugbar",
            "version": "v3.2.8",
            "version_normalized": "3.2.8.0",
            ...
        },
    ]
...
}

影響はこのせいで、先程のPRでは$packages = $installed['packages'] ?? $installed;とすることで、packagesというキーがあればそれを使うように修正がされているのです。

第2の問題

一部の人だけですが、updateを行おうとするとこんな感じでエラーが出てくるかもしれません。

$ composer update --no-plugins
Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Root composer.json requires davejamesmiller/laravel-breadcrumbs 5.x, found davejamesmiller/laravel-breadcrumbs[5.3.1] in lock file but not in remote repositories, make sure you avoid updating this package to keep the one from lock file.

これは私の場合はですが、packagist.jpを設定していたからでした。

まずはこれを外します

composer config -g --unset repos.packagist

これで私はupdateをすることが出来ましたが、jpを使いたい人はいると思います。
そんなときはcomposer.jsonのrepositoriesで設定をしましょう。

composer.json
    "repositories": [
        {
            "type": "composer",
            "url": "https://packagist.jp",
            "cannonical": false
        }
    ]

cannonical: falseは2.xからのオプションで公式のではないリポジトリを定義するためのものです。
packagist.jp自体は有志の方が作られたものであるため、falseにします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MySQL インストールから接続(Symfony)までの流れ

環境

  • mysql Ver 8.0.22 for osx10.15 on x86_64 (Homebrew)
  • プロジェクトディレクトリのターミナルと、MySQLにログイン状態のターミナル(MySQLインストール後)2つを並べて多くと効率的
  • SQLにはrootユーザーで接続の場合

プロジェクトの必要に応じて接続ユーザーは考慮してください。

MySQLインストール

brew install mysql

MySQL起動

mysql.server start

MySQLログイン

mysql -u root
ログインユーザーの root は初期導入後はパスワードが設定されていないため、パスワード無しでログインできる。

MySQL導入時に存在するDBを確認

SHOW DATABASES;

+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

導入時に既にいくつかDBの確認があるがけさないこと。

MySQL rootユーザーのパスワード設定

現在のログインユーザーのrootのパスワードを確認

mysql DBへ切り替える

use mysql;でユーザー 情報が保存しているDBに切り替える

userテーブルを表示

フィールドがたくさんあるので以下のフィールドを指定
※以前のバージョンはPasswordフィールドが合ったようだが8.0にはないので注意
SELECT Host, User FROM mysql.user;

+-----------+------------------+
| Host      | User             |
+-----------+------------------+
| localhost | mysql.infoschema |
| localhost | mysql.session    |
| localhost | mysql.sys        |
| localhost | root             |
+-----------+------------------+

パスワード設定

mysql> ALTER USER 'root'@'localhost' IDENTIFIED BY 'root-password';でユーザー 情報が保存しているDBに切り替える
※root-passwordに設定したいパスワードを記入

MySQL ログアウトしてログインしてみる(確認)

ログアウト
exit
ログイン
mysql -u root -p
先程パスワードを設定したので必ず「-p」をつける。パスワード入力できる状態になるので入力してログイン。

新規にDBを作成する (必要な場合)

Doctrine経由で作成するのだが、MySQL8.0で認証方法の設定を変更しておく必要があるので先に行う。

rootユーザーの認証方法を確認

  • SQLにログイン mysql -u root -p
  • mysql DBに切り替えるuse mysql
  • userテーブルのpluginフィールドを確認SELECT user, host, plugin FROM user;
+------------------+-----------+-----------------------+
| user             | host      | plugin                |
+------------------+-----------+-----------------------+
| mysql.infoschema | localhost | caching_sha2_password |
| mysql.session    | localhost | caching_sha2_password |
| mysql.sys        | localhost | caching_sha2_password |
| root             | localhost | caching_sha2_password |
+------------------+-----------+-----------------------+
4 rows in set (0.00 sec)
  • caching_sha2_passwordをmysql_native_passwordに変更
    ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root-password';

  • 設定が変わっているか確認
    SELECT user, host, plugin FROM user;

+------------------+-----------+-----------------------+
| user             | host      | plugin                |
+------------------+-----------+-----------------------+
| mysql.infoschema | localhost | caching_sha2_password |
| mysql.session    | localhost | caching_sha2_password |
| mysql.sys        | localhost | caching_sha2_password |
| root             | localhost | mysql_native_password |
+------------------+-----------+-----------------------+
4 rows in set (0.00 sec)

参考記事: https://blog.janjan.net/2018/11/01/mysql8-request-authentication-method-unknown-to-the-client/

Symfonyの設定ファイルを変更

.envファイルを変更。まだこのプロジェクト用のDBはないが次のステップで作成するので、(db_name)にはこれから作るDB名を入力しておく。

編集前

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7

編集後

DATABASE_URL=mysql://root:(最初の方で設定したrootのパスワード)@127.0.0.1:3306/(db_name)

Doctrine経由でDB作成

php bin/console doctrine:database:create;する。以下表示されればOK。確認したい人はSQL側でshow databases;で確認。

Created database `(db_name)` for connection named default

注意:以下エラーが出た場合は、SQL側で接続ユーザーの認証方法をmysql_native_passwordにしないとエラーが出るので2つ前の設定をしっかりすること。

SQLSTATE[HY000] [2054] The server requested authentication method unknown to the client

Doctrine経由でテーブル作成

ざっくりメモ
- php bin/console make:entityEntityファイルを作成(プロパティを決めます)
- php bin/console make:migrationマイグレーションファイル作成(DBに反映する設定情報のファイルを作ります)
- php bin/console doctrine:migrations:migrateマイグレーション実行(DBに変更を反映します)
- テーブルが新規作成もしくは情報が更新される
- SQLにログインしているターミナルでshow tables;でちゃんとテーブルが作られているか確認

+------------+---------------+------+-----+---------+----------------+
| Field      | Type          | Null | Key | Default | Extra          |
+------------+---------------+------+-----+---------+----------------+
| id         | int           | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255)  | NO   |     | NULL    |                |
| zip_code   | int           | NO   |     | NULL    |                |
| address    | varchar(255)  | NO   |     | NULL    |                |
| open_time  | int           | NO   |     | NULL    |                |
| close_time | int           | NO   |     | NULL    |                |
| map        | varchar(1000) | NO   |     | NULL    |                |
+------------+---------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)
  • SQLにログインしているターミナルでDESCRIBE (テーブル名);でちゃんとフォールドが設定されているか確認
+------------+---------------+------+-----+---------+----------------+
| Field      | Type          | Null | Key | Default | Extra          |
+------------+---------------+------+-----+---------+----------------+
| id         | int           | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255)  | NO   |     | NULL    |                |
| zip_code   | int           | NO   |     | NULL    |                |
| address    | varchar(255)  | NO   |     | NULL    |                |
| open_time  | int           | NO   |     | NULL    |                |
| close_time | int           | NO   |     | NULL    |                |
| map        | varchar(1000) | NO   |     | NULL    |                |
+------------+---------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Composer本体のバージョンを1系に固定化する

2020年10月24日 Composerバージョン2がリリースされました?
https://blog.packagist.com/composer-2-0-is-now-available

しかし、最新版では今まで動いていたComposerプロジェクトが動作しなくなったりまだ安定化できていなさそうなので、一旦以前の1系のバージョンに戻します。

公式のインストーラ経由

https://getcomposer.org/download

$ curl -sS https://getcomposer.org/installer | php -- --version=1.10.15
$ mv composer.phar /usr/local/bin/composer

公式のDockerイメージ経由

https://hub.docker.com/_/composer

現在、 latest1.10.15 を指してるのでlatestを指定していた場合は1系の最新版がインストールされます。

いずれは2系がインストールされるようになると思うので、
今後も1系の最新版で固定化するには下記のように指定しておきます。

COPY --from=composer:1.10.15 /usr/bin/composer /usr/bin/composer

また、イメージタグの方はメジャーバージョンのみ固定など柔軟にバージョン指定が行えます。

COPY --from=composer:1 /usr/bin/composer /usr/bin/composer

関連する記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LaravelでPDFを出力する

概要

Laravelでpdfを扱うケースがあったので、Laravel-mpdfを導入した。
その時の手順を記載する。

dompdfとSnappyも検討したが、以下の理由により断念。

  • dompdfは使えないcssが多すぎる。
  • Snappyは必要なパッケージが多すぎてインストールが難しい。

docker環境のLaravelでSnappyを利用しようと考えましたが、パッケージが足りてなくて利用できなかった…。
本番環境でもパッケージのインストールが必要になると考えると、自分の知識では難しいと判断。

laravel-mpdfのインストール

composer require carlos-meneses/laravel-mpdf

プロバイダーとファサードの追加

config/app.phpに以下のprovidersとaliasesに以下を追加する。

'providers' => [
    // ...
    Meneses\LaravelMpdf\LaravelMpdfServiceProvider::class
]
'aliases' => [
    // ...
    'PDF' => Meneses\LaravelMpdf\Facades\LaravelMpdf::class
]

config

config/pdf.phpを作成して以下を記載する。

return [
    'mode'                 => '',
    'format'               => 'A4',
    'default_font_size'    => '12',
    'default_font'         => 'sans-serif',
    'margin_left'          => 10,
    'margin_right'         => 10,
    'margin_top'           => 10,
    'margin_bottom'        => 10,
    'margin_header'        => 0,
    'margin_footer'        => 0,
    'orientation'          => 'P',
    'title'                => 'Laravel mPDF',
    'author'               => '',
    'watermark'            => '',
    'show_watermark'       => false,
    'watermark_font'       => 'sans-serif',
    'display_mode'         => 'fullpage',
    'watermark_text_alpha' => 0.1,
    'custom_font_dir'      => '',
    'custom_font_data'     => [],
    'auto_language_detection'  => false,
    'temp_dir'               => rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR),
    'pdfa'          => false,
        'pdfaauto'      => false,
];

フォント

今のままでは日本語を扱えないのでフォントを追加して、読み込むようにする。
以下のリンクからIPAフォントをダウンロードする。

https://moji.or.jp/ipafont/

ダウンロードしたフォントをresources/fonts/ipag.ttfに配置する。
config.phpのreturn文に以下を追加する。

'custom_font_dir' => base_path('resources/fonts/'),
'custom_font_data' => [
  'ipafont' => [
    'R'  => 'ipag.ttf',
  ]
]

cssに以下を追加する。
※追加しなくても日本語表示できたので、自動判別されている?

body {
    font-family: 'examplefont', sans-serif;
}

サンプル

ビューは、resources/views/pdfs/test.blade.phpが配置されているとする。

<?php

namespace App\Http\Controllers;

use PDF;
use Illuminate\Http\Request;

class PdfsController extends Controller
{
  public function test (Request $req)
  {
    $data = [
      'name' => $req->name,
      'age' => $req->age,
    ];
    $pdf = PDF::loadView('pdfs.test', $data);

    // 表示させる場合
    // return $pdf->stream('document.pdf');

    // ダウンロードさせる場合
    return $pdf->download('test.pdf');
  }
}

参考サイト

https://github.com/mccarlosen/laravel-mpdf

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

phpunit テスト実行時にエラー General error: 1 no such table テーブル名

エラー内容

tarminal
SQLSTATE[HY000]: General error: 1 no such table: users ...................
........
.....

usersテーブルが無いよ!って返事が北

テーブルあるけどなぁ、、、、
ggったらphpunit.xmlの設定を変えると改善するとのこと

phpunit.xml
    <php>
        <server name="APP_ENV" value="testing"/>
        <server name="BCRYPT_ROUNDS" value="4"/>
        <server name="CACHE_DRIVER" value="array"/>
        <server name="DB_CONNECTION" value="sqlite"/><!--使用しているDB言語に変更-->
        <server name="DB_DATABASE" value=":memory:"/><!--使用しているDB名に変更-->
        <server name="MAIL_DRIVER" value="array"/>
        <server name="QUEUE_CONNECTION" value="sync"/>
        <server name="SESSION_DRIVER" value="array"/>
    </php>


tarminal
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 4.47 seconds, Memory: 24.00 MB

OK (3 tests, 7 assertions)

無事テスト成功しました?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

素のPHPしか触ってなかった人がlaravelを触ってみる

初書:2020/10/26
PC:macOS 10.15.7

タイトル通りですが、素のphp以外でライブラリもフレームワークも使ったことがないんですが、
よく見かけるしちょっと気になったので触ってみようと思った次第。

前提

・composer、homebrewインストール済み

Larabelとは

Laravel は、MVCのWebアプリケーション開発用の無料・オープンソースのPHPで書かれたWebアプリケーションフレームワークである。

Wikipediaより
つまりフレームワークである。
どうやら今phpで一番人気があるフレームワークらしい。

早速使って・・・

みようと思ったのだが、ググってみると
「laravelのインストールから表示まで」か
「機能を追加してみる」という感じの記事が多く、
フレームワークが何かすらなんとなくしか分からなかった自分からすると全然使い方が分からん。

ので、結構調べてみると、次の記事を見つけた
Laravel入門 - 使い方チュートリアル -

これが結構分かりやすかったのだが、フレームワークとは先に用意された一式の中に、自分でコードを書いて作っていくという形なので、今までの素のphpのように書いていくわけではない模様。

インストール

ちなみにcomposerというものを使用するので、composerを先に入れる必要がある。
脳死でインストールする場合は以下をどうぞ

% brew install composer

その後composer -vを実行して、バージョン情報が表示されればインストール完了

そしてlaravelをダウンロードする
公式サイトはこちら

ローカルにのみインストールをする方法もありますが、グローバルにインストーラーをダウンロードして、それをローカルにインストールする方法もあるみたいなので、お好きな方でインストールしてください

% composer global require laravel/installer # これはグローバルにインストール
% laravel new application # アプリケーションの作成。"application"はお好きな名前に

バージョン確認をする

% php artisan --version
Laravel Framework 8.11.2

8.11.2だそうです。

とりあえずサーバーを立てる

% php artisan serve

これで、Laravel development server started: http://127.0.0.1:8000のような感じで表示されるので、後半のurlにアクセスすると、laravelのページが表示される

スクリーンショット 2020-10-23 22.34.58.png
調べたら出てくる、よくみるページでは無い、だと、、、

どうやらversion8からデフォルト画面が変わったっぽい
起動できているのに間違いはなさそうなので、これでよしとする。右下にバージョンも書いてるし。

設定する(.env)

調べてみるとこんなページが。
Laravel8をインストールしたらやっていること一覧
保存版!Laravelの.envでできること大全
.envファイルとconfig/app.phpに色々と設定できる項目があるらしい。せっかくなので設定しておく

とりあえず、データベース周りと言語周り、時刻関連は設定しておいた。

機能を追加していく

本当はこれ以降に記述する予定だったのだが、思ったより長くなりそうなのでそれぞれ記事を作成して、ここにはリンクだけ貼る、という形にする。

とりあえず新規ページを作成する

laravel8 とりあえずページを作成してみる
ページを作らないことには始まらないと思うので

ログイン機能をつけてみる

Laravel8でログイン機能を実装する
jetstream - Livewire を使用したログイン機能周りの実装

まとめ

まだまだ手探りの状態なので、間違い等があれば、その時は指摘をお願いします。
また「機能を追加していく」の欄は随時追加していく予定です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

最強のLaravel開発環境(Docker)を日本時間にする

はじめに(開発環境構築)

https://qiita.com/ucan-lab/items/5fc1281cd8076c8ac9f4

こちらの神リポジトリをcloneし、利用しようとしたときに、
TimeZoneがUTCだったため、日本時間に修正した際の備忘録となります。

下記構成内のDockerfileを修正します。
コンテナを立ち上げたことがあるものに対して実施する際には、
コンテナ停止時にDockerfileを修正し、再ビルドを実施してください。

ディレクトリ構成

├── backend # Laravelプロジェクトのルートディレクトリ
├── infra
│     └── docker
│          ├── mysql
│          │   ├── Dockerfile
│          │   └── my.cnf
│          ├── nginx
│          │   ├── Dockerfile
│          │   └── default.conf
│          └── php
│              ├── Dockerfile
│              ├── php-fpm.d
│              │   └── zzz-www.conf => unixドメインソケットの設定ファイル
│              └── php.ini
├── Makefile
└── docker-compose.yml

mysql

修正箇所

docker-laravel\infra\docker\mysql\Dockerfile
# ENV TZを変更する(4行目付近)
# ENV TZ=UTC \
ENV TZ=Asia/Tokyo \

確認方法

build、コンテナ立ち上げ(up)が完了後、の作業になります。

shell
$ make db
# $ docker-compose exec db bash (上記makeコマンドの内容)
root@XXXXXXX:# mysql -u root -p -h 127.0.0.1
Enter password: secret
# Dockerfileに記載されている8行目付近のPWを記入してください。
#  MYSQL_ROOT_PASSWORD=secret
#                       ̄ ̄ ̄ ̄
mysql> show variables like '%time_zone%';
+------------------+--------+
| Variable_name    | Value  |
+------------------+--------+
| system_time_zone | JST    |
| time_zone        | SYSTEM |
+------------------+--------+
2 rows in set (0.01 sec)

system_time_zoneにJSTが表示されれば問題ありません。

nginx

修正箇所

docker-laravel\infra\docker\nginx\Dockerfile
# ENV TZを変更する(6行目付近)
# ENV TZ=UTC
ENV TZ=Asia/Tokyo

確認方法

build、コンテナ立ち上げ(up)が完了後、の作業になります。

shell
$ make web
# $ docker-compose exec web ash  (上記makeコマンドの内容)
/work/backend# date

現在時間が表示されれば問題ありません。

php

修正箇所

docker-laravel\infra\docker\php\Dockerfile
# timezone environmentを変更する(5行目付近)
# ENV TZ=UTC \
  # locale
#  LANG=en_US.UTF-8 \
#  LANGUAGE=en_US:en \
#  LC_ALL=en_US.UTF-8 \
ENV TZ=Asia/Tokyo \
  # locale
  LANG=ja_JP.UTF-8 \
  LANGUAGE=ja_JP:ja \
  LC_ALL=ja_JP.UTF-8 \
docker-laravel\infra\docker\php\Dockerfile
# localの設定を変更(36行目付近)
#  locale-gen en_US.UTF-8 && \
#  localedef -f UTF-8 -i en_US en_US.UTF-8 && \

  locale-gen ja_JP.UTF-8 && \
  localedef -f UTF-8 -i ja_JP ja_JP.UTF-8 && \

確認方法

build、コンテナ立ち上げ(up)が完了後、の作業になります。

shell
$ make app
# $ docker-compose exec app bash (上記makeコマンドの内容)
root@XXXXXXX:/work/backend# php -r 'echo date("Y/m/d H:i:s"),PHP_EOL;'

現在時間が表示されれば問題ありません。

最後に

以上で対応完了となります。
誰かの一助になれば幸いです。

誤りやもっと良い改修方法があればコメントにてご教示いただけますと幸いです。
よろしくお願いいたします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[PHP][DatePeriod]一定の期間の中から特定の曜日を取り出す、とかが超便利になるクラス

概要

みなさん、DateTime関数は使っていますか?
カレンダーに、ToDoの日付計算に、日付表示部分をいい感じに表示するのに……
システム開発において、DateTime関数は避けることのできないクラスでしょう。
そんなDateTimeクラスと関連のある、超便利なクラスの紹介です。

ちょっと待って!それforループするの!?

PHPソースコードでカレンダーの出力をする場合、適当に調べるとこのような処理が出てきたりします。

//タイムゾーン設定
date_default_timezone_set('Asia/Tokyo');

//表示させる年月を設定 ↓これは現在の月
$year = date('Y');
$month = date('m');

//月末日を取得
$end_month = date('t', strtotime($year.$month.'01'));

$aryCalendar = [];

//1日から月末日までループ
for ($i = 1; $i <= $end_month; $i++){
    $aryCalendar[$i]['day'] = $i;
    $aryCalendar[$i]['week'] = date('w', strtotime($year.$month.sprintf('%02d', $i)));
}

一ヶ月分だけなら単純な処理ですね。
では、表示を2か月区切りに…3か月……いや、一年区切りで……と伸びたとき、どうしますか?
一か月だけだとしてもそもそも業務上の区切り日は15日終わり、16日開始でありそれに合わせる場合はどうしましょうか?

なんと、簡単に解決します!そうDatePeriodならね。

詳しいドキュメントは以下。
https://www.php.net/manual/ja/class.dateperiod.php

例えば一か月だけならこんな感じです。

$now = new \DateTime();

$startDate = \DateTime::createFromFormat("Y-m-d", $now->format("Y-m-") . "1")->setTime(0,0,0);
$endDate = \DateTime::createFromFormat("Y-m-d", $now->format("Y-m-t"))->setTime(23,59,59);

$interval = new \DateInterval('P1D');
$datePeriod = new \DatePeriod($startDate, $interval, $endDate);

$aryCalendar = [];
foreach($datePeriod as $datetime) {
    $aryCalendar[$datetime->format("Y-m-d")] = [
        "day" => $datetime->format("d"),
        "week" => $datetime->format("D")
    ];
}

とりあえずDateTimeクラスで開始日と終了日さえ決めてしまえば、DatePeriodの時点でDateTimeクラスの配列のように利用することのできるクラスを出力することができます。
これで15日締めでも簡単!
さらに、日付同士の間隔はDateIntervalで決定しているため、一週間毎・一か月毎のような案件でも簡単に作成することができます。

みなさんもDatePeriodを使って簡単な日付管理ライフを!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

laradock + atom + xdebug でPHPをステップ実行する

はじめに

こんにちは、普段は業務で求人系サービスの開発や社内向けツールの開発を行なっている@taku-0728です。
今回は laradock + atom + xdebug でPHPのステップ実行を行います。
これができればわざわざprint文を仕込んでデバッグしたりすることがなくなりますので、作業効率の向上につながります。同じように laradock + atom で開発を行っている方は是非参考にしていただければと思います。

やること

下の画像のように処理の途中で実行を一時停止しその時点での変数の中身を確認したり、コードを1行ずつ実行して次にどの条件分岐に入るのか確認したりできるようにしていきます。

ステップ実行 (2).gif

開発環境

  • OS:macOS Catalina 10.15.6
  • PHP:7.4.11
  • Laravel:6.18.26
  • Docker:19.03.13

前提条件

Laravelプロジェクトを作成していること
laradockで必要なコンテナを立ち上げてブラウザからアクセスできること

実装

1. laradockの設定

まずlaradockの必要な設定を行います。
laradockのファイルが置いてあるディレクトリに遷移します。

$ cd xxxxxxx/laradock

envファイルを編集します。

$ vi .env
.env
WORKSPACE_INSTALL_XDEBUG=false

PHP_FPM_INSTALL_XDEBUG=false

この2行を

.env
WORKSPACE_INSTALL_XDEBUG=true

PHP_FPM_INSTALL_XDEBUG=true

こう変えます。
次に設定ファイルも変更します。

$ vi workspace/xdebug.ini
workspace/xdebug.ini
; NOTE: The actual debug.so extention is NOT SET HERE but rather (/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini)

xdebug.remote_host="host.docker.internal"
xdebug.remote_connect_back=0
xdebug.remote_port=9000
xdebug.idekey=PHPSTORM

xdebug.remote_autostart=0
xdebug.remote_enable=0
xdebug.cli_color=0
xdebug.profiler_enable=0
xdebug.profiler_output_dir="~/xdebug/phpstorm/tmp/profiling"

xdebug.remote_handler=dbgp
xdebug.remote_mode=req

xdebug.var_display_max_children=-1
xdebug.var_display_max_data=-1
xdebug.var_display_max_depth=-1

内容に多少差異はあると思いますが、大体こんな感じになっていると思います。
ここを下記のように書き換えます。
コメントアウトしていますが、別に消してもらってもかまいません。

workspace/xdebug.ini
; NOTE: The actual debug.so extention is NOT SET HERE but rather (/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini)

xdebug.remote_host=host.docker.internal
xdebug.remote_connect_back=0
xdebug.remote_port=9002
;xdebug.idekey=PHPSTORM

xdebug.remote_autostart=1
xdebug.remote_enable=1
;xdebug.cli_color=0
;xdebug.profiler_enable=0
;xdebug.profiler_output_dir="~/xdebug/phpstorm/tmp/profiling"

xdebug.remote_handler=dbgp
xdebug.remote_mode=req

;xdebug.var_display_max_children=-1
;xdebug.var_display_max_data=-1
;xdebug.var_display_max_depth=-1

似たようなことをもう一回やります。

$ vi php-fpm/xdebug.ini
php-fpm/xdebug.ini
; NOTE: The actual debug.so extention is NOT SET HERE but rather (/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini)

xdebug.remote_host="host.docker.internal"
xdebug.remote_connect_back=0
xdebug.remote_port=9000
xdebug.idekey=PHPSTORM

xdebug.remote_autostart=0
xdebug.remote_enable=0
xdebug.cli_color=0
xdebug.profiler_enable=0
xdebug.profiler_output_dir="~/xdebug/phpstorm/tmp/profiling"

xdebug.remote_handler=dbgp
xdebug.remote_mode=req

xdebug.var_display_max_children=-1
xdebug.var_display_max_data=-1
xdebug.var_display_max_depth=-1

ここも大体こんな感じになっていると思いますので、下記のように書き換えます。
コメントアウトしていますが、別に消してもらってもかまいません。

php-fpm/xdebug.ini
; NOTE: The actual debug.so extention is NOT SET HERE but rather (/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini)

xdebug.remote_host=host.docker.internal
xdebug.remote_connect_back=0
xdebug.remote_port=9002
;xdebug.idekey=PHPSTORM

xdebug.remote_autostart=1
xdebug.remote_enable=1
;xdebug.cli_color=0
;xdebug.profiler_enable=0
;xdebug.profiler_output_dir="~/xdebug/phpstorm/tmp/profiling"

xdebug.remote_handler=dbgp
xdebug.remote_mode=req

;xdebug.var_display_max_children=-1
;xdebug.var_display_max_data=-1
;xdebug.var_display_max_depth=-1

ここまでできたら、コンテナを起動します。自分の環境にあわせて適宜変えてください。

$ docker-compose up -d nginx mysql redis

※私の環境ではxdebug.iniの設定を変更する前にコンテナを起動していた場合、ステップ実行が正常に動作しませんでした。色々試しましたが最終的に一度イメージを削除して再度コンテナを起動すると正常に動作したので、もし正常に動作しなかった場合は一度イメージを削除して再度コンテナを起動してみてください。

ここまでいけば、laradock側の設定は終了です。

2. atom側の設定

laradock側の設定が終わったので、次にatom側の設定を行っていきます。
理由は後述しますがここからは使用するPHPのバージョンによってやることが変わるので、使用しているPHPのバージョンに合わせて変えてください。

使用しているPHPのバージョンが7.0以下の場合

パッケージの検索画面でphp-debugと検索し、下記の画像と同じヒットしたパッケージをインストールしてください。
スクリーンショット 2020-10-13 22.21.01.png

インストールすると、下記の画像のようにatom-debug-uiというパッケージと、atom-ide-uiというパッケージをインストールするように求められると思います。この2つのパッケージの役割は今回の記事の内容とは逸れるので割愛しますが、インストールしたphp-debugを動作させるには必須のパッケージですのでYesを選択してインストールしてください。
スクリーンショット 2020-10-13 22.21.43.png

php-debug,atom-debug-ui,atom-ide-uiの3つのパッケージをインストールしたらphp-debugの設定画面を開いてください。
まずはPortの設定を行います。
下記の画像を参考にServer Listen Portを9002番(php-fpm/xdebug.ini xdebug.remote_port, workspace/xdebug.ini xdebug.remote_portと同じ値)に設定してください。
スクリーンショット 2020-10-13 22.32.58.png
次にPath Mappingsの下記の形式を参考にremoteとlocalのパスの設定を行います。
[{"remotePath":"","localPath":""}]
remotePathにはdockerコンテナ内のファイルパスを、localPathにはMacbook内のファイルパスを指定します。
私の場合だと下記のようになります。当然ですが、ご自身の環境に合わせて変えてください。
スクリーンショット 2020-10-13 22.44.06.png

使用しているPHPのバージョンが7.1以上の場合

インストールするphp-debugのバージョンを変更します。理由はphp-debug 3.5以上の場合に必須となるatom-ide-uiにあります。このパッケージはatomにideのような様々な機能を持たせてくれる便利なパッケージなのですが、そのパッケージの機能の1つであるエラー解析がPHP7.0までしか対応していません。そのためPHP7.4から使える下記のような書き方をすると、エラーでないにもかかわらず警告が表示されてしまいます。

スクリーンショット 2020-10-14 0.16.29.png

そのままでも動くといえば動くのですが、警告がうっとうしいのでatom-ide-uiを必要としない古いバージョンのphp-debugをインストールしていきます。
私の環境がMacなので、Macを例にしてやっていきます。
左上のメニューから Atom>シェルコマンドをインストール を選択します。すでにインストールしていて、apmコマンドが使える方は飛ばしてください。
スクリーンショット 2020-10-14 0.29.22.png

シェルコマンドのインストールが完了すると、apmコマンドが使えるようになるのでターミナルを開いて下記コマンドを入力します。

$ apm install php-debug@0.2.4

インストールが完了したら、下記の画像を参考に Path Maps と Server Address を設定してください。
インストールした php-debug のバージョンが古いため、remotePathとlocalPathの形式が異なっています。下記画像を参考に間違えないようにしてください。
スクリーンショット 2020-10-14 1.00.51.png

結果

ステップ実行ができるようになったので、快適なPHPライフを送りましょう。
ステップ実行 (2).gif

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

phpモジュールにbrotli入れてbrコンテンツを使う!!

ブラウザーのコンテンツの圧縮でよく聞いているのがgzipだと思います。今回UberEatsで使用しようとしたAPIがJson形式だけど、拡張子が「.json.br」でbrotliで圧縮されてそれをそのままだと利用できなかったので、それを解決する方法をまとめた内容です。

Brotliとは

簡単に調べると「https://hackers-high.com/linux/brotli-installation-effect/」でこんな感じで説明されています。

brotliは2015年に発表され、その後Googleによってアップデートされたデータ圧縮アルゴリズムです。

httpにおける圧縮アルゴリズムとして使われることを主な目的としています。従来から広く使われているgzipと比較して、圧縮率が向上していながら、圧縮/伸長速度は同程度を維持しています。ただし、SSL/TLSが必須となっています。どの程度圧縮率が向上しているのかは、Brotilの効果を参照。

圧縮に辞書を併用しているのが特徴で、辞書には"<div/>"、"before"、"普通"などの頻繁に使われるHTMLタグや各言語の表現が約1万語入っており、圧縮をより効率的にしています。

ブラウザーの開発ツール等で見える以下のHeader情報brのことです。

accept-encoding: gzip, deflate, br

対応状況

brotli

Fedora / CentOS / RHEL

Remi's RPM repositoryを利用して「php-brotli」の名前でインストールが可能です。

centosの例
# yum install -y php-brotli

その他

Remi's RPM repositoryを利用出来ない場合はbuildしてインストールします。

# git clone --recursive --depth=1 https://github.com/kjdev/php-ext-brotli.git
# cd php-ext-brotli
# phpize
# ./configure
# make
# make install

設定

php.ini及モジュールのディレクトリーに設定ファイルを作成します。

extension=brotli.so

Dockerイメージのphp-fpm

インストールする際にはインストール前に「apt-get update」が必要があります。

FROM php:7.1-fpm
...
RUN apt-get update && apt-get install -y git && \
    git clone --recursive --depth=1 https://github.com/kjdev/php-ext-brotli.git && \
    cd php-ext-brotli && \
    phpize && \
    ./configure && \
    make && \
    make install && \
    printf '%s\n' 'extension=brotli.so'  >> /usr/local/etc/php/conf.d/brotli.ini && \
    rm -rf php-ext-brotli
...

確認

phpのメソードについて:https://github.com/kjdev/php-ext-brotli

$compressed = brotli_compress('Compresstest');

$uncompressed = brotli_uncompress($compressed);

echo $uncompressed;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Laravel】開発環境では「php artisan config:cache」をするべきではない

Q. なぜ?

A. ドキュメントにそう書いてある。

The command should not be run during local development as configuration options will frequently need to be changed during the course of your application's development.

(意訳)アプリケーションの開発中には設定を頻繁に変更する必要があるので、ローカル開発環境ではこのコマンドは実行するべきではありません。

Q. そう書いてある、では納得できない!

A. 一度php artisan config:cacheを実行すると、それ以降(キャッシュを消去しない限り)configディレクトリ配下の設定ファイルを修正するたびにphp artisan config:cacheを実行しなければ設定が反映されなくなるため。

解説

とある機能を実装しようとして、サンプルコードを実装してみたところ設定が反映されずなぜか動かない。。php artisan config:cacheもしくはphp artisan config:clearをしたら動きました!という経験、一度はありませんか?
これは前述した通り、一度設定ファイルをキャッシュするとキャッシュされた設定ファイルを優先的に読みにいくのでconfigディレクトリ配下の設定ファイルが読み込まれないために発生します。
ドキュメントにも記載がある通り開発中は設定ファイルが頻繁に編集されるため、その度にコマンドを打って反映させるのは煩雑です。そのため開発環境では設定ファイルをキャッシュするべきではないのです。

結論から言ってしまえば以上!なのですが、この記事ではほんの少しLaravel内部のコードを追うことで設定ファイルがどう読み込まれているかを見ていきます。

設定ファイルの読み込み

設定ファイルの読み込みは/Illuminate/Foundation/Bootstrap/LoadConfiguration.phpのbootstrapメソッド内で行われています。

Illuminate/Foundation/Bootstrap/LoadConfiguration.php
~~省略~~
class LoadConfiguration
{
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        $items = [];

        // First we will see if we have a cache configuration file. If we do, we'll load
        // the configuration items from that file so that it is very quick. Otherwise
        // we will need to spin through every configuration file and load them all.
        if (file_exists($cached = $app->getCachedConfigPath())) {
            $items = require $cached;

            $loadedFromCache = true;
        }

        // Next we will spin through all of the configuration files in the configuration
        // directory and load each one into the repository. This will make all of the
        // options available to the developer for use in various parts of this app.
        $app->instance('config', $config = new Repository($items));

        if (!isset($loadedFromCache)) {
            $this->loadConfigurationFiles($app, $config);
        }
~~省略~~

まずif (file_exists($cached = $app->getCachedConfigPath()))でキャッシュされたファイルがあるかどうか確認します。
$app->getCachedConfigPath()は特に設定を変更していない場合{your-project-path}/bootstrap/cache/config.phpという文字列が返されます。

実際にphp artisan config:cacheを打つとbootstrap/cache配下にconfig.phpが生成されているのがわかるかと思いますが、これこそが上述したキャッシュされた設定ファイルです。このconfig.phpは全設定をArray形式で返します。このファイルをrequireして\$items変数に代入し、$item変数を元にRepositoryクラス(/Illuminate/Config/Repository.php)をnewします。これでキャッシュがある場合の読み込みが完了です。

設定値にアクセスするためのヘルパ関数であるconfig()は、このRepositoryインスタンスのgetメソッドを呼び出すことで設定値を返してくれるのです。

helpers.php
    function config($key = null, $default = null)
    {
        if (is_null($key)) {
            return app('config');
        }

        if (is_array($key)) {
            return app('config')->set($key);
        }

        return app('config')->get($key, $default);
    }

if (file_exists($cached = $app->getCachedConfigPath()))がfalse、つまり設定ファイルが見つからない場合は\$itemsは空のままRepositoryクラスがnewされたあとに、$this->loadConfigurationFiles()によって設定値が読み込まれます。

Illuminate/Foundation/Bootstrap/LoadConfiguration.php
    /**
     * Load the configuration items from all of the files.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @param  \Illuminate\Contracts\Config\Repository  $repository
     * @return void
     *
     * @throws \Exception
     */
    protected function loadConfigurationFiles(Application $app, RepositoryContract $repository)
    {
        $files = $this->getConfigurationFiles($app);

        if (!isset($files['app'])) {
            throw new Exception('Unable to load the "app" configuration file.');
        }

        foreach ($files as $key => $path) {
            $repository->set($key, require $path);
        }
    }

このメソッドの内部ではconfigディレクトリ配下のファイルからkeyとvalueを取り出し、repositoryインスタンス(先ほどnewしたRepositoryクラスのインスタンス)のsetメソッドによって設定値を一つづつ読み込んでいます。

要するに、キャッシュのあるなしでは読み込むファイルが異なるのです。
- キャッシュがある(bootsrap/cache/config.phpが存在する)場合
- bootstrap/cache/config.phpが返す配列を読み込む
- キャッシュがない(bootsrap/cache/config.phpが存在しない)場合
- configディレクトリ配下の各phpファイルから配列を読み込む

ここで、一度php artisan config:cacheをして設定ファイルをキャッシュした後に、configディレクトリ配下のphpファイル(例えばauth.phpやmail.phpなど)の値を変更した場合はどうなるでしょうか?

Laravelはphp artisan config:cacheによって生成されたbootstrap/cache/config.phpを読み込みますが、変更前の値しか保持していないので当然変更は反映されません。この場合、再度php artisan config:cacheを打ってbootstrap/cache/config.phpを再生成するか、php artisan config:clearを打ってbootstrap/cache/config.phpを削除し、configディレクトリ配下の各phpファイルを読みにいくようにすることで設定が反映されるようになります。

以上、Laravelで設定がどのように読み込まれるか、php artisan config:cachephp artisan config:clearをすることで設定ファイルが反映される流れを解説しました。

ちなみにドキュメントによるとプロダクション環境ではパフォーマンス向上のためにphp artisan config:cacheをするべきだとしています。

To give your application a speed boost, you should cache all of your configuration files into a single file using the config:cache Artisan command. This will combine all of the configuration options for your application into a single file which will be loaded quickly by the framework.

You should typically run the php artisan config:cache command as part of your production deployment routine.

まとめ

  • 開発環境で作業している場合
    • php artisan config:cacheは実行しない
    • すでにphp artisan config:cacheを実行済みでbootstrap/cache/config.phpが生成されている場合はphp artisan config:clearで削除する
  • プロダクション環境にデプロイした場合
    • php artisan config:cacheで設定ファイルをキャッシュする

それではHappyなLaravelライフを?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

getimagesize()を使って画像を判別する場合も注意しなければいけない

はじめに

先日、exif_imagetype()だけで画像かどうかを判別してはいけないというエントリを書いたが、その後、別のWordPressプラグインで同様の脆弱性が発見された。そちらではgetimagesize()を使っていたので取り上げる。

概要

脆弱性の見つかったプラグインはPowerPressというポッドキャストのためのプラグイン。画像のふりをしたPHPスクリプトをアプロードし、任意のコードを実行することが可能だが、脆弱性を利用するためにはサイト管理者の権限が必要なため、サイトの運用によってはそこまで危険性は無い。

解説

PoCは以下の通りhttps://wpscan.com/vulnerability/10427 で、脆弱性の修正箇所は https://plugins.trac.wordpress.org/changeset/2396808 になる。

PoCの一部を見ると、POSTする値に以下のような記述がある。ここから、PNGに偽装したup.phpというファイルをアップロード、実行させようとしていることが分かる。

PoCの一部
"------WebKitFormBoundary5Ac7Ayyi2qVtiLqA\r\n" + 
"Content-Disposition: form-data; name=\"rss2_image_file\"; filename=\"up.php\"\r\n" + 
"Content-Type: image/png\r\n" + 
"\r\n" + 
"\x89PNG\r\n" + 
"\x1a\n" + 
"\x3c?php phpinfo(); ?\x3e\r\n" + 
"------WebKitFormBoundary5Ac7Ayyi2qVtiLqA--\r\n";

このup.phpをファイルに切り出すと以下のようになる。

$ hexdump up.php
0000000 5089 474e 0a0d 0a1a 3f3c 6870 2070 6870
0000010 6970 666e 286f 3b29 3f20 3e3e
000001c

$ file up.php
up.php: PNG image data, 1885957734 x 1864902971, 32-bit

プラグインの中では、$ImageData = @getimagesize($temp);として取得した値を利用して画像かどうかを判定してしまっているが、PHP.netのドキュメントにも以下のように記載されている。

警告
この関数は、filename が適切な画像ファイルであることを想定しています。 画像以外のファイルを渡してもそれを画像だと判断してしまい、関数の処理は成功するでしょう。 しかし、配列には意味のない値が含まれる場合があります。

getimagesize() を使って、そのファイルが画像であるかどうかを確かめることはできません。 そのようなことをしたい場合は、そのために用意されたソリューション (Fileinfo 拡張モジュールなど) を使いましょう。

getimagesize('up.php');で得られる値は以下のようになる。

array(6) {
  [0]=>
  int(1885957734)
  [1]=>
  int(1864902971)
  [2]=>
  int(3)
  [3]=>
  string(38) "width="1885957734" height="1864902971""
  ["bits"]=>
  int(32)
  ["mime"]=>
  string(9) "image/png"
}

なお、PHP.netの注意書き通りにFileinfoを利用しても、今回は以下のようになってしまうため、代替とはならない。

string(47) "PNG image data, 1885957734 x 1864902971, 32-bit"

結局、当該プラグインではアップロードを許可するファイルの拡張子を制限することで対応している。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ラッコにはCakePHPのテーブルのリレーションがわからない

久しぶりにテーブルを新しく作っていたら、「あれ、リレーションって全部でいくつあったけ?」「なんか辞儀通りじゃないものもあったよな?」と思い調査

https://book.cakephp.org/3/ja/orm/associations.html
はい、困ったときはドキュメント様

1 対 1 hasOne
1 対 多 hasMany
多 対 1 belongsTo
多 対 多 belongsToMany

「ああそうそうこれこれ」
「ただ、どっちのテーブルファイルに書けばいいのかモヤッとしてるとこあるのよね」

hasOne

Users hasOne Addressesのときは

addresses.user_idのようにもたれている方にidのカラムを追加

belongsTo

Addresses belongsTo Usersのときは

addresses.user_idのようにもたれている側に追加

hasMany

Article hasMany Commentのときは

Comment.article_idのようにもたれている方に追加

belongsToMany

このときは互いに互いのIDを持っていると

あれ?何かこれではうまくいかないパターンがあったはず。

思い出したら追記します。しばしお餅を

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む