【PHP】N+1問題をLaravel Debugbarを使って検証してみる【Laravel】
N+1問題とは
【Ruby on Rails】N+1問題ってなんだ? - Qiita
1回のクエリ発行でN件のレコードを取得し、
それぞれN件のレコードが持っているリレーション先のテーブルのレコード
を取得しようとする(N回のクエリ発行)とこのN+1問題が起こる。
具体例
実際に以下のようなリレーションを持つテーブルがあったとする。
membersテーブルにて外部キーをclub_idとしてclubsテーブルとリレーションを作る。
テーブルに対応したモデルを作る。
Member.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Member extends Model { use HasFactory; public function Club_content() { return $this->hasOne('App\Models\Club','id','club_id'); } }
Club.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Club extends Model { use HasFactory; }
ルーティング
<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\testnplus1; Route::get('/',[testnplus1::class,'index'])->name('test');
そして以下のようなコントローラーとブレードを書いてN+1問題を起こす。
testnplus1.php
<?php namespace App\Http\Controllers; use App\Models\Member; class testnplus1 extends Controller { // public function index() { $members = Member::all(); return view('showname',['members' => $members]); } }
blade
@foreach ($members as $member) <div> <p>{{$member->name}}</p> <p>{{$member->Club_content->name}}</p> </div> @endforeach
そしてlaravel Debugbarを利用してどんなSQLクエリが実行されたか確認してみる。すると以下のような結果となった。
まずコントローラ内のMember::all()はテーブル内のレコードを全て取得するメソッドなので、上記SQLの"select * from members"に対応している。
問題なのはそれ以下のSQLクエリである。ブレードの繰り返し処理内の"$member->Club_content->name"を実行するたびにクエリを実行することとなるのでデータベースに負担をかけてしまうこととなる。今回は5、6個のレコードだけでテストしているが、このレコード数が大きくなればなるほどデータベースへの負荷を高めてしまうこととなる。
このN+1問題を解決するためにイーガーロードを利用する。イーガーロードを利用することでN回行ったSQLクエリを1回のSQLクエリにまとめることができる。
イーガーロードを利用するにはwithメソッドを使用する。
Eloquent:リレーション 8.x Laravel
コントラーのコードを以下のように変更する。
<?php namespace App\Http\Controllers; use App\Models\Member; class testnplus1 extends Controller { // public function index() { //N+1問題が発生するコード //$members = Member::all(); //イーガーローディングによりN+1問題を防ぐコード $members = Member::with('Club_content')->get(); return view('showname',['members' => $members]); } }
Laravel Debugbarを使ってクエリの発行数を見てみると7→2になっている。
これでデータベースへの負荷を下げることができる。
機能実現だけでなく、パフォーマンスのことも考えることが大切だな〜。
【PHP】issetとemptyについてのメモメモ♪
いつも感覚的に使っちゃてたissetとemptyについてドキュメントやQiitaを見ながら再確認。
isset
https://www.php.net/manual/ja/function.isset.php
変数が宣言されており、かつ その値がnullではない
→true
そうでない場合
→false
empty
※ !isset($x) || $x==false と同じ意味
https://www.php.net/manual/ja/function.empty.php
変数が存在しない場合 や 値がfalseに等しい場合
→true
そうでない場合
→false
値がfalseに等しいとはどういう意味か?PHPは動的型付け言語なので名前通り動的に型付けがされる。
<?php var_dump((bool) ""); // bool(false) var_dump((bool) "0"); // bool(false) var_dump((bool) 1); // bool(true) var_dump((bool) -2); // bool(true) var_dump((bool) "foo"); // bool(true) var_dump((bool) 2.3e5); // bool(true) var_dump((bool) array(12)); // bool(true) var_dump((bool) array()); // bool(false) var_dump((bool) "false"); // bool(true) ?>
PHP: 論理型 (boolean) - Manual より引用
Qiitaにあった使えそうな早見表
PHP isset, empty, is_null の違い早見表 - Qiita
【PHP】オートロードとクラスインポート
PHPのオートロード(autoload) - Qiita
【PHP】Composerを使用してクラスのオートロードを行う | Points & Lines
【PHP超入門】名前空間(namespace・use)について - Qiita
laravel使ってると何でuseだけでクラスが使えるようになるの?
結論autoload(オートロード)とクラスインポートの組み合わせ。
フルスクラッチで挙動について確認してみる。
今回のディレクトリの構成。
オートロードを使うためにcomposerを利用する。
composer.jsonにて
{ "autoload": { "psr-4": { "app\\": "./" } } }
と記述しcomposer dumpautoloadを実行する。上記コードは名前空間とするapp と 現在のディレクトリ(.)を紐付けている。
ディレクトリと同様の構造の名前空間を指定することでオートロード対象となる。
そのためクラスファイル(ファイル名とクラス名が同じファイル)に名前空間に上記で指定した「app」+ 「クラスファイルがあるまでのディレクトリ構造」
を指定する。なので以下のようになる。
test1.php
<?php namespace app\a; Class Test1 { function printa() { echo "a!!!"; } }
test2.php
<?php namespace app\b; class Test2{ function printb() { echo "b!!!"; } }
そして以下のように利用
try.php
<?php require_once("vendor/autoload.php"); use app\a\Test1; use app\b\Test2; (new Test1)->printa(); (new Test2)->printb();
それぞれのファイルをrequireしていないことがわかる。またuseを使いクラスをインポートし、コードが綺麗になる。
オートロードを勉強することでlaravelのコードの見方が変わるかも。
【PHP】名前空間とエイリアスについて再確認の巻
参考
【PHP超入門】名前空間(namespace・use)について - Qiita
PHP: 名前空間 - Manual
以下のようなコードだとエラーが起きる
try.php
<?php require_once 'lebron.php'; require_once 'curry.php'; shoot();
lebron.php
<?php function shoot() { echo '2ポイントシュート'; }
curry.php
<?php function shoot() { echo '3ポイントシュート'; }
shoot()と言われてもlebronのshootか、curryのshootか分からない(名前の衝突が起こっている)のでエラーとなる。なので名前空間を利用してエラーを回避する。
try.php
<?php require_once 'lebron.php'; require_once 'curry.php'; lebron\shoot(); curry\shoot();
lebron.php
<?php namespace lebron; function shoot() { echo '2ポイントシュート'; }
curry.php
<?php namespace curry; function shoot() { echo '3ポイントシュート'; }
こんな感じにディレクトリ感覚で指定もできる。
try.php
<?php namespace nba; require_once 'lebron.php'; require_once 'curry.php'; //修飾形式 lakers\forward\shoot(); warriers\guard\shoot(); //完全修飾形式 \nba\lakers\forward\shoot(); \nba\warriers\guard\shoot();
lebron.php
<?php namespace nba\lakers\forward; function shoot() { echo '2ポイントシュート'; }
curry.php
<?php namespace nba\warriers\guard; function shoot() { echo '3ポイントシュート'; }
<?php namespace sports\basketball\nba\lakers\forward; function shoot() { echo '2ポイントシュート'; }
try.php
<?php require_once 'lebron.php'; require_once 'curry.php'; sports\basketball\nba\lakers\forward\shoot();
長い名前空間の時いちいち書くのがめんどくさいので、useを使ってエイリアスを作成し楽をする。
<?php require_once 'lebron.php'; require_once 'curry.php'; //やり方① use sports\basketball\nba\lakers\forward as king; //やり方② use sports\basketball\nba\warriers\guard; king\shoot(); guard\shoot();
【PHP】ファイル操作そんなに勉強したことなかったので、基本を勉強する
ここでのファイルポインタとは
- ファイルを操作するための変数
- ファイルを編集するための現在位置
fopen
PHP: fopen - Manual
ファイルまたはurlを開く。返り値はファイルポインタを返す。失敗した場合はfalseを返す。
fclose
PHP: fclose - Manual
ファイルを閉じる
fgets
PHP: fgets - Manual
ファイルから1行取得する
引数にファイルポインタを指定し、そのファイルから一行づつデータを取得し、文字列として返す。読み込むデータがない場合やエラーが起こった場合にfalseを返す。
<?php //開ける $fp = fopen('test.txt','r'); echo fgets($fp); //閉じる fclose($fp); ?>
fwrite
ファイルに書き込む
<?php //modeにwを指定 $fp = fopen('test.txt','w'); fwrite($fp,'書き込み!'); fclose($fp); ?>
ファイルに追加で書き込む
<?php //modeにaを指定 $fp = fopen('test.txt','a'); fwrite($fp,'追加で書き込み!'); fclose($fp); ?>
file_get_contents
PHP: file_get_contents - Manual
ファイルの全ての内容を取得する。返り値は読み込んだデータ。失敗した場合はfalseを返す。PHPは動的型付けのため失敗しなかった場合でも==を使うと思わぬ挙動となる可能性があるため条件分岐を行う際には===を使用する。
<?php $data = file_get_contents('test.txt'); //一行ずつ文字列を配列に代入 $array = explode("\n",$data); var_dump($array); //出力結果 //array(2) { [0]=> string(9) "一行目" [1]=> string(9) "二行目" } ?>
file_put_contents
PHP: file_put_contents - Manual
文字列をファイルに書き込む。この関数はfopen()、fwrite()、fclose()を使ってファイルに書き込むのと同じ。
<?php //文字列パターン file_put_contents('test.txt','こんにちは!'); //配列パターン file_put_contents('test.txt',[1,2,3,4,5]); //12345 となる //このコードだと上書きされてしまう。 ?>
fgetcsv
csvファイルから1行ずつ取得する。返り値は,で区切った配列。
<?php $fp = fopen('test.csv','r'); var_dump(fgetcsv($fp)); var_dump(fgetcsv($fp)); //array(3) { [0]=> string(6) "lebron" [1]=> string(4) "melo" [2]=> string(4) "wade" } array(3) { [0]=> string(4) "bosh" [1]=> string(5) "mario" [2]=> string(4) "cole" } fclose($fp); ?>
fputcsv
配列をcsv形式にフォーマットし、ファイルポインタに書き込む。第二引数には文字列の配列を指定する。
<?php $fp = fopen('test.csv','w'); fputcsv($fp,['a','b','c']); fclose($fp); ?>
【PHP】クッキーとセッション勉強会
セッション
ユーザーが行う一連の操作のこと 例えば ログイン-> ............ ->ログアウト の流れ
使われる場面
よくある例えだが、ショッピングサイトでカートに商品を入れ、ページを開いているタブを閉じ、再び開きカートをみると先ほど入れた商品が入っているという仕組みはクッキーが活用されている。
そもそもなぜcookieが必要なのか
プログラムを書いてみる
ページにアクセスした際に、countを1プラスするプログラムを作成する。
①setcookieを利用したパターン
<?php if(!isset($_COOKIE['count'])){ setcookie('count',0); echo '初めてですね!'; }else{ setcookie('count',$_COOKIE['count'] += 1); echo $_COOKIE['count'].'回目の更新ですね!'; } ?>
最初にページを開くと '初めてですね!' と表示され、ページを更新するごとにcountがプラス1されて画面に表示されます。
ブラウザでcookieを確認するには(Chrome)の場合は開発者ツールを開き、アプリケーション→Cookieから確認することができます。
またHttpヘッダを確認することにより、Cookieが設定されているかも確認することができます。Chromeの場合、開発者ツールからネットワーク→左の名前という部分からURLを選択し、ヘッダーという部分で確認することができます。1回目のアクセスを行うとレスポンスヘッダーに Set-Cookie: count=0 という部分があります。これはサーバーからブラウザに対してcount=0を保存するよう指示を出していることを意味しています。
HTTP Cookie の使用 - HTTP | MDN
setcookieに関して以下の記事を読むと、セキュリティーに関して考えなければいけないことがわかる。
setcookie()っていつ使うの? - Qiita
②session_startを利用したパターン(cookieとしてセッションIDをブラウザに保存する)
<?php session_start(); if(!isset($_SESSION['count'])){ $_SESSION['count'] = 0; echo '初めてですね!'; }else{ $_SESSION['count']++; echo $_SESSION['count'].'回目の更新ですね!'; } ?>
流れ
- ユーザーがページにアクセス(リクエスト)
- session_start()によりセッションを開始し、セッションIDを生成し、レスポンスヘッダーにてCookieに保存するように指示を出す。セッションIDによりサーバーはユーザーを識別することができる。(レスポンス)
- 2回目のリクエストの際にリクエストヘッダにセッションIDを含めてアクセス(リクエスト)
- セッションIDに基づいた値などを使い処理を行い返す(レスポンス)
あとは3、4の繰り返し。①と同様の方法でヘッダーの確認が可能。①のやり方と大きな違いはデータ自体をCookieに保存していないこと。①ではcountとその値をCookieに保存していたが、②ではセッションIDだけをCookieに保存している。countとその値はセッションファイルに保存されている。なのでセッションIDをもとにセッションファイルから$_SESSIONというグローバル変数を使って値を取得しているということ。
またこのセッションIDが他人にバレてしまうと、個人情報流失してしまう恐れがある。(セッションハイジャック)以下の動画が面白かった。
【PHP】データベース周りをフレームワークに頼りすぎていたのでPDOについてもう一度復習する【その1】
optionなど詳しい仕様はドキュメントを参照
PHP: PDO - Manual
PHP: PDOStatement - Manual
PDOオブジェクトを生成する
PHP: PDO::__construct - Manual
<?php //データソース $dsn = 'mysql:dbname=pdo_test;host=localhost;charset=utf8'; //ユーザー名 $user = 'root'; //パスワード $password = 'root'; //第4引数にオプションも追加することができる try { $db = new PDO($dsn,$user,$password); }catch(PDOException $e){ echo $e->getMessage(); } ?>
PDO::exec
<?php $exec_count = $db->exec('INSERT INTO members SET name="DwightHoward", number=39, position="center", age=36'); //1と表示される echo $exec_count; ?>
- SQLを実行し、返り値として更新か削除された行数を返す
- SELECT文は対応していない
- SELECT文を実行したいなら、PDO:query() か PDO:prepare()とその返り値であるPDOSTatementオブジェクトのexecute()を利用する
PDO::query
<?php //PDOStatementオブジェクトを取得 $pdo_stm = $db->query('SELECT * FROM members'); //PDOStatementオブジェクトのfetchメソッドを使用 var_dump($pdostm->fetch()); ?>
- 返り値はPDOStatementオブジェクト、失敗したらfalseを返す
- プレースホルダを指定しない時(SQLが固定)に使用する。
- 複数回SQL文を実行したい時、SQL文にプレースホルダがある時は、PDO:prepare()とその返り値であるPDOStatementオブジェクトのexecute()
PDOStatement::fetch
PHP: PDOStatement::fetch - Manual
- SQLの実行結果セットの次の一行を取得する
さっきのvar_dumpの出力結果のfetch()をfetchAll()に変えると以下のような返り血となる
array(10) { ["id"]=> string(1) "1" [0]=> string(1) "1" ["name"]=> string(11) "LebronJames" [1]=> string(11) "LebronJames" ["number"]=> string(1) "6" [2]=> string(1) "6" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "37" [4]=> string(2) "37" }
つまり
var_dump($pdostm->fetch()); var_dump($pdostm->fetch());
と実行すると
array(10) { ["id"]=> string(1) "1" [0]=> string(1) "1" ["name"]=> string(11) "LebronJames" [1]=> string(11) "LebronJames" ["number"]=> string(1) "6" [2]=> string(1) "6" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "37" [4]=> string(2) "37" } array(10) { ["id"]=> string(1) "2" [0]=> string(1) "2" ["name"]=> string(14) "CarmeloAnthony" [1]=> string(14) "CarmeloAnthony" ["number"]=> string(1) "7" [2]=> string(1) "7" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "37" [4]=> string(2) "37" }
PDOStatement::fetchAll
PHP: PDOStatement::fetchAll - Manual
- SQLの実行結果セットの残りの行すべてを取得
array(5) { [0]=> array(10) { ["id"]=> string(1) "1" [0]=> string(1) "1" ["name"]=> string(11) "LebronJames" [1]=> string(11) "LebronJames" ["number"]=> string(1) "6" [2]=> string(1) "6" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "37" [4]=> string(2) "37" } [1]=> array(10) { ["id"]=> string(1) "2" [0]=> string(1) "2" ["name"]=> string(14) "CarmeloAnthony" [1]=> string(14) "CarmeloAnthony" ["number"]=> string(1) "7" [2]=> string(1) "7" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "37" [4]=> string(2) "37" } [2]=> array(10) { ["id"]=> string(1) "3" [0]=> string(1) "3" ["name"]=> string(12) "YutaWatanabe" [1]=> string(12) "YutaWatanabe" ["number"]=> string(2) "18" [2]=> string(2) "18" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "27" [4]=> string(2) "27" } [3]=> array(10) { ["id"]=> string(1) "4" [0]=> string(1) "4" ["name"]=> string(9) "ChrisPaul" [1]=> string(9) "ChrisPaul" ["number"]=> string(1) "3" [2]=> string(1) "3" ["position"]=> string(5) "guard" [3]=> string(5) "guard" ["age"]=> string(2) "37" [4]=> string(2) "37" } [4]=> array(10) { ["id"]=> string(1) "5" [0]=> string(1) "5" ["name"]=> string(12) "DwightHoward" [1]=> string(12) "DwightHoward" ["number"]=> string(2) "39" [2]=> string(2) "39" ["position"]=> string(6) "center" [3]=> string(6) "center" ["age"]=> string(2) "36" [4]=> string(2) "36" } }
PDO::prepare と PDOStatement::execute
<?php //ユーザーがnameにYutaWatanabeを指定したとする。 //YutaWatanabeに関する行を取得する。 $player_name = "YutaWatanabe"; //PDOStatementオブジェクトを取得、SQL文を"準備" $pdo_stm = $db->prepare('SELECT * FROM members WHERE name = ? '); //PDOStatementオブジェクトのexecuteメソッドを"実行" //?にYutaWatanabeをバインドする $isSuccess = $pdo_stm->execute(array($player_name)); //取得したデータを表示してみる var_dump($pdo_stm->fetch()); //実行が成功したか表示してみる var_dump($isSuccess); ?>
実行結果
array(10) { ["id"]=> string(1) "3" [0]=> string(1) "3" ["name"]=> string(12) "YutaWatanabe" [1]=> string(12) "YutaWatanabe" ["number"]=> string(2) "18" [2]=> string(2) "18" ["position"]=> string(7) "forward" [3]=> string(7) "forward" ["age"]=> string(2) "27" [4]=> string(2) "27" } bool(true)
PDO::prepare 準備
- 複数回実行されるSQL文やプレースホルダがある時に有効
- SQLインジェクションからの保護に有効
- 返り値はPDOStatementオブジェクト、falseまたはPDOException
- SQL文にて 名前付きパラメータや?パラメータを使用することができる
PDOStatement::execute 実行
PHP: PDOStatement::execute - Manual
- prepareのプレースホルダ部分をバインドするために、引数として配列を渡す。他にbindParam()やbindValue()を使用する方法もある。
- 返り値は成功した時にtrue、失敗した時にfalseを返す