20201223のlaravelに関する記事は7件です。

LaravelとVue.jsを使った見積作成アプリ その1

はじめに

この記事は私自身がLaravelとVue.jsを勉強する目的で学んだことをまとめたものです。

この記事で作成するアプリケーションについて

この記事は以下の環境で作成しました。

  • Laravel 5.7.29
    • PHP 7.3.11
  • Vue.js 2.6.11

アプリケーションの全体像

今回作成する見積作成アプリは全部で6画面あり、認証機能まで備えたアプリケーションを作成していきます。

こちらが完成した見積作成アプリです。

  • 見積一覧ページ
    • このページでは作成した見積の一覧を表示します。
  • 見積編集ページ
    • このページで見積の内容を編集、保存します。
  • PDF表示ページ
    • このページでは作成した見積をPDFで表示し、保存と印刷を可能にします。
  • ログインページ
    • ログインページも作成します。最終的にはログイン中のユーザーの見積のみ表示するように実装します。
  • 会員登録ページ
    • 会員登録ではメールアドレス、ユーザー名、パスワードを入力します。
  • プロフィール編集ページ
    • このページで見積に表示される自分の情報を編集できるようにします。

テーブル定義

見積テーブルと商品テーブルを作成します。二つのテーブルの関係性は見積一つに対し商品が多数紐づく「一対多」にします。

見積テーブル
ID id
タイトル title
納入場所 location
取引方法 transaction
有効期限 effectiveness
宛先 customer
納入期限 deadline_at
見積日 estimated_at
商品テーブル
ID id
見積ID estimate_id
商品名 name
単位 unit
数量 quantity
単価 unit_price
備考 other

見積一覧ページの作成

環境構築ができていてLaravelの初期画面が表示されている前提で進めます。

データベースの接続設定

まずは接続設定を.envで行います。estimateというデータベースを作成しています。環境構築にはHomesteadを使用しました。

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=estimate
DB_USERNAME=homestead
DB_PASSWORD=secret

マイグレーションファイルとモデルクラスの作成

$ php artisan make:migration create_estimates_table --create=estimates

作成されたファイルに記入していきます。

create_estimates_table.php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateEstimatesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('estimates', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title', 100)->nullable();
            $table->string('location', 100)->nullable();
            $table->string('transaction', 100)->nullable();
            $table->string('effectiveness', 100)->nullable();
            $table->string('customer', 100)->nullable();
            $table->string('deadline_at', 100)->nullable();
            $table->date('estimated_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('estimates');
    }
}

テーブル名はestimatesとしました。格納したい物の名前の複数形にするのが一般的です。また、見積作成の途中で保存したい時や記入せずに作成したい場合に対応するため、nullable()でカラムにNULL値を許容しました。

マイグレーションを実行します。

$ php artisan migrate

次にモデルクラスを作成します。

$ php artisan make:model Estimate

appディレクトリにEstimateモデルが作成されます。

Estimate.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Estimate extends Model
{
    //
}

Estimateモデルに記述はしていませんが、継承元であるModelクラスで様々な設定を読み取ってくれるらしいです。

これでデータを扱う準備ができたのですが、テストデータが入っていた方がコントローラーを書きやすいので、Seederを用いてデータを挿入します。

$ php artisan make:seeder EstimatesTableSeeder

runメソッドの中にデータを挿入するコードを記述します。ここでは3つの見積を作りました。

EstimatesTableSeeder.php
<?php

use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class EstimatesTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $user = DB::table('users')->first();

        $titles = ['2021年おめでとうセール', '商品見積の件', 'サンプル見積の件'];
        $customers = ['株式会社XXX', '株式会社YYY', '株式会社ZZZ'];

        foreach (array_map(NULL, $titles, $customers) as [ $title, $customer ]) {
            DB::table('estimates')->insert([
                'title' => $title,
                'user_id' => $user->id,
                'customer' => $customer,
                'created_at' => Carbon::now(),
                'updated_at' => Carbon::now(),
            ]);
        }
    }
}

コマンドラインで実行します。

$ php artisan db:seed --class=EstimatesTableSeeder

「Database seeding completed successfully.」と返ってきたら成功です。

ルーティングの設定

web.php
Route::get('/estimates', 'EstimateController@index')->name('estimates.index');

コントローラークラスの作成

コントローラークラスはコマンドラインから作成。

$ php artisan make:controller EstimateController

作成されたEstimateController.phpにindexメソッドを追加します。

EstimateController.php
use App\Estimate; // ★ 追加

    public function index()
    {
        $estimates = Estimate::all();

        return view('estimates/index', [
            'estimates' => $estimates,
        ]);
    }

view関数でテンプレートにデータを渡し、その結果を返却しています。view関数の第一引数がテンプレートファイル名で第二引数がテンプレートに渡すデータです。

テンプレートの作成

テンプレートファイルを作成します。

$ mkdir resources/views/estimates
$ touch resources/views/estimates/index.blade.php
$ touch resources/views/layout.blade.php

layout.blade.phpとindex.blade.phpの中身は以下のように記述しました。

layout.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="csrf-token" content="{{ csrf_token() }}">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>見積作成アプリ</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <link rel="stylesheet" href="{{ asset('css/app.css') }}">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
</head>
<body>
  <header>
    <nav class="navbar navbar-expand-xs navbar-dark bg-dark p-1">
      <a class="navbar-brand" href="{{ route('estimates.index') }}">見積作成アプリ</a>
    </nav>
  </header>
  @yield('content')
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
  @yield('scripts')
</body>
</html>
index.blade.php
@extends('layout')

@section('content')
<main>
  <div class="container">
    <div class="row">
      <div class="col col-md-12">
        <h2 class="text-center" style="padding-top:25px">見積一覧</h2>
        <table class="table table-bordered table-hover" style="table-layout:fixed;">
          <thead class="thead-dark">
            <tr>
              <th class="col">タイトル</th>
              <th class="col">見積もり期日</th>
              <th class="col">場所</th>
              <th class="col">宛先</th>
            </tr>
          </thead>
          <tbody>
            @foreach($estimates as $estimate)
              <tr>
                <td class="position-relative">
                  <a href="{{ route('estimates.edit', ['estimate' => $estimate->id]) }}" class="stretched-link">
                    {{ $estimate->title }}
                  </a>
                </td>
                <td>{{ $estimate->estimated_at }}</td>
                <td>{{ $estimate->location }}</td>
                <td>{{ $estimate->customer }}</td>
              </tr>
            @endforeach
          </tbody>
        </table>
      </div>
    </div>
  </div>
</main>
<footer class="fixed-bottom bg-dark">
  <nav class="my-navbar">
    <div class="container">
      <div class="row">
        <div class="col-md-3">
          <a href="#">
            <button>新規作成</button>
          </a>
        </div>
        <div class="col-md-3 offset-md-6">
          <a href="#">
            <button>プロフィール設定</button>
          </a>
        </div>
      </div>
    </div>
  </nav>
</footer>
@endsection

テンプレートの中でも@を付ければPHPのようにforeachを使えます。この際、コントローラーから渡された$estimatesを参照しています。変数の値の展開は{{ }}のように波括弧二つで実現します。

CSSフレームワークにはBootstrapを使用しました。

見積編集ページの作成

商品テーブルの作成

まずは見積テーブルと同様にマイグレーションファイルを作成します。

$ php artisan make:migration create_items_table --create=items

マイグレーションファイルを記述します。

create_items_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateItemsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('items', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('estimate_id')->unsigned();
            $table->string('name', 100)->nullable();
            $table->string('unit', 10)->nullable();
            $table->integer('quantity')->nullable();
            $table->integer('unit_price')->nullable();
            $table->string('other', 100)->nullable();
            $table->timestamps();

            $table->foreign('estimate_id')->references('id')->on('estimates')->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('items');
    }
}

ここでは外部キー制約を設定しています。外部キー制約は他のテーブルとの結びつきを表現するためのカラムに設定します。外部キー制約が設定されたカラムには、好き勝手な値は入れられなくなります。今回の例で言うと、商品テーブルの見積ID列には実際に存在する見積IDの値しか入れることができなくなります。これによりデータの不整合を防ぎます。また、onDelete('cascade')により見積テーブルのデータを削除した場合、商品テーブル内の一致するデータを自動的に削除してくれます。

マイグレーションを実行します。

$ php artisan migrate

続けて商品テーブルに対応するモデルクラスを作成します。

$ php artisan make:model Item
Item.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Item extends Model
{
    //
}

テストデータを挿入するためにシーダーを作成します。

$ php artisan make:seeder ItemsTableSeeder
ItemsTableSeder.php
<?php

