- 投稿日:2020-12-23T22:50:42+09:00
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.phpuse 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 EstimateappディレクトリにEstimateモデルが作成されます。
Estimate.php<?php namespace App; use Illuminate\Database\Eloquent\Model; class Estimate extends Model { // }Estimateモデルに記述はしていませんが、継承元であるModelクラスで様々な設定を読み取ってくれるらしいです。
これでデータを扱う準備ができたのですが、テストデータが入っていた方がコントローラーを書きやすいので、Seederを用いてデータを挿入します。
$ php artisan make:seeder EstimatesTableSeederrunメソッドの中にデータを挿入するコードを記述します。ここでは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.phpRoute::get('/estimates', 'EstimateController@index')->name('estimates.index');コントローラークラスの作成
コントローラークラスはコマンドラインから作成。
$ php artisan make:controller EstimateController作成されたEstimateController.phpにindexメソッドを追加します。
EstimateController.phpuse 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.phplayout.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 ItemItem.php<?php namespace App; use Illuminate\Database\Eloquent\Model; class Item extends Model { // }テストデータを挿入するためにシーダーを作成します。
$ php artisan make:seeder ItemsTableSeederItemsTableSeder.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.phpRoute::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.phpuse 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'を指定します。
次のポイントはデータベースに書き込む処理です。データベースへの書き込みは以下の手順で実装します。
- モデルクラスのインスタンスを作成する。
- インスタンスのプロパティに値を代入する。
- saveメソッドを呼び出す。
これにより、モデルクラスが表すテーブルに対してINSERTが実行されます。
次回
ここまで、見積一覧ページと見積編集ページのコントローラーを作成しました。次回はVue.jsを利用した見積編集ページのテンプレートを作成します。
- 投稿日:2020-12-23T18:10:33+09:00
画像、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'); } }
- 投稿日:2020-12-23T12:15:21+09:00
Laravel + livewire 年齢算出
laravelを使った開発が一旦暇になったのでlaravel8にあるlivewireを試しにいじってみました。
ある方の記事を参考に進めていった際2箇所ほどエラーにあったのでその際の記録。
(参考にした記事は誕生日から年齢を算出するものです。)livewireをインストール
composer require calebporzio/livewirelivewireに必要なファイルを作成
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/711bootstrap/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引数の書き方と同じ要領。
参考記事
- 投稿日:2020-12-23T08:52:14+09:00
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!」をターミナルに表示するだけのものとする。
概要
- バッチの作成
- 実行と確認
詳細
バッチの作成
アプリ名ディレクトリで下記コマンドを実行してバッチクラスを作成する。
$ php artisan make:command EchoStrCommandアプリ名ディレクトリで下記コマンドを実行して作成したバッチクラスが記載されているファイルを開く。
$ vi app/Console/Commands/EchoStrCommand.php開いたファイルを下記のように修正する。
アプリ名ディレクトリ/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"; } }実行と確認
- 投稿日:2020-12-23T07:05:25+09:00
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:$PATHdirenvを使ってパスを通すことをおすすめしています。
これでラッパーコマンド(
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を使っていて同じような点を不便に感じている人がいたらぜひ使ってみてください。
- 投稿日:2020-12-23T02:03:20+09:00
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の名前に入っているデータを取り出し、それと入力画面作成時にセッションへセットしたデータを比べ、同じであれば成功時の処理を、違えばそこで処理を終了する。
図で表すとこのようになります。
次に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対策の仕組みについて見ていきます。
セッションをチェックしているコードを見てみる
今回はセッションをチェックしているコードのうち、デフォルトでセットされている
VerifyCsrfTokenに焦点を当てました。VerifyCsrfToken
名前空間
Illuminate\Foundation\Http\Middlewarepublic 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メソッドがHEAD、GET、OPTIONSであれば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-TOKENとX-XSRF-TOKENのデフォルトの値はnullなので、もしリクエストの_tokenのデータが設定されていない場合はgetTokenFromRequestメソッドはnullを返し、その場合tokensMatchメソッドの$request->session()->token()と$token一致しないため、tokensMatchメソッドはfalseを返します。こういうわけで@csrfなどを含めずにLaravelのPOSTメソッドを使ったリクエストを実行するとエラーが起こります。LaravelのCSRF対策のためのセッションの比較の仕方は分かりましたが、セッションの生成の方法がまだ分かりません。そこで今度はデフォルト時のLaravelのセッションの生成の仕方を見てみます。
セッションが作られる過程を見てみる
StartSession
名前空間
Illuminate\Session\Middleware
StartSessionはIlluminate\Foundation\Http\kernelの$middlewarePriority配列の要素の一つです。ちなみにこのkernelはpublic/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\Sessionpublic 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のコードにより実装されているクラスを見ていくのですが、そのクラスの理解を容易にするためにセッションの作成過程が書かれているStartSessionのhandleメソッドと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_string、ctype_alnum、strlenを見て下さい。)
セッション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->startedにtrueを入れる。$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\Sessionpublic 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.phpのfile要素の値)、引数$minutesには$this->config->get('session.lifetime')(/config/session.phpのlifetime要素の値)が入ります。
次は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.phpのlifetime要素の値を引いた数以上だった場合は次の処理へ進み、そうでない場合は空の文字列データを返す。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の値を組み合わせたものと引数$dataをFilesystemクラスのputメソッドへ渡し、trueを返すということを行います。putメソッドについては後で見ていきます。次はこのクラスでたびたび出てきた
Filesystemクラスについて見ていきます。Filesystem
名前空間:
Illuminate\Filesystempublic 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)freadでfopenメソッドで参照するファイルのリソースポインタから同クラスの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メソッドの機能とその仕組みについて
- 投稿日:2020-12-23T01:38:15+09:00
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して実験できたのでそちらのほうが得るものが大きかったです。
やっぱりDockerは触って失敗したら即破棄→再構築できるので便利だなと再確認できました。以上!!


