LoginSignup
39
15

More than 1 year has passed since last update.

はじめまして、NACKと申します。

「毎月7日は罠(わな=07)の日」……と言い張ってみます。
※記念日認定されているわけではありませんので、ご注意ください。

というわけで、Laravelの開発中に、実際に罠にはまった話を、昔のゲームブック風に書いてみます。
楽しんでいただければ幸いです。「では、幸運を祈っています(1へ進む)」

「そういうお遊びは興味無いから!」という方は、直接 罠の解説まで飛んでください。
実際にはまった罠の話が列挙されています。


Laravelトラップ(ゲームブック風)


1

君は小さく、四角い、明るい部屋にいる。

扉は全部で2箇所ある。1つは東の壁、もう1つは西の壁にある。
東の扉には「Eloquentの旅」、西の扉には「envの旅」と書かれている。

東の扉を開けるなら11へ進め。
西の扉を開けるなら9へ進め。


2

冒険を楽しみ、さらに挑戦してみようと思うなら1へ進む。
もう満足したか、全ての冒険を達成したなら、Laravelの開発に挑戦すべきだろう。
楽しんでくれてありがとう。


3

値は正しく取得できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、13へ進め。


4

更新日時は無事更新できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、10へ進め。


5

値は取得できなかった(14へ)。
解説


6

データは無事更新できた(2へ)。


7

値は正しく取得できた(2へ)。


8

更新日時は更新されなかった(14へ)。
解説


9

commandファイル内で、envに設定された値を取得したい。
君はconfigファイル経由で取得してもいいし(15へ)、
commandファイルで直接取得してもいい(3へ)。

configファイル経由で取得する
$app_name = \Config::get('app.name');  // config/app.php に return ['name' => env('APP_NAME', 'Laravel')];という定義済
commandファイルで直接取得する
$app_name = env('APP_NAME', 'Laravel');

10

Eloquentを使い、テーブルの全レコードのデータを更新したい。
君はそのままupdateしてもいいし(12へ)、全件該当する条件を指定してからupdateしてもいい(6へ)。
もしくは、queryメソッドを呼び出してからupdateするのも自由だ(17へ)。

そのままupdateする
App\Flight::update(['piyo' => 1]);
全件該当する条件を指定してからupdateする
App\Flight::whereRaw('1=1')->update(['piyo' => 1]);
queryメソッドを呼び出してからupdateする
App\Flight::query()->update(['piyo' => 1]);

11

更新日時を更新したい。
ただし、今回は「更新日時以外の更新対象項目の値が、変更前後で変わらないこともある」状況だ。