use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class ItemsTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $names = ['ガードレール', 'エムコール', '塩化カルシウム'];
        $units = ['式', '袋', '袋'];
        $quantities = [1, 10, 25];
        $unit_prices = [150000, 5000, 1000];

        foreach (array_map(NULL, $names, $units, $quantities, $unit_prices) as [ $name, $unit, $quantity, $unit_price ]) {
            DB::table('items')->insert([
                'estimate_id' => 1,
                'name' => $name,
                'unit' => $unit,
                'quantity' => $quantity,
                'unit_price' => $unit_price,
            ]);
        }
    }
}

今回はID=1の見積に対して3つの商品を登録しました。

$ php artisan db:seed --class=ItemsTableSeeder

ルーティングの設定

web.php
Route::get('/estimates/edit', 'EstimateController@showEditForm')->name('estimates.edit');
Route::post('/estimates/edit', 'EstimateController@edit');
Route::get('/estimates/create', 'EstimateController@create')->name('estimates.create');
Route::post('/estimates/create', 'EstimateController@create');

コントローラーの作成

コントローラーを書いていきます。既存の見積もりを編集する場合はそのままshowEditFormへ、新規作成の場合はcreate->showEditFormと推移します。

EstimateController.php
use App\Item; // ★ 追加

    public function showEditForm(Request $request)
    {
        $estimate_id = $request->input('estimate');
        $estimate = Estimate::find($estimate_id);

        return view('estimates/edit', [
            'estimate' => $estimate,
        ]);
    }

    public function create()
    {
        $estimate = new Estimate();
        $estimate->save();

        return redirect()->route('estimates.edit', [
            'estimate' => $estimate->id,
        ]);
    }

    public function edit(Request $request)
    {
        $estimate_id = $request->input('estimate');
        $current_estimate = Estimate::find($estimate_id);

        $current_estimate->title = $request->title;
        $current_estimate->location = $request->location;
        $current_estimate->transaction = $request->transaction;
        $current_estimate->effectiveness = $request->effectiveness;
        $current_estimate->customer = $request->customer;
        $current_estimate->deadline_at = $request->deadline_at;
        $current_estimate->estimated_at = $request->estimated_at;

        $current_estimate->save();

        return redirect()->route('estimates.edit', [
            'estimate' => $estimate_id
        ]);
    }

コントローラーメソッドの引数にRequestクラスのインスタンスを受け入れる記述をすることでユーザーの入力値をRequestクラスのインスタンス$requestに詰めて引数として渡してくれます。Requestクラスのインスタンスにはリクエストヘッダや送信元IPなどいろいろな情報が含まれていますが、その中にフォームの入力値も入っています。

$request->title;

リクエスト中の入力値は上記のようにプロパティとして取得することができます。

また、クエリパラメータの取得にはRequestクラスのinputメソッドを使用します。inputメソッドの第一引数へ、クエリパラメータのキーを指定します。今回指定するクエリパラメータのキーは'estimate'です。この為、inputメソッドの第一引数には'estimate'を指定します。

次のポイントはデータベースに書き込む処理です。データベースへの書き込みは以下の手順で実装します。

  1. モデルクラスのインスタンスを作成する。
  2. インスタンスのプロパティに値を代入する。
  3. saveメソッドを呼び出す。

これにより、モデルクラスが表すテーブルに対してINSERTが実行されます。

次回

ここまで、見積一覧ページと見積編集ページのコントローラーを作成しました。次回はVue.jsを利用した見積編集ページのテンプレートを作成します。

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

画像、PDFをPDF化

画像やPDFを1つのPDFにする

tcpdfとfpdiを使用。

jpgやpngなどの画像をPDF化するならtcpdfのみで可能ですが
PDFをPDFにマージするなどもしたい場合はfpdiも必要。

fpdiはtcpdfまたはtfpdfの拡張機能としても使用できるので
fpdiをインストールして、tcpdfの機能を使いたかったらtcpdfもインストールする、
という認識です。

インストール

composer require tecnickcom/tcpdf
composer require setasign/fpdi

処理

<?php

namespace App\Services\Hoge;

use setasign\Fpdi\Tcpdf;
use File;
use Utils;

