たけるのプログラミング

作ったものとか、気ままにアップします。

【PHP】N+1問題をLaravel Debugbarを使って検証してみる【Laravel】

N+1問題とは
【Ruby on Rails】N+1問題ってなんだ? - Qiita

ループ処理の中で都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが低下してしまう問題のこと。

1回のクエリ発行でN件のレコードを取得し、

それぞれN件のレコードが持っているリレーション先のテーブルのレコード

を取得しようとする(N回のクエリ発行)とこのN+1問題が起こる。

具体例

実際に以下のようなリレーションを持つテーブルがあったとする。

f:id:takeru232423:20220314133636p:plain

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クエリが実行されたか確認してみる。すると以下のような結果となった。f:id:takeru232423:20220314141526p:plain

まずコントローラ内の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になっている。
f:id:takeru232423:20220314143412p:plain

これでデータベースへの負荷を下げることができる。

機能実現だけでなく、パフォーマンスのことも考えることが大切だな〜。

【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(オートロード)とクラスインポートの組み合わせ。

フルスクラッチで挙動について確認してみる。

今回のディレクトリの構成。

f:id:takeru232423:20220309132929p:plain

オートロードを使うために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ポイントシュート';
}


また例えば名前空間が長い時
lebron.php

<?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: fwrite - Manual

ファイルに書き込む

<?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

PHP: fgetcsv - Manual

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

PHP: fputcsv - Manual

配列をcsv形式にフォーマットし、ファイルポインタに書き込む。第二引数には文字列の配列を指定する。

<?php
$fp = fopen('test.csv','w');
fputcsv($fp,['a','b','c']);
fclose($fp);
?>

【PHP】クッキーとセッション勉強会

クッキー(cookie)とは

Webブラウザにデータを保存するためのファイルのこと、Webブラウザに保存されるデータのこと

セッション

ユーザーが行う一連の操作のこと 例えば ログイン-> ............ ->ログアウト の流れ

使われる場面

よくある例えだが、ショッピングサイトでカートに商品を入れ、ページを開いているタブを閉じ、再び開きカートをみると先ほど入れた商品が入っているという仕組みはクッキーが活用されている。

そもそもなぜcookieが必要なのか

ステートレス

通信プロトコルであるHTTPはステートレスつまり状態を保持することができないため、cookieを利用してセッションを管理する必要がある。

プログラムを書いてみる

ページにアクセスした際に、countを1プラスするプログラムを作成する。

①setcookieを利用したパターン

PHP: setcookie - Manual

<?php
if(!isset($_COOKIE['count'])){
    setcookie('count',0);
    echo '初めてですね!';
}else{
    setcookie('count',$_COOKIE['count'] += 1);
    echo $_COOKIE['count'].'回目の更新ですね!';
}
?>

最初にページを開くと '初めてですね!' と表示され、ページを更新するごとにcountがプラス1されて画面に表示されます。
ブラウザでcookieを確認するには(Chrome)の場合は開発者ツールを開き、アプリケーション→Cookieから確認することができます。
f:id:takeru232423:20220302205612p:plain



また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 - Manual

<?php
session_start();
if(!isset($_SESSION['count'])){
    $_SESSION['count'] = 0;
    echo '初めてですね!';
}else{
    $_SESSION['count']++;
    echo $_SESSION['count'].'回目の更新ですね!';
}
?>
流れ
  1. ユーザーがページにアクセス(リクエスト)
  2. session_start()によりセッションを開始し、セッションIDを生成し、レスポンスヘッダーにてCookieに保存するように指示を出す。セッションIDによりサーバーはユーザーを識別することができる。(レスポンス)
  3. 2回目のリクエストの際にリクエストヘッダにセッションIDを含めてアクセス(リクエスト)
  4. セッション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: PDO::exec - Manual

<?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: PDO::query - Manual

<?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 準備

PHP: PDO::prepare - Manual

  • 複数回実行されるSQL文やプレースホルダがある時に有効
  • SQLインジェクションからの保護に有効
  • 返り値はPDOStatementオブジェクト、falseまたはPDOException
  • SQL文にて 名前付きパラメータや?パラメータを使用することができる
PDOStatement::execute 実行

PHP: PDOStatement::execute - Manual

  • prepareのプレースホルダ部分をバインドするために、引数として配列を渡す。他にbindParam()やbindValue()を使用する方法もある。
  • 返り値は成功した時にtrue、失敗した時にfalseを返す