君はsave()を使ってもいいし(8へ)、update()を使ってもいい(4へ)。
もしくは、touch()を使うのも自由だ(16へ

save()を使う
$flight = App\Flight::find(1);
$flight->name = 'ほげ'; // 元のnameも"ほげ"
$flight->save();
update()を使う
App\Flight::where('id', 1)
          ->update(['name' => 'ほげ']); // 元のnameも"ほげ"
touch()を使う
$flight = App\Flight::find(1);
$flight->name = 'ほげ'; // 元のnameも"ほげ"
$flight->touch();

12

データは更新されなかった(14へ)。
解説


13

commandファイル内で、envに設定された値を取得したい。
(ただし、このアプリは php artisan config:cache を使用している)。
君はconfigファイル経由で取得してもいいし(7へ)、
commandファイルで直接取得してもいい(5へ)。

configファイル経由で取得する
$app_name = \Config::get('app.name');  // config/app.php に return ['name' => env('APP_NAME', 'Laravel')];という定義済
commandファイルで直接取得する
$app_name = env('APP_NAME', 'Laravel');

14

君は失敗した。だが、くじけることはない。君はいつだって再挑戦できるのだ。


15

値は正しく取得できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、13へ進め。


16

更新日時は無事更新できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、10へ進め。

※special thanks: @mikkame


17

データは無事更新できた(2へ)。

※special thanks: @mikkame


罠の解説

Eloquent / QueryBuilder

saveメソッドとupdateメソッドの違い

「(更新日時以外の)値が、元の値から変更されているかどうか」で挙動が異なります。

  • 値が変更されている場合のみ更新するのがsaveメソッド
  • 値の変更状況に関わらず更新するのがupdateメソッド
    です。

参考: Eloquentのメソッド saveとupdateは処理が異なる

そのため、(「変更しようとしたが、結果的に同じ値だった」場合を含め)値が変更されなかった場合、saveメソッドでは更新日時が変更されません
どうしてもsaveメソッドと同じような実装をしつつ、時間も確実に更新したい場合は、saveメソッドの"替わり"にtouchメソッドを使いましょう。データ部分は(更新が必要であれば)更新した上で、時間は確実に更新されます(special thanks: @mikkame )。ただ、touchメソッドだと、ネーミング的に「データが更新される感が薄い」のが悩ましいところです……。

なお、こちら「データが更新されていないのだから、更新日時も更新しなくて良いのでは?」と思われる方もいらっしゃると思います。
正しい見解ではあるのですが、データの差分取込バッチを作った時、この仕様のために問題が発生しました……。

発生した問題:
親子テーブルを対象とする差分取込バッチで
「親テーブルの更新日時が変わっていたら取込対象とする。
 そのため、子テーブルだけデータ更新したい場合でも、親テーブルの更新日時は更新しておく」
という作りにしたつもりだった。
が、各テーブルの更新処理をsaveメソッドで行っていたため、親テーブルのデータ更新が無い場合に、更新日時が更新されず、取込対象から漏れた。

一括更新時のupdateメソッド

百聞は一見に如かず……ということで、下記のサンプルコードをご覧ください(関連コードのみ抜粋)。

// クエリログ出力準備
\DB::enableQueryLog();
        
// 条件指定無しで更新
App\Flight::update(['piyo' => 1]);

// クエリログ&実行速度出力
var_dump(\DB::getQueryLog());

/*
出力結果:
array(0) {
}
→SQLの実行ログが無い!
*/
// クエリログ出力準備
\DB::enableQueryLog();
        
// 全件当てはまる条件付きで更新
App\Flight::whereRaw('1 = 1')->update(['piyo' => 1]);

// クエリログ&実行速度出力
var_dump(\DB::getQueryLog());

/*
出力結果:
array(1) {
  [0] =>
  array(3) {
    'query' =>
    string(xx) "update `flights` set `piyo` = ?, `updated_at` = ? where 1 = 1"
    'bindings' =>
    array(2) {
      [0] =>
      int(1)
      [1] =>
      string(19) "2018-12-07 12:34:56"
    }
    'time' =>
    double(439.91)
  }
}
→SQLの実行ログが出てきた!
*/

というわけで、条件指定無しでupdateした時は、SQLが実行されません

原因は、「条件が指定されているかどうか」ではなく、「Eloquent(Model)クラスから直接呼び出すupdateメソッド」と「whereメソッド使用後に呼び出すupdateメソッド」の実装が異なっているからです(当然といえば当然なのですが……)。

Illuminate\Database\Eloquent\Modelクラス
// whereメソッド(whereRawメソッド等も含む)を使用せず、Eloquentクラスから直接updateメソッドを呼び出すと、ここに来る。
    /**
     * Update the model in the database.
     *
     * @param  array  $attributes
     * @param  array  $options
     * @return bool
     */
    public function update(array $attributes = [], array $options = [])
    {
        if (! $this->exists) { 
            return false;
        }

        return $this->fill($attributes)->save($options);
    }
Illuminate\Database\Eloquent\Builderクラス
// whereメソッド(whereRawメソッド等も含む)使用後にupdateメソッドを呼び出すと、ここに来る。
    /**
     * Update a record in the database.
     *
     * @param  array  $values
     * @return int
     */
    public function update(array $values)
    {
        return $this->toBase()->update($this->addUpdatedAtColumn($values));
    }

なお、私が修正した時は Illuminate\Database\Eloquent\Builder クラスのupdateメソッドを呼び出す」ために whereRaw メソッドを使ってしまいましたが、全件更新時の正攻法は「 query メソッドを使う」です(special thanks: @mikkame )。

全件更新の正攻法
App\Flight::query()->update(['piyo' => 1]);

Environment

env値の呼出し箇所とキャッシュ

special thanks: @cflat0528

基本的に、.envファイルに設定した値は、どこからでも取得できます(ControllerやModel、Commandからも取得可能です)。

App\Console\Commands\HogeCommandクラス(自作のコマンドクラス)
<?php

namespace App\Console\Commands;
use Illuminate\Console\Command;

class HogeCommand extends Command
{
    protected $signature = 'hoge:fuga';

    public function handle()
    {
        // envファイルからAPP_NAMEという変数の値が取得できればその値を、なければ"Laravel"という文字列を出力する
        echo env('APP_NAME', 'Laravel') . "\n"; 
    }
}

ただし、 php artisan config:cache と組み合わせて使う場合は注意が必要です。
php artisan config:cache の実行前後で、上記のコマンドの実行結果を比較してみましょう。

$ php artisan hoge:fuga    # ↓envの値が取得できている
hoge-batch
$ php artisan config:cache #configの値をキャッシュすると……
Configuration cache cleared!
Configuration cached successfully!
$ php artisan hoge:fuga    # ↓出力結果が変わってしまった!
Laravel     
$

これは、「envファイル読み込み処理の仕様」です。

php artisan config:cache を実行した時、APP_NAMEという変数がconfig/app.phpで以下のように設定されている場合、

config/app.php
<?php

'name' => env('APP_NAME', 'Laravel'),

この値を envファイル内に定義した定数値 に置き換えた「configのキャッシュファイル」が生成されます。

bootstrap/cache/config.php
<?php

'name' => 'hoge-batch' // .envで指定したAPP_NAME

ここまでは良いのですが、一度 php artisan config:cache をした後は、アプリの起動時にenvファイルが読み込まれなくなります

参考:
https://github.com/laravel/framework/blob/197a7c3b86d24b8698c61107263b68cb737d51c8/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php#L12-L31

Illuminate\Foundation\Bootstrap\LoadEnvironmentVariablesクラス
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        if ($app->configurationIsCached()) {
            return; // ←configキャッシュがある場合、ここでリターンしてしまうため、後続のenvファイル読み込みが行われない
        }
        $this->checkForSpecificEnvironmentFile($app);
        try {
            (new Dotenv($app->environmentPath(), $app->environmentFile()))->load();
        } catch (InvalidPathException $e) {
            //
        }
    }

そのため、php artisan config:cache 実行後は、configファイル以外に書かれているenvの値は(キャッシュファイルに保存されておらず、envファイルの読み込みも行われないので)取得できなくなってしまうのです。

(ゲームブック部分の)参考文献

39
15
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
15