class HogeService
{
    public function createPdf(string $id)
    {
        // ファイル取得
        $files = Utils::getAllFiles('hoge', $id);

        $maxsize = 550;
        $tcpdf = new Tcpdf\Fpdi('p', 'px', 'A3');
        $tcpdf->SetPrintHeader(false);
        $tcpdf->SetPrintFooter(false);

        foreach ($files as $file) {
            $extension = File::extension($file);
            $file_path = storage_path('app') . '/' . $file;

            if ($extension == 'pdf') {
                $pageCnt = $tcpdf->setSourceFile($file_path);

                for ($i = 1; $i <= $pageCnt; $i++) {
                    $tcpdf->addPage();
                    $tcpdf->useTemplate($tcpdf->importPage($i));
                }
            } else {
                $tcpdf->AddPage();

                // @NOTE:フルパスでないとセットしてくれない
                // 第5引数大文字でないとセットしてくれない
                $img_size = getimagesize($file_path);
                $set_size = ($img_size[0] >= $maxsize) ? $maxsize : $img_size[0];

                $tcpdf->Image(
                    $file_path,                                // 画像ファイル名
                    5,                                         // 領域左上のX座標
                    5,                                         // 領域左上のY座標
                    $tcpdf->pixelsToUnits($set_size),          // 領域の幅 [指定しない場合、自動計算される]
                    0,                                         // 領域の高さ [指定しない場合、自動計算される]
                    strtoupper($extension),                    // 画像フォーマット
                    '',                                        // AddLink()で作成したリンク識別子
                    '',                                        // align
                    ($img_size[0] >= $maxsize) ? true : false, // resize
                    300,                                       // dpi
                    '',                                        // palign
                    false,                                     // ismask
                    false,                                     // imgmask
                    0,                                         // border
                    false,                                     // fitbox
                    false,                                     // hidden
                    false,                                     // fitonpage
                    false,                                     // alt
                );
            }
        }

        $pdf_path = storage_path('app') . "/data/hoge/$id/hoge_$id.pdf";
        // 保存
        $tcpdf->output($pdf_path, 'F');
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel + livewire 年齢算出

laravelを使った開発が一旦暇になったのでlaravel8にあるlivewireを試しにいじってみました。
ある方の記事を参考に進めていった際2箇所ほどエラーにあったのでその際の記録。
(参考にした記事は誕生日から年齢を算出するものです。)

livewireをインストール

composer require calebporzio/livewire

livewireに必要なファイルを作成

php artisan make:livewire birthday

以下のファイルが作成される

app/Http/Livewire/Birthday.php
resources/views/livewire/birthday.blade.php

年齢算出処理作成

app/Http/Livewire/Birthday.php

<?php

namespace App\Http\Livewire;

use Carbon\Carbon;
use Livewire\Component;

class Birthday extends Component
{
    /** bladeとのデータ共有プロパティ */
    public $year = 0;
    public $month = 0;
    public $day = 0;
    public $age = -1;
    public $last_day_of_month = 0;

    public function mount($year = 0, $month = 0, $day = 0)
    {

        $this->year = $year;
        $this->month = $month;
        $this->day = $day;
        $this->onChange();
    }

    public function onChange()
    {
        $year = intval($this->year);
        $month = intval($this->month);
        $day = intval($this->day);

        // 該当月の日(28〜31日)を計算
        if ($year > 0 && $month > 0) {
            $this->last_day_of_month = Carbon::create($this->year, $this->month)->endOfMonth()->day;
        }

        // 年齢を計算
        if (checkdate($month, $day, $year)) {
            $this->age = Carbon::createFromDate($this->year, $this->month, $this->day)->age;
        } else {
            $this->age = -1;
        }
    }

    public function render()
    {
        return view('livewire.birthday');
    }
}

ビュー作成

resources/views/livewire/birthday.blade.php

<div>
    {{--  --}}
    {{ Form::select('birth-year',
        array_combine(range(1950, date("Y")), range(1950, date("Y"))),
        '', [
        'wire:model' => 'year',
        'wire:change' => 'onChange',
    ])}}
    {{--  --}}
    {{ Form::select('birth-month',
        array_combine(range(1, date("m")), range(1, date("m"))),
        '', [
        'wire:model' => 'month',
        'wire:change' => 'onChange',
    ])}}
    {{--  --}}
    {{ Form::select('birth-day',
        array_combine(range(1, $last_day_of_month), range(1, $last_day_of_month)),
        '', [
        'wire:model' => 'day',
        'wire:change' => 'onChange',
    ])}}
    {{-- 年齢 --}}
    @if($age > -1)
        / {{ $age }}
    @endif
</div>

呼び出したいblade箇所で以下のように呼び出します。

<html>
<head>
    @livewireStyles
</head>
<body>
<div>
    @livewire('birthday')
</div>
@livewireScripts
</body>
</html>

1つ目のエラー

Call to undefined method CompilerEngine::startLivewireRendering()

参考記事主さんが質問していました。
https://github.com/livewire/livewire/issues/711

bootstrap/cache/packages.php

facade/ignitionを
livewire/livewireの上に移動すれば解決とな。

2つめのエラー

@livewire('birthday', 2000, 12, 31)

呼び出し箇所を上記のようにしたら以下のエラー。

array_intersect_key(): Expected parameter 1 to be an array, int given (View: {ファイルパス}\index.blade.php)

エラー発生箇所は以下。

vendor\calebporzio\livewire\src\LifecycleManager.php

public function mount($params = [])
{
    // Assign all public component properties that have matching parameters.
    collect(array_intersect_key($params, $this->instance->getPublicPropertiesDefinedBySubClass()))
            ->each(function ($value, $property) {
                $this->instance->{$property} = $value;
            });

@livewire('birthday', 2000, 12, 31)で呼び出すと$paramsには'2000'がきていました。

$paramsには配列が来る想定だけど文字列がきているのが原因のようです。
以下のように呼び出せば解決しました。

@livewire('birthday', ['year' => 2000, 'month' => 12, 'day' =>31])

routeの第2引数の書き方と同じ要領。

参考記事

https://blog.capilano-fw.com/?p=4466

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

laravel6 バッチを作って実行してみる

目的

  • laravel6のアプリでバッチを作成し、実行する方法をまとめる。

実施環境

  • ハードウェア環境
項目 情報
OS macOS Catalina(10.15.5)
ハードウェア MacBook Pro (13-inch, 2020, Four Thunderbolt 3 ports)
プロセッサ 2 GHz クアッドコアIntel Core i5
メモリ 32 GB 3733 MHz LPDDR4
グラフィックス Intel Iris Plus Graphics 1536 MB
  • ソフトウェア環境
項目 情報 備考
PHP バージョン 7.4.8 Homebrewを用いてこちらの方法で導入→Mac HomebrewでPHPをインストールする
Laravel バージョン 6.X commposerを用いてこちらの方法で導入→Mac Laravelの環境構築を行う
MySQLバージョン 8.0.19 for osx10.13 on x86_64 Homwbrewを用いてこちらの方法で導入→Mac HomebrewでMySQLをインストールする

条件

  • laravel6のアプリがありローカルサーバなどを起動しブラウザから確認する事ができること。

情報

  • バッチの実行方法は手動でコマンドを叩いて実行するものとする。
  • バッチの作成と手動実行の方法をまとめることが目的のためバッチの内部で実行する処理は最低限とし「Hello Japan!」をターミナルに表示するだけのものとする。

概要

  1. バッチの作成
  2. 実行と確認

詳細

  1. バッチの作成

    1. アプリ名ディレクトリで下記コマンドを実行してバッチクラスを作成する。

      $ php artisan make:command EchoStrCommand
      
    2. アプリ名ディレクトリで下記コマンドを実行して作成したバッチクラスが記載されているファイルを開く。

      $ vi app/Console/Commands/EchoStrCommand.php
      
    3. 開いたファイルを下記のように修正する。

      アプリ名ディレクトリ/app/Console/Commands/EchoStrCommand.php
      <?php
      
      namespace App\Console\Commands;
      
      use Illuminate\Console\Command;
      
      class EchoStrCommand extends Command
      {
          /**
           * The name and signature of the console command.
           *
           * @var string
           */
          // 下記を修正
          protected $signature = 'command:echo_str';
      
          /**
           * The console command description.
           *
           * @var string
           */
          // 下記を修正
          protected $description = 'Echo Hello Japan!';
      
          /**
           * Create a new command instance.
           *
           * @return void
           */
          public function __construct()
          {
              parent::__construct();
          }
      
          /**
           * Execute the console command.
           *
           * @return mixed
           */
          public function handle()
          {
              //下記を追記
              echo 'Hello Japan!'."\n";
          }
      }
      
  2. 実行と確認

    1. アプリ名ディレクトリで下記コマンドを実行し出力結果の中に「command:echo_str」が存在していることを確認する。

      $ php artisan list
      
    2. アプリ名ディレクトリで下記コマンドを実行する。

      $ php artisan command:echo_str
      
    3. 下記のようにターミナル上に「Hello Japan!」の文字が表示されることを確認する。

      EchoStrCommand_php_—_laravel6.png

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

Laradockをちょっと便利にするコマンドラッパーを作った

この記事は個人開発 Advent Calendar 2020の23日目です。

TL;DR

Laradockをちょっと便利にするコマンドラッパーを作りました。
https://github.com/ngmy/laradockctl

背景

WebサービスやOSSを開発するときはDockerを使うと開発マシンが汚れないので便利ですね。
私はもっぱらPHPで開発しており、また一からDockerで開発環境を構築する時間もないので、開発環境はLaradockのお世話になることが多いです。
LaradockはPHPの開発に必要なもの全部入りのDocker環境で、PHP界隈ではおなじみのツールです。

そんなLaradockですが、使っていて個人的に不便に感じる点があります。

Laradockを使っていて個人的に不便に感じる点

1. どのコンテナを起動すればいいのか忘れる

「このWebサービスではnginxコンテナとmysqlコンテナを立ち上げて……」「このOSSではmailhogコンテナも立ち上げて……」といった具合に、プロジェクトに応じて起動するコンテナが異なるので、毎回どのコンテナを起動すればいいのか忘れます。
これはしばらく触っていなかったプロジェクトをひさしぶりにメンテナンスする必要が生じたときによく起こります。

2. コマンドを打つのが面倒くさい

私はターミナルとVimで開発する古いタイプの人間なので、ターミナル上でカレントディレクトリをガンガン移動しながら開発します。
Laradockを操作するときはdocker-composeコマンドを実行することになるのですが、Laradockのインストールディレクトリ以外から実行するにはいちいち-fオプションや--env-fileオプションでdocker-compose.ymlファイルや.envファイルを指定しなければならず、コマンドを打つのが非常に面倒くさくなります。
かといって、いちいちLaradockのインストールディレクトリに移動するのも面倒です。

作ったもの

というわけで、上記の不便に感じる点を解消するために、Laradockのコマンドラッパーを作りました。
https://github.com/ngmy/laradockctl

下記のコマンドでプロジェクトルートにインストールして使います。

git submodule add https://github.com/ngmy/laradockctl.git

パスを通します。

export PATH=/PATH_TO_LARADOCKCTL/bin:$PATH

direnvを使ってパスを通すことをおすすめしています。

これでラッパーコマンド(laradockctl)が使えるようになります。

ラッパーコマンドの例です。

Composerを実行する
laradockctl workspace:composer
# docker-compose -f /PATH_TO_LARADOCK/docker-compose.yml --env-file /PATH_TO_LARADOCK/.env workspace composer のラッパー
Artisanを実行する
laradockctl laravel:artisan
# docker-compose -f /PATH_TO_LARADOCK/docker-compose.yml --env-file /PATH_TO_LARADOCK/.env workspace php artisan のラッパー
Laravelのアプリケーションログを表示する
laradockctl laravel:logs
# docker-compose -f /PATH_TO_LARADOCK/docker-compose.yml --env-file /PATH_TO_LARADOCK/.env workspace tail storage/logs/laravel.log のラッパー

素のdocker-composeコマンドを使う場合よりタイプ数がぐっと減ります。

またカスタムコマンドを追加することもできます。

私はプロジェクトごとに開発環境の起動コマンドを書いて、プロジェクトのリポジトリに一緒にコミットしています。

下記の例では、開発環境用のLaradockの.envファイルを使って必要なコンテナを起動して、開発環境用のLaravelの.envファイルをコピーして、Composerでパッケージをインストールして、Laravelの初期処理を行い、データベースマイグレーションを行い、フロントエンドのビルドを行うmy:upというコマンドを定義しています。

開発環境を起動するカスタムコマンドの例
#!/bin/bash
set -Ceuo pipefail

local NAME='my:up'
local DESCRIPTION='Launch my development environment'

handle() {
  cp -f ../.laradock/env-development .env
  docker-compose up -d nginx mysql mailhog workspace
  cp ../.env.development ../.env
  docker-compose exec workspace composer install
  docker-compose exec workspace php artisan key:generate
  docker-compose exec workspace php artisan migrate
  docker-compose exec workspace npm install
  docker-compose exec workspace npm run dev
}

このカスタムコマンドをプロジェクト内の適当な場所(.laradock/commandsなど)に置いて、パスを通します。

export LARADOCKCTL_COMMAND_PATH=/PATH_TO_YOUR_COMMAND:/PATH_TO_LARADOCKCTL/commands

これもdirenvを使うことをおすすめします。

これでカスタムコマンドが使えるようになります。

laradockctl my:up

上記のコマンドを実行するだけで開発環境が立ち上がるようになるので、ひさしぶりのメンテナンスでどのコンテナを起動すればいいのか忘れてしまっていても大丈夫です。

おわりに

Laradockをちょっと便利にするコマンドラッパーを作った話をしました。
Laradockを使っていて同じような点を不便に感じている人がいたらぜひ使ってみてください。

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

LaravelのCSRF対策の処理を実際のコードから見てみる

初めに

Laravelを使用している方でLaravelの扱い方に慣れていないころ、419エラーに出会った方は割といるのではないかと思います。
今回はその419エラーを生み出しているLaravelのCSRF対策の仕組みついて実際のコードから見ていきます!

環境

ツール バージョン
PHP 7.4.8
Laravel 8.14.0

CSRF対策とは?

CSRF(クロスサイト・リクエスト・フォージェリ)とは、ざっくり言うと罠サイト等から他の人のブラウザのCookieに書かれているセッションIDを取得し、そのIDを使用して特定のWebアプリケーションへアクセスすることで、元のセッションのデータの持ち主がリクエストをしたように偽装することです。詳しくはIPAの記事を見てみて下さい。

CSRF対策の一例

体型的に学ぶ安全なWebアプリケーションの作り方 第二版 4.5節 「重要な処理」の際に混入する脆弱性より

入力画面
<?php
session_start();
if (empty($_SESSION['token'])) {
  $token = bin2hex(openssl_random_pseudo_bytes(24));
  $_SESSION['token'] = $token;
} else {
  $token = $_SESSION['token'];
}
?>

<form action="45-003a.php" method="POST">
新パスワード<input name="pwd" type="password"><br>
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_COMPAT, 'UTF-8');?>">
<input type="submit" value="パスワード変更">
</form>
結果を映す画面
<?php
session_start();
$token = filter_input(INPUT_POST, 'token');
if (empty($_SESSION['token']) || $token !== $SESSION['token']) {
  die('正規の画面からご利用ください。'); //エラーメッセージ
}
//成功時の動作
?>

このプログラムが行っていることはこの3つです。
1. フォーム入力画面を作成時にPHPの暗号を生成する関数(openssl_random_pseudo_bytes)を実行し、その結果をセッションへセットする。
2. セッションへセットしたデータはhtmlの<form></form>内にtokenという名前でセットし、他の入力データと共にHTTPのPOSTリクエスト実行から結果を表示するプログラムへ送る。
3. 入力画面から送られてきたデータのうちtokenの名前に入っているデータを取り出し、それと入力画面作成時にセッションへセットしたデータを比べ、同じであれば成功時の処理を、違えばそこで処理を終了する。
図で表すとこのようになります。
CSRF対策の紹介.jpg

次にLaravel内でのCSRF対策の処理を見ていきます。

LaraveのCSRF対策のコードを見てみる

コードを見る前に

 LaravelのCSRF対策のコードは多数のクラスのメソッドによって成り立っているため、この章以降のコードを追うのみではCSRF対策の構造が分かりにくく、この記事自体を見る気が萎える方も出てしまう方も出てしまうかもしれません。なので、Laravelのコードを見る前にLaravelのデフォルトのCSRF対策のプログラムでやっていることとそのプログラムの進行の図をここでお見せします。もし、この章以降のコードを追う中でCSRF対策の構造の理解がしにくいな、と感じた場合はここを見返してみて下さい。
 LaravelのCSRF対策でやっていることは、暗号作成時にその暗号の値を/storage/framework/sessions/暗号の値へファイルとして保存し、その暗号の照合時に、リクエスト内で@csrfなどでhtmlの<form></form>内に_tokenという名前セットされたその暗号の値と作成時に作られたファイルに記された暗号の値を比べる、ということです。つまり、LaravelのデフォルトのCSRF対策では$_SESSIONは用いていないということです。
図で表すとこんな感じです。
Laravel内のCSRF対策.jpg

前振りはここまでにして、さっそくLaravelの実際のコードからCSRF対策の仕組みについて見ていきます。

セッションをチェックしているコードを見てみる

今回はセッションをチェックしているコードのうち、デフォルトでセットされているVerifyCsrfTokenに焦点を当てました。

VerifyCsrfToken

名前空間 Illuminate\Foundation\Http\Middleware

public function handle($request, Closure $next)
{
  if (
      $this->isReading($request) || // .....(1)
      $this->runningUnitTests() ||  // .....(2)
      $this->inExceptArray($request) ||  // .....(3)
      $this->tokensMatch($request)  // .....(4)
     ) {
         return tap($next($request), function ($response) use ($request) {
             if ($this->shouldAddXsrfTokenCookie()) {
                 $this->addCookieToResponse($request, $response);
             }
           });  // .....(5)
       }

       throw new TokenMismatchException('CSRF token mismatch.');
}

セッションのチェックを実行するhandleメソッドをまずは見ていきます。
このメソッドで行われていることは、(1)~(4)の条件のどれかをクリアしたリクエストは(5)へ進み、それ以外は例外処理が行われ、エラーが表示されるということです。そして、(1)~(4)の条件は何なのかというと、
(1) リクエストのHTTPメソッドがHEADGETOPTIONSであればtrue、それ以外はfalse
(2) リクエストがユニットテスト内の物であればtrue、それ以外はfalse
(3) セッションのチェックの対象外のリクエスト(App\Http\Middleware\VerifyCsrfTokenで設定できる)であればtrue、それ以外はfalse
(4) リクエストで送られてきたデータの中の_tokenの名前が与えられたデータとあらかじめ作成されたセッションデータが一致した場合true、そうではない場合はfalse

(5)の内容は、リクエストで送られてきたセッションデータがある場合はそれをXSRF-TOKENという名前を付けたCookieへ書き込み、ない場合は新たにセッションのデータを作成し、それを書き込む感じです。これは処理を渡されると必ず実行され、その結果が次の処理へ渡されます。(理由は$this->shouldAddXsrfTokenCookieメソッドはコードを書き換えない限り必ずtrueを返すから)

先ほど見てきた(1)~(4)の条件の中で、セッションの確認を行っている④のメソッドについて次は見ていきます。
(4)を行っているメソッドはtokensMatchメソッドで、コードは以下のようになっています。

protected function tokensMatch($request)
{
  $token = $this->getTokenFromRequest($request);

  return is_string($request->session()->token()) && is_string($token) &&
          hash_equals($request->session()->token(), $token); 
}

内容は非常にシンプルで、リクエストから送られてきた$tokenとリクエストにあらかじめセットされた$request->session()->token()が互いに文字列データであり一致している場合にtrueを返します。(hash_equalsを用いることでタイミング攻撃に対して安全に文字列データを比較できます。)

セッションの値の確認方法は分かりましたが、$request->session()->token()の値と比較する対象である$tokenがどのようにリクエストのデータから取得できるか謎なので、getTokenFromRequestメソッドについて調べていきます。

protected function getTokenFromRequest($request)
{
  $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN'); //..(1)

  if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
    $token = CookieValuePrefix::remove($this->encrypter->decrypt($header,
              static::serialized()));
  } //...(2)

  return $token;
}          

getTokenFromRequestメソッドでは以下の二つのことが行われています。
(1) リクエストで送られてきた_tokenの名前の付いたデータの値かそれが空であれば、X-CSRF-TOKENという名前のリクエストのヘッダーの値が代わりに入る。
(2) $tokenの値がnullかつX-XSRF-TOKENという名前のリクエストのヘッダーの値が存在し、nullではない場合にX-XSRF-TOKENの値がうまく復号化できた場合にその値を$tokenに入れる。

X-CSRF-TOKENX-XSRF-TOKENのデフォルトの値はnullなので、もしリクエストの_tokenのデータが設定されていない場合はgetTokenFromRequestメソッドはnullを返し、その場合tokensMatchメソッドの$request->session()->token()$token一致しないため、tokensMatchメソッドはfalseを返します。こういうわけで@csrfなどを含めずにLaravelのPOSTメソッドを使ったリクエストを実行するとエラーが起こります。

LaravelのCSRF対策のためのセッションの比較の仕方は分かりましたが、セッションの生成の方法がまだ分かりません。そこで今度はデフォルト時のLaravelのセッションの生成の仕方を見てみます。

セッションが作られる過程を見てみる

StartSession

名前空間 Illuminate\Session\Middleware

StartSessionIlluminate\Foundation\Http\kernel$middlewarePriority配列の要素の一つです。ちなみにこのkernelpublic/index.phpを見てもらえれば分かると思うのですが、Laravelのシステム全体が動くときの起点となります。
では最初にhandleメソッドについて見ていきます。

//初期設定
public function __construct(SessionManager $manager,
 callable $cacheFactoryResolver = null)
{
  $this->manager = $manager; //SessionManagerはここでセットされている
  $this->cacheFactoryResolver = $cacheFactoryResolver;
}

public function handle($request, Closure $next)
{
  if (! $this->sessionConfigured()) {
      return $next($request);
  } //...(1)

  $session = $this->getSession($request); //...(2)

  if ($this->manager->shouldBlock() || 
     ($request->route() instanceof Route && $request->route()->locksFor())) {
      return $this->handleRequestWhileBlocking($request, $session, $next);
  } else {
      return $this->handleStatefulRequest($request, $session, $next);
  } //...(3)
}

このプログラムの内容はこの3つです。
(1) もしconfig\session.phpが存在しない、もしくは中身がない場合はfalseを返してクロージャの処理をするが、たいていのLaravelのアプリケーションの場合そんなことはないので、何もせずに次の処理へ
(2) セッションを取得する。詳しくは後で見ていく。
(3) もしconfig\session.php内の配列のblockキーの値がtrueまたはリクエストのURLの値からIlluminate\Routing\RouteインスタンスになるかつセッションごとにURLのリクエスト制限がない時に$this->handleRequestWhileBlocking($request, $session, $next)は行われるが、それ以外は$this->handleStatefulRequest($request, $session, $next);が実行される。

config\session.php内の配列のblockキーの値はたいてい設定のでfalseであり、セッションごとにURLのリクエスト制限はデフォルトではありません。よってhandleメソッドは大抵の場合$this->handleStatefulRequest($request, $session, $next);を最終的に実行します。

全体の流れを見たところで、次はセッションの取得について見ていきます。

public function getSession(Request $request)
{
  return tap($this->manager->driver(), function ($session) use ($request) {
    $session->setId($request->cookies->get($session->getName()));
  });
}

getSessionで行っていることは、セッションを扱うインスタンス(Illuminate\Session\Storeインスタンス、詳細は後で見ていく)をセットし、そのインスタンスへリクエストのCookieデータのうちconfig\session.php内の配列のcookieキーの値の名前が付けられたデータを代入します。

次はhandleメソッドの処理の目玉であるhandleStatefulRequestについて見ていきます。

protected function handleStatefulRequest
(Request $request, $session, Closure $next)
{
  $request->setLaravelSession($this->startSession($request, $session)); //...(1)

  $this->collectGarbage($session); //...(2)

  $response = $next($request); //...(3)

  $this->storeCurrentUrl($request, $session); //...(4)

  $this->addCookieToResponse($response, $session); //...(5)

  $this->saveSession($request); //...(6)

  return $response;
}

handleStatefulRequestの内容はこの5つです。
(1) リクエストのCookieデータに従ってセッションを設定し、Illuminate\Session\StoreインスタンスをIlluminate\HttpRequestインスタンスへセットする。これを行うことで、$request->session()によりStoreインスタンスをいつでも呼び出せる。Storeインスタンスについては後で見ていく。
(2) 古くなったセッションデータ(config\session.php内の配列のlifetimeキーの値を超えているデータ)を削除する。
(3) セッションと関りが浅いと思い全く調べていないので、処理の詳細は分からない。おそらくIlluminate\HttpResponseインスタンスを返しているのかな?
(4) storeCurrentUrlの処理の説明は必要なときのために現在のURLを保存すると書いてあるが、これがどれほど重要かはいまいちわからない。GETリクエスト以外では効果なし。
(5) レスポンスのヘッダーに、config\session.php内の配列のcookieキーの値の名前がついたCookieのデータをセットする。
(6) セッションのデータを保存する。保存場所・方法については後で見ていく。

(1)~(6)の処理が終わった後、レスポンスのインスタンスを返します。
ちなみにセッションをセットするstartSessionの処理はこんな感じです。

protected function startSession(Request $request, $session)
{
  return tap($session, function ($session) use ($request) {
    $session->setRequestOnHandler($request); //デフォルトの状態では無視してよし

    $session->start();
  });
}

Illuminate\Session\Storeインスタンスのstartメソッドを実行しStoreインスタンスを返します。このstartメソッドについては後で見ていきます。

これまではセッションの作成過程を大まかにみてきましたが、次はセッションの作成・管理を行うインスタンスがどのようにセットされているのかを見ていきます。

Manager(abstract class)

名前空間 Illuminate\Support
このManagerクラスは抽象クラスで、このクラス内にセッションを作成するなどの機能はなく、セッションや他のLaravelの機能を使用する際にそれぞれの機能を担うクラスたちをまとめる役割があります。

public function driver($driver = null)
{
  $driver = $driver ?: $this->getDefaultDriver(); //...(1)

  if (is_null($driver)) {
    throw new InvalidArgumentException(sprintf(
    'Unable to resolve NULL driver for [%s].', static::class));
  } //...(2)

  if (! isset($this->drivers[$driver])) {
    $this->drivers[$driver] = $this->createDriver($driver);
  } //...(3)

  return $this->drivers[$driver];
}

このメソッドで行っていることは以下の三つです。
(1)このメソッドの引数にインスタンスが指定されていればそれを$driverへ入れ、引数が指定されていない場合は、継承先のクラスに存在するgetDefaultDriverメソッドの結果を入れる。デフォルトでは引数はnullなため、getDefaultDriverメソッドの結果が$driverへ入れられる。getDefaultDriverメソッドについては後で説明する。
(2)もし$driverの値がnull出会った時に例外処理が行われる。
(3)もし$this->drivers[$driver]に値が既にある場合は何もせず、ない場合は$driverの値をcreateDriverメソッドへ渡し、その結果を$this->drivers[$driver]へ入れる。

(1)~(4)が終了した後、$this->drivers[$driver]の値を返します。
次にこのメソッドの(3)に登場したcreateDriverメソッドについて見ていきます。

protected function createDriver($driver)
{
  if (isset($this->customCreators[$driver])) {
    return $this->callCustomCreator($driver); //...(1)
  } else {
    $method = 'create'.Str::studly($driver).'Driver'; //...(2)

    if (method_exists($this, $method)) {
      return $this->$method(); //...(3)
    }
  }

  throw new InvalidArgumentException("Driver [$driver] not supported.");
}

このメソッドで行っていることは以下の三つです。
(1)もし$this->customCreators[$driver]に値がセットされている場合はcallCustomCreatorメソッドの結果を返すが、あらかじめ$this->customCreators[$driver]に値がセットされていることはないのでここでは扱わない。
(2)あらかじめ設定されている文字列とLaravelのhelperメソッドの一つであるStr::studlyメソッドの引数へ$driverの値を入れるた結果の値を結合した値を$methodへ入れる。getDefaultDriverメソッドを紹介する時に説明するが、$driverの値は必ず文字列データになり、その値がStr::studlyメソッドによってそのデータの頭文字が大文字になる。
(3)method_existsのよってこのクラスまたは継承先のクラスに$methodの名前を持ったメソッドが存在する場合はその結果を返す。もし存在しない場合は例外処理が行われる。

次はManagerクラスの継承先の一つであり、getDefaultDriverメソッドと$methodの名前を持ったメソッドを有しているSessionManagerクラスについて見ていきます。

SessionManager(Managerを継承)

名前空間 Illuminate\Session

public function getDefaultDriver()
{
  return $this->config->get('session.driver');
}

このメソッドは継承元であるManagerクラスのcreateDriverメソッドの引数である$driverの値を作成してます。
このメソッドは/config/session.phpの配列内のdriver要素の値を返します。この要素のデフォルトの値は'file'という文字データなので、createDriverメソッドの引数である$driverの値はデフォルトでは必ず文字列データになり、どうメソッド内の$methodの値はcreateFileDriverになります。

先ほど見たように継承元であるManagerクラスのcreateDriverメソッドは継承元かこのクラスの$methodの値のメソッドの結果を返します。さきほど$methodの値はcreateFileDriverということが分かったので、今度はこのクラス内のcreateFileDriverメソッドについて見ていきます。

protected function createFileDriver()
{
  return $this->createNativeDriver();
}

protected function createNativeDriver()
{
  $lifetime = $this->config->get('session.lifetime'); //...(1)

  return $this->buildSession(new FileSessionHandler(
  $this->container->make('files'), $this->config->get('session.files'), 
   $lifetime)); //...(3)
}

createFileDriverメソッドは同クラス内のcreateNativeDriverメソッドの結果を返します。ということでcreateNativeDriverメソッドについて見ていきます。
createNativeDriverメソッドの内容は以下の二つです。
(1)/config/session.phpの配列内のlifetime要素の値を$lifetimeへ入れる。(デフォルト値は120)
(2)$this->container->make('files')で得られる、ファイル書き込み機能を実装したIlluminate\Filesystem\Filesystemインスタンスと/config/session.phpの配列内のfiles要素の値である/storage/framework/sessions/までのパス名を引数として渡したセッションの値をファイルへ書きこむ機能を担うFileSessionHandlerインスタンスを同クラス内のbuildSessionメソッドへ渡し、その結果を返す。

buildSessionメソッドとはLaravelのセッションの様々な操作が書かれているクラスのインスタンスを返すメソッドなのですが、このメソッドについて掘り下げていきます。

protected function buildSession($handler)
{
  return $this->config->get('session.encrypt')
    ? $this->buildEncryptedSession($handler)
    : new Store($this->config->get('session.cookie'), $handler);
}

buildSessionメソッドは、/config/session.phpの配列内のencrypt要素の値がfalseの場合はセッションの操作を行うプログラムが書かれたIlluminate\Session\Storeクラスの引数に先ほどのFileSessionHandlerインスタンスを入れたインスタンスが返され、falseの場合はStoreクラスに$this->container['encrypter']で得られるIlluminate\Encryption\Encrypterクラスの機能が合わさったIlluminate\Session\EncryptedStoreインスタンスが返されます。デフォルトの場合はencrypt要素の値はfalseなのでStoreインスタンスが返されます。

いったんここまでの流れをまとめる(読み飛ばしても大丈夫)

ここまでCSRF対策を行うVerifyCsrfTokenクラスやStartSessionクラスのコードを見ながら、Laravel内のCSRF対策やセッションの作成過程を見てきましたが、セッションの作成やセッションの内容のファイルへの書きこみ、取り出しの過程をPHPでどのように実現させているのかをまだ見ていません。なので、次はトークンの作成と管理の機能がPHPのコードにより実装されているクラスを見ていくのですが、そのクラスの理解を容易にするためにセッションの作成過程が書かれているStartSessionhandleメソッドとhandleStatefulRequestメソッドで行われることの流れとその結果をおさらいします。

handleメソッドの流れとそれぞれの工程の結果はこんな感じでした。

  • 送られてきたリクエストのCookieのからセッションのデータを取り出す。⇒ Illuminate\Session\StoreインスタンスのsetIdメソッドを実行したうえで、Storeインスタンスを呼び出す。
  • Illuminate\Http\RequestインスタンスとStoreインスタンスをhandleStatefulRequestメソッドへ渡す。

handleStatefulRequestメソッドの流れとそれぞれの工程の結果はこんな感じでした。

  • 同クラス内のstartSessionメソッドでセッションを作成する。⇒ Storeインスタンスのstartメソッドを実行
  • 期限切れのセッションを削除する。⇒ StoreインスタンスのgetHandlerメソッドを実行
  • 作成したセッションを保存する。⇒ Storeインスタンスのsaveメソッドを実行

上の二つのメソッドの結果を見てわかる通り、セッションの作成にはIlluminate\Session\Storeインスタンスが密接にかかわっています。ということで次はStoreインスタンスの元であるIlluminate\Session\Storeクラスについて見ていきます。

Store

名前空間 Illuminate\Session

//初期設定
public function __construct($name, SessionHandlerInterface $handler, $id = null)
{
  $this->setId($id);
  $this->name = $name;
  $this->handler = $handler;
}

Storeクラスのコンストラクトメソッドは、StartSessionクラスのgetSessionメソッド実行時、ひいてはManagerクラスのdriverメソッド実行時にStoreインスタンス作成した際に呼び出されます。その時の$nameの値は$this->config->get('session.cookie')$handlerの値はIlluminate\Session\FileSessionHandlerインスタンスとなります。(ここら辺はSessionManagerクラスの説明の中で書かいています。)引数$idは指定されていないので、$this->idにはランダムな文字列データが入れられますが、getSessionメソッドの実行中に$this->idは書き換えられるので、気にしなくて良いです。

public function setId($id)
{
  $this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}

public function isValidId($id)
{
  return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}

protected function generateSessionId()
{
  return Str::random(40);
}

StartSessionクラスのgetSessionメソッドで呼び出されるこのクラス内のsetIdメソッドは、引数の値がセッションの形式に沿っているか確かめ、もし沿っているのであればその値を返し、ダメであれば新たにセッションIDを作成し、$this->idへその値を入れます。
セッションの形式の確認は同クラス内のisVaildIdメソッドで行っており、このメソッドでやっていることは、文字列データかつ英数字かつ文字数が40文字であればtrue、そうでなければfalseを返します。(詳しくはis_stringctype_alnumstrlenを見て下さい。)
セッションIDの作成は同クラス内のgenerateSessionIdメソッドで行っています。このメソッドの内容はただ単にStr::random(40)で作成された40時のランダムな文字列データを返します。ちなみにStr::random()にはrandom_bytesが使われているようです。

public function start()
{
  $this->loadSession(); //...(1)

  if (! $this->has('_token')) {
    $this->regenerateToken();
  } //...(2)

  return $this->started = true; //...(3)
}

StartSessionクラスのstartSessionメソッドで実行されるstartメソッドで行われることは以下の三つです。
(1)セッション情報が書かれたファイルを取り出し、その値を$this->attributes配列へ入れる。詳しくは後で見ていく。
(2)もし$this->attributes配列に'_token'がキーとなる値が存在しない場合同クラス内のregenerateTokenメソッドを実行。regenerateTokenメソッドについては後で見ていく。
(3)最後に$this->startedtrueを入れる。$this->startedはセッションの様々な操作に使われているようだが、どのように使われているかまでは調べきれなかったのでわからない。

次はstartメソッドで一番初めに実行されるloadSessionについて見ていきます。

protected function loadSession()
{
  $this->attributes = array_merge($this->attributes, $this->readFromHandler());
}

protected function readFromHandler()
{
  if ($data = $this->handler->read($this->getId())) { //......(1)
    $data = @unserialize($this->prepareForUnserialize($data)); //......(2)

    if ($data !== false && ! is_null($data) && is_array($data)) {
      return $data; //......(3)
    }
  }

  return [];
}

public function getId()
{
  return $this->id;
}

loadSessionメソッドでは、同クラス内のreadFromHandlerメソッドの結果を$this->attributesへ加えています。そして、そのメソッド内で実行されているreadFromHandlerメソッドは以下の三つのことを行っています。
(1)同クラスのsetIdメソッドで$this->idへセットされた値をgetIdメソッドで呼び出し、その値を引数にとりFileSessionHandlerインスタンスのreadメソッドが実行される。raedメソッドによりもし$this->idの値と同じ名前のファイルが存在する場合、そのファイルの内容を$dataへ入れて(2)以降を実行し、存在しなければ文字列データの空の値('')を$dataへ入れ、何もせず空の配列を返す。
(2)ファイルから取得したセッションの情報は作成時に保存用の表現としてシリアル化されているので、unserializeメソッドでPHPで扱える値に復元する。(prepareForUnserializeメソッドはただ引数を返すだけ)
(3)もしデータがうまく復元されなかったり、nullであったり、文字列データ出ない場合は空の配列を返し、そうでなければ$dataの値をそのまま返す。

今度は、loadSessionメソッドが終了した後に行われるhasメソッドについて見ていきます。

public function has($key)
{
  return ! collect(is_array($key) ? $key : func_get_args())
             ->contains(function ($key) 
  {
    return is_null($this->get($key));
  });
}

public function get($key, $default = null)
{
  return Arr::get($this->attributes, $key, $default);
}

hasメソッドではcollectメソッドを使用してIlluminate\Support\Collectionインスタンスを呼び出し、そのインスタンスのメソッドであるcontainsメソッドにより引数の$keyがキーとなる値が$this->attributesにある場合はtrue、ない場合はfalseを返します。containsメソッドの引数内で実行されているgetメソッドArr::getによって$this->attributesに引数$keyの値をキーにとる値がある場合はその値を返し、ない場合はからの文字列データを返します。

次にもしhasメソッドの結果がtrueの場合(ブラウザのクッキーにセッションが存在しない状態でリクエストが来た場合)に行われるregenerateTokenメソッドとそのメソッド内で実行されるputメソッドを見ていきます。

public function regenerateToken()
{
  $this->put('_token', Str::random(40));
}

public function put($key, $value = null)
{
  if (! is_array($key)) {
    $key = [$key => $value];
  }

  foreach ($key as $arrayKey => $arrayValue) {
    Arr::set($this->attributes, $arrayKey, $arrayValue);
  }
}

regenerateTokenメソッドはgenerateSessionIdメソッドと同じようにランダムな文字列データを作成し、その値を同クラス内のputメソッドで_tokenをキーとして$this->attributesにセットします。putメソッドは引数の$keyをキーとし、$valueをその値としてArr::setにより$this->attributesへセットするか、$key自体が配列の場合はそれを$this->attributesへセットします。

この次はhandleStatefulRequestメソッドの最後に実行されるsaveメソッドを見ていきます。

public function save()
{
  $this->ageFlashData();

  $this->handler->write($this->getId(), $this->prepareForStorage(
    serialize($this->attributes)));

  $this->started = false;
}

このメソッドでは、FileSessionHandlerインスタンスのwriteメソッドにより、リクエストのクッキーのセッションIDの値(なければ新たに作成)を名前にとるファイルを新たに作成し、それに$this->attributesを(serialize)[https://www.php.net/manual/ja/function.serialize]でシリアル化させた値を書き込みます。そして最後におそらくセッションの処理が終わったことを知らせるために`$this->started = false`を行います。

そして、最後に後で説明するヘルパーメソッドであるcsrf_tokenメソッドで実行されるtokenメソッドについて説明していきます。

public function token()
{
  return $this->get('_token');
}

このメソッドの説明は簡潔で、getメソッドにより_tokenをキーにとる値が$this->attributesに存在すればその値を返し、なければ空の文字列データを返します。$this->attributes[_token]が存在しないことはデフォルトではまずないので、何かしらの文字列データを返します。

Storeクラスの中でFileSessionHandlerインスタンスが何回か登場したので、今度はFileSessionHandlerクラスについて見ていきます。

FileSessionHandler

名前空間:Illuminate\Session

public function __construct(Filesystem $files, $path, $minutes)
{
  $this->path = $path;
  $this->files = $files;
  $this->minutes = $minutes;
}

このクラスのコンストラクトメソッドはSessionManagerクラスのbuildSessionメソッドの中で実行されます。この時の引数$fileには$this->container->make('files')Illuminate\Filesystem\Filesystemインスタンス), 引数$pathには$this->config->get('session.files')/config/session.phpfile要素の値)、引数$minutesには$this->config->get('session.lifetime')/config/session.phplifetime要素の値)が入ります。
次はStoreクラスのreadFromHandlerメソッド内で実行されるreadメソッドについて見ていきます。

public function read($sessionId)
{
  if ($this->files->isFile($path = $this->path.'/'.$sessionId)) { //...(1)
    if ($this->files->lastModified($path) >= Carbon::now()
         ->subMinutes($this->minutes)->getTimestamp()) { //...(2)
      return $this->files->sharedGet($path); //...(3)
    }
  }

  return '';
}

readメソッドでは以下の三つのことを行っています。

(1)Illuminate\Filesystem\FilesystemクラスのisFileメソッドで$this->path.'/'.$sessionIdの値のパスとファイル名を持つファイルがないか検証し、ある場合は$pathへそのファイルのパスとファイル名を入れて次の処理へ進み、ない場合は空の文字列データを返す。
(2)FilesystemクラスのlastModifiedメソッドにより(1)で取得した情報からそのファイルが最後に書き込まれた時間を割り出し、もしその値がCarbon\CarbonクラスのsubMinutesメソッドなどを使用し、現在の時刻から/config/session.phplifetime要素の値を引いた数以上だった場合は次の処理へ進み、そうでない場合は空の文字列データを返す。lastModifiedメソッドとgetTimestampメソッド(こちらはおそらく)はint型のUnix タイムスタンプで時刻を返し、Unix タイムスタンプは時間が進むほど大きくなる。つまり、ここで行っていることを言い換えるとファイルの作成時刻とリクエストが発生した時刻の差分がlifetime要素の値より小さい(新しい)場合は次の処理、大きい(古い)場合は空の文字列データを返す、ということになる。
(3)ここでは$pathを引数にとりFilesystemクラスのsharedGetメソッドを実行する。sharedGetメソッドについては後で見ていく。

最後にStoreクラスのsaveメソッドで実行されるwriteメソッドを見ていきます。

public function write($sessionId, $data)
{
  $this->files->put($this->path.'/'.$sessionId, $data, true);

  return true;
}

といってもこのメソッドは単に、$this->config->get('session.files')の値と引数$sessionIdの値を組み合わせたものと引数$dataFilesystemクラスのputメソッドへ渡し、trueを返すということを行います。putメソッドについては後で見ていきます。

次はこのクラスでたびたび出てきたFilesystemクラスについて見ていきます。

Filesystem

名前空間:Illuminate\Filesystem

public function isFile($file)
{
  return is_file($file);
}

このメソッドはis_fileの結果を返します。もし引数の$fileが通常のファイルならtrueを返し、ディレクトリなどそれ以外ならfalseを返します。

public function lastModified($path)
{
  return filemtime($path);
}

このメソッドはfilemtimeメソッドの結果を返します。引数$pathが最後に書き込まれた時刻をint型のUnix タイムスタンプで返します。失敗した時は警告が発生します。

public function sharedGet($path)
{
  $contents = ''; //...(1)

  $handle = fopen($path, 'rb'); //...(2)

  if ($handle) { //...(3)
    try {
      if (flock($handle, LOCK_SH)) { //...(4)
        clearstatcache(true, $path); //...(5)

        $contents = fread($handle, $this->size($path) ?: 1); //...(6)

        flock($handle, LOCK_UN); //...(7)
      }
    } finally {
      fclose($handle); //...(8)
    }
  }

  return $contents; //...(9)
}

このメソッドで行うことは以下の9つです。

(1)$contentsの初期化
(2)fopenメソッドにより、$pathのファイルのポインタリソースを返す。つまり、対象のファイル中の動作をする場所を返している。もっと知りたい方はこちら(PHPのポインタについてはまだ勉強不足なので、ここについて詳しく書けない。)ちなみにfopenメソッドの第二引数は行う動作を決めるものであり、rbはバイナリモードでファイルを読み込むという動作を表している。バイナリモードについては勉強不足で説明できない。(教えて頂けるとありがたいです。)
(3)fopenメソッドは失敗した場合にfalseを返すので、失敗した時だけ(9)へ進み、それ以外は次の処理へ進む。
(4)flockメソッドとは、第一引数のポインタリソースを持つファイルの状態の変更を止めたり、他のプログラムからの参照を止めることで競合するプログラムが存在してもアプリケーション全体を問題なく動作させる機能を持つ。ちなみに第二引数のLOCK_SHによって、他のプログラムから同じファイルのデータを参照できるが、変更できなくさせている。もしぴんと来なければ、こちらの記事をどうぞ。
(5)clearstatcacheメソッドは第一引数をtrueにすることで第二引数のファイルのキャッシュ値を削除する機能を持つ。データが頻繁に変化するファイルを扱うときに便利。
(6)freadfopenメソッドで参照するファイルのリソースポインタから同クラスのsizeメソッド内のfilesizeメソッドで入手したファイルのサイズ分(単位はバイト)の内容を読み込み、その結果を$contentsへ入れる。もしファイルサイズが読み取れなかった場合は1バイト分読み込む。
(7)ファイルの読み込みが終わった後、そのままでは他のプログラムが読み込んだファイルの変更ができないため第二引数をLOCK_UNにしたflockメソッドによって、ファイルを再び自由に変更可能にします。
(8)fopenメソッドで参照したファイルのリソースポインタを閉じる動作としてfcloseメソッドを行う。ちなみにこのメソッドを書かなくてもfopenメソッドで参照したファイルのリソースポインタは勝手に閉じられる。
(9)$contentsの値を返す。

ここで、なぜfile-get-contentsメソッドを使用せず、freadメソッドを使用しているのか疑問に思う方もいるかと思います。なぜfreadメソッドを使用するのかの解答は(調べてなくて)分かっていませんが、おそらくバイナリセーフなfreadメソッドを使用することでNullバイト攻撃を防ぐためだと思います。バイナリセーフ、Nullバイト攻撃についてはこちらの記事をどうぞ。

最後にStoreクラスのsaveメソッドで使用されたputメソッドを見ていきます。

public function put($path, $contents, $lock = false)
{
  return file_put_contents($path, $contents, $lock ? LOCK_EX : 0);
}

このメソッドで行われていることは、file_put_contentsメソッドで引数$pathに書かれているファイルへ$contentsのデータを書き込みます。Storeクラスのsaveメソッドにより$lock=trueとなるため、file_put_contentsメソッドは他のプログラムによる書き込むファイルの読み込みと変更を禁止する状態(これを排他ロックというらしい)で実行されます。file_put_contentsメソッドが成功した場合はファイルへ書き込んだデータの量(バイト数)が返され、失敗した場合はfalseが返されます。

一番最後に$request->input('_token')の値を作成するヘルパーメソッドのcsrf_tokenメソッドを見ていきます。

csrf_token(helper関数)

ファイル場所 laravel/framework/src/Illuminate/Foundation/

if (! function_exists('csrf_token')) {
  /**
   * Get the CSRF token value.
   * @return string
   * @throws \RuntimeException
   * /
   function csrf_token()
   {
     $session = app('session');
     if (isset($session)) {
       return $session->token();
     }

     throw new RuntimeException('Application session store not set.');
    }
}

このメソッドはもしsessionの解決で何らかのインスタンス(おそらくStoreインスタンス)が得られた場合にそのインスタンスのtokenメソッドの結果を返します。もし何も得られなかった場合は例外処理に映ります。tokenメソッドについてはStoreクラスを見ていく中で取り上げているので、そちらを見て下さい。sessionの解決がStoreインスタンスを返す根拠は明確に見つかったわけではありませんが、同じヘルパーメソッドのsessionメソッドのコードのPHPDocの返り値の部分が@return mixed|\Illuminate\Session\Store|\Illuminate\Session\SessionManagerになっていたのに対し、実際に返している値がapp('session')だからです。ここについては今後もう少し調べていきます。

終わりに

ここまで見てくれてありがとうございます、そしてお疲れ様です。
今回のコードリーディングで、Laravelと一般的なコードでCSRF対策のために行っていることの根本は変わらないということが知れて良かったです。
しかし、詳しく調べる気力が出なかったので、この記事の中には「きっとこんな感じだろう」という憶測で書いてしまっている部分もあります。今後もLaravelのコードを読むことで、このような部分を潰していきたいと思います。もし気になる、疑問に思うところがあれば指摘してくださるとありがたいです。
今後もLaravel内で気になることがあれば、こんな風にコードリーディングをできたらなーと思います。

ちなみにこんな記事も書いてます。良かったらぜひ
Laravelのサービスコンテナのバインドと解決の仕組みが知りたい!
Laravelのsingletonメソッドの機能とその仕組みについて

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

Dockerの思い出しついでにlaravel動作環境を構築してみた

導入

ちょっと前に取り組んでいたdockerの内容を思い出しついでにlaravelの動作環境構築にチャレンジしてみました。
過去に docker laravel 環境構築 とかでググった参考サイトをもとに環境構築したことはありますが、
alpineオフィシャルイメージ(クリーンインストール状態?)を環境構築のスタートとし、PHP動作環境を構築 + nginxを導入 + laravelのインストールnginxとの連携までを行いました。
無事環境構築に成功したので、今回の総括の意味を込めて振返り記事を作成しました。

完成した内容は以下のリポジトリで公開してます
※ ツッコミどころ満載だと思います
https://github.com/AiRKING-no9/nginxBuildEnvOnAlpine

投稿者のすぺっく

  • 仕事 or 趣味でwebブラウザのフロント関連とPHPのサーバーサイドアプリケーションをさわる
  • 仕事で使う開発環境はvirtualbox or dockerで動作する環境を提供してもらってた。フロントソースを動作させる上で開発環境に調整必要そうならググって調整加えるくらいならやってた
  • Linuxで動いているサーバーに入ってコマンド叩いてちょこちょこ触れる
  • クリーンインストールされた状態のLinux系OSに対してPHP動作環境構築とかやったことない
    自分のホストOS汚したくなくてalpineのオフィシャルイメージからコンテナ立ち上げ→node突っ込んでフロントソースコンパイルを過去やってみた程度
  • nginxを手順に沿って導入してwelcome画面をみて満足した
    nginxの細かい設定とか設定ファイルの内容よく知らない
  • dockerfileとdocker-composeファイルをある程度調べながらdockerコンテナの構築はできる

つまづいた・悩んだポイント

その1: パッケージマネージャー(apk)経由でPHP7をインストールしようとしたら見つからない

今回 2020/11月頃時点で最新だったalpine3.12を利用しましたが、最新のalpineバージョンを利用するとPHP本体とPHP関連のパッケージが見つからなかった...
最終的にapkが参照するリポジトリを追加することで無事 apk add パッケージ名 でPHP本体とPHP関連のパッケージがインストールできました。
※ 今回は http://dl-cdn.alpinelinux.org/alpine/edge/community のURLを /etc/apk/repositories ファイルに追記することでリポジトリが追加されうまくインストールできました。

その2 : php-fpmをインストール成功 → php-fpm not found になる

apk add php-fpm ですんなりインストールできたと思ったら php-fpm -v を実行してもphp-fpm not foundが表示されてしまう...
結果としてphp-fpm7 という名前で /usr/sbin/ ディレクトリ配下にインストールされていた。
自分の場合1文字多くなるけどphp-fpm7でいいやと思ったのでそのままphp-fpm7でコマンド実行をつづけたが、
どうしてもphp-fpmで実行したいという人はphp-fpm7を対象にphp-fpm命名でシンボリックリンクを作成すればOKです。

今回の件を通して、apkのインストール先ディレクトリどこ?とか色々調べましたが、
調べている中で、binディレクトリsbinディレクトリの役割があることを知りました。
Macで何気なくbinディレクトリとか触ってたけど役割あるなんて知らなかったんよ(´・ω:;.:...
以下、調査時に参考にした資料

その3 : laravelとnginxの連携がうまく行かない

nginxの導入→ welcome画面の表示がうまくいき、composer経由でlaravelプロジェクトの作成までは順調に進めた。
しかし、nginxとlaravelを連携させようとするとnginxがlaravelのページ出力を返してくれなかった。
うまく行かなった原因として、nginxの設定ファイルとphp-fpmの設定ファイルを編集することでうまく応答してくれるようになった。
nginxとかphp-fpmの設定全く知らなかったのでここが一番解決まで時間かかった。

  • nginxの設定ファイル(/etc/nginx/conf.d/default.conf)
server {
    listen 80;
    server_name  localhost;

    access_log  /var/log/nginx/laravelProject/access.log  main;
    error_log   /var/log/nginx/laravelProject/error.log  warn;
    # 以下でlaravelプロジェクトのpublicを参照するように設定
    root /www/laravelProject/public;

    location / {
        index index.php index.html;
        try_files $uri $uri/ /index.php$is_args$args;
    }

    # phpファイル実行時のfastCGIの接続設定
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php-fpm/php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}
  • php-fpmの設定ファイル(/etc/php7/php-fpm.d/www.conf)
[www]

user = nginx
group = nginx

listen =/var/run/php-fpm/php-fpm.sock
listen.owner = nginx
listen.group = nginx

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

その4 : ホストとコンテナのディレクトリ同期を行うと空ディレクトリになる

dockerfile内ではじめはcomposer create-project laravel/laravel=5.5 プロジェクト名でコンテナ内でlaravelプロジェクトが作成され動作することを確認していました。
ある程度のlaravel動作環境が構築できたので、ホストとコンテナを同期ディレクトリでマウント → 同期ディレクトリ先にlaravelプロジェクトインストールを実行したところ空ディレクトリになってしまいました。

最終的にコンテナを一旦構築完了したあとにdocker exec経由でlaravelプロジェクトの作成コマンドを実行することで同期ディレクトリ内にうまく展開することができましたが、
正直今回のやり方が正解だとは思わないので現在も原因調査中です。
※ コンテナ構築中に同期ディレクトリ触るとダメなのかな?

総括

いままでLinux系OSがクリーンインストールされた状態からここまで環境構築したことがなかったので、いろいろと苦戦しました。
が、当初の目標を達成できたことで学べた内容も新鮮でした。途中で折れない・諦めない心大事。
正直alpineベースのPHP動作環境コンテナが構築できるオフィシャル・アンオフィシャルイメージがあるのでそちらをベースイメージに利用すればここまでやる必要がないのですが、
必要最低限のミドルウェアを導入して環境構築できた謎の自己満足は満たされました。
なによりLinux系OSを学ぶ上でクリーンインストールされた状態から色々Tryして実験できたのでそちらのほうが得るものが大きかったです。:thumbsup:
やっぱりDockerは触って失敗したら即破棄→再構築できるので便利だなと再確認できました。

以上!!

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