Laravel ユーザー毎に記録を作成、表示できるように

こんにちは!Laravel勉強中のhirokinishizawaです!

このlaravel勉強ブログでは、はじめてversion minorをしてみたので前回から見て頂けると幸いです!(一気に.5上がってるのは内緒の方向でお願いします)

techblog.scouter.co.jp

はじめに

今回は前回のブログで言っていたユーザー毎に釣りの記録を管理できるようにしました!(あと何もやってなかったスタイルも少しだけ笑)

f:id:hiroki-nishizawa:20180917060008j:plain

f:id:hiroki-nishizawa:20180917060014j:plain

f:id:hiroki-nishizawa:20180917060017j:plain

ディレクトリについてしばらく書いていなかったので軽く書こうかと思います!

Model

User.php
Post.php

Controller

HomeController.php
PostsController.php

Views

resources
 └── views
      ├── auth
      │   ├── passwords
      │   │   ├── email.blade.php
      │   │   └── resets.blade.php
      │   └── login.blade.php
      │   └── register.blade.php
      ├── layouts
      │   └── app.blade.php
      └── posts
      │    ├── create.blade.php
      │    ├── delete.blade.php
      │    └── edit.blade.php
      ├── home.blade.php
      └── welcome.blade.php

この中で今回のユーザー毎に表示や作成出来るようにするためにModelとControllerファイルを変更しました!

モデル

UserとPostsの関係は1対多と呼ばれる関係です!

ユーザーは複数の記録を保存できるので、Userは複数のPostを保存する 1つの記録の情報は1人しか存在しないのでPostは1つのUserしか持ちません! 上記の関係に基づき、各モデルにはこのように関数を追記しました!!(リレーション)

User.php

<?php
public function posts()
{
    return $this->hasMany('App\Post');
}

Post.php

<?php
public function user()
{
    return $this->belongsTo('App\User');
}

コントローラー

HomeController.php

<?php
public function index(Request $request)
{
    $user = $request->user();
    $posts = $user->load('posts');
    return view('home', ['posts'=>$posts->posts]);
}

PostsController.php

<?php
public function create()
    {
        return view('posts.create');
    }

    public function edit(Post $post)
    {
        return view('posts.edit')->with('post', $post);
    }

    public function post(PostRequest $request)
    {
        $post = new Post();
        $post->fish_name = $request->fish_name;
        $post->year = $request->year;
        $post->month = $request->month;
        $post->day = $request->day;
        $post->prefecture = $request->prefecture;
        $post->place = $request->place;
        $post->user_id = $request->user()->id;
        $post->save();
        return redirect('/home');
    }

    public function update(PostRequest $request, Post $post)
    {
        $post->fish_name = $request->fish_name;
        $post->year = $request->year;
        $post->month = $request->month;
        $post->day = $request->day;
        $post->prefecture = $request->prefecture;
        $post->place = $request->place;
        $post->save();
        return redirect('/home');
    }

    public function delete(Request $request)
    {
        $post = Post::find($request->id);
        return view('posts.delete')->with('post',$post);
    }

    public function remove(Request $request)
    {
        $post = Post::find($request->id);
        $post->delete();
        return redirect('/home');
    }

実際に変更点があったのはpostだけになります。 user_idを取得するために$post->user_id = $request->user()->id;になっています!

躓いた点

リレーションと関係あるかはわからないですが、postをユーザー管理を出来るようにuser_idを作ってから送信しようとしたらUnknown column 'updated_at'というエラーが出てくるようになりました。 調べてみたところpublic $timestamps = false;でupdate_atを更新するのを回避できると書いてあったので試しにやってみたら次はcreated_atがないよーと怒られました。。。 postなのに作成日時はないとだめなのかな??

migrationファイルを見てみると作成した時からあるcreatedとmodifiedがありましたが、update_atはありませんでした。なのでcreatedとmodified($table->datetime('created');、$table->datetime('modified');)を削除してtimestamps($table->timestamps();)を入れることにより対応できました!

最後に

だんだんlaravelの内容が難しくなってきて、実装と文章にするのが難しくなってきましたーーー泣 分かりづらかったら申し訳ございません。

今は全てapp直下のmodelとcontrollerファイルだけを使って書いていますが何かほかのrepositoryファイルとかも使って作成していこうかと思います!

先週nuxtmeetup#5のイベントを公開したので興味あれば是非参加してください!! nuxt-meetup.connpass.com

ありがとうございました!

Laravel: アカウント登録出来るようにする

こんにちは!scouterでフロントエンジニアをしてるhirokinishizawaです!

はじめに

今回Laravel勉強version5.0.0では前回まで作っていた釣りの記録をユーザー管理するための準備でアカウント登録できるようにしました!

前回投稿した記事

techblog.scouter.co.jp

アカウント登録できるようにするにあたって

最初に言っておきますLaravelでアカウント登録をできるようにするのはとても簡単です!

アカウントを登録出来るようにするにはターミナルでLaravelをインストールしたプロジェクトに移動して

php artisan make:auth

こちらを実行すると次のようにアカウント登録するたに必要なファイルやルーティングが生成されアカウント登録、ログインなどが出来るようになります!

生成されるファイルはこちらになっています!

Created View: /resources/views/auth/login.blade.php
Created View: /resources/views/auth/register.blade.php
Created View: /resources/views/auth/passwords/email.blade.php
Created View: /resources/views/auth/passwords/reset.blade.php
Created View: /resources/views/auth/emails/password.blade.php
Created View: /resources/views/layouts/app.blade.php
Created View: /resources/views/home.blade.php
Created View: /resources/views/welcome.blade.php
Installed HomeController.
Updated Routes File.
Authentication scaffolding generated successfully!

ルーティングには

<?php

Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');

がweb.phpに追加されています!

Route::get('/home', 'HomeController@index')->name('home');こちらは作成されたHemeController.phpの中のindexメソッドを使用しているのはわかりますが、Auth::routes();の方はちょっとよく分かりませんでしたが今は置いておいてとりあえず出来ることが何なのかブラウザ上で確認してみようかと思います!Auth::routes();のルーティングの中身は後ほど説明したいと思います!

make:authをした後の挙動

最初の画面(topページ)

サーバーを立ち上げた後localhost:8000を開くと以前は

f:id:hiroki-nishizawa:20180910010206p:plain

このような画面でしたが上書きされて

f:id:hiroki-nishizawa:20180910010143p:plain

このような画面になっています!

アカウント登録,ログイン画面

topページのヘッダーにあるloginとregisterを押すとログイン画面、アカウント登録画面にいきます。

f:id:hiroki-nishizawa:20180910010146p:plain

f:id:hiroki-nishizawa:20180910010151p:plain

home画面

login完了するとhome画面に行きます

f:id:hiroki-nishizawa:20180910010155p:plain

初期画面はこのようになっているのですが、自分は前回まで作っていた釣りのhome画面があるので/homeにアクセスすると

f:id:hiroki-nishizawa:20180910010159p:plain

このような感じになっています!今はまだユーザー毎に記録したものを表示出来るようにしていないので、どのユーザーでアクセスしても同じ内容が表示されます!!

Forgot Your Password?

topページにあるForgot Your Password? を押すとパスワード再発行をする画面に行きます!

f:id:hiroki-nishizawa:20180910010202p:plain

追加されたAuth::routes()の中身

冒頭でmake:authをしたときルーティングに追加されたAuth::routes()の中身について書いていこうかと思います!

ルーティングの中身

先程わからないと言っていたAuth::routes()を調べてみたら中はvender\laravel\framework\src\Illuminate\Routing\Route.phpに記載されていることがわかりました!

中のauthメソッドを見てみるとこのようになっていました!

<?php
 public function auth()
    {
        // Authentication Routes...
        $this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
        $this->post('login', 'Auth\LoginController@login');
        $this->post('logout', 'Auth\LoginController@logout')->name('logout');

        // Registration Routes...
        $this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
        $this->post('register', 'Auth\RegisterController@register');

        // Password Reset Routes...
        $this->get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm')->name('password.request');
        $this->post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email');
        $this->get('password/reset/{token}', 'Auth\ResetPasswordController@showResetForm')->name('password.reset');
        $this->post('password/reset', 'Auth\ResetPasswordController@reset');
    }

login画面を例として話していきます。login画面はルーティングを見る感じLoginControllerのshowLoginFormメソッドに書かれているのがわかると思います!ですがLoginControllerを見てみるとshoLoginFormというメソッドは見つかりませんでした。他のルーティングも見る限りApp\Http\Controllersの中のControllerファイルにはありませんでした。

結局分からず一度調べることに。。。

認証系のメソッドはほぼトレイトとして実装されており、その実体は、Illuminate\Foundation\Auth\以下に存在します。

らしいです。。。。。

こちらはmake:authを実行した際にAuth::routes();が追加されたことによって参照されるようになるみたいです!

すごい便利。。。笑

改めてLaravelの凄さを実感しました!笑

ではアカウント登録をするとき、ログインをしたときの動きをControllerファイルで見ていきましょう!

アカウント登録をするとき

ルーティング

// Registration Routes...
        $this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
        $this->post('register', 'Auth\RegisterController@register');

RegisterController@showRegistrationForm'

<?php
public function showRegistrationForm()
    {
        return view('auth.register');
    }

こちらはただアカウント登録をするページを表示するだけのメソッドでした!

RegisterController@register

<?php
public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        $this->guard()->login($user);

        return $this->registered($request, $user)
                        ?: redirect($this->redirectPath());
    }

登録したらその場でログインをし、AuthControllerで設定されたリダイレクト先へリダイレクトするようになっています。というわけで/homeにリダイレクトされます!

ログインするとき

ルーティング

// Authentication Routes...
        $this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
        $this->post('login', 'Auth\LoginController@login');

LoginController@showLoginForm

<?php
public function showLoginForm()
    {
        return view('auth.login');
    }

こちらはただログインする画面を表示するだけのメソッドでした!

LoginController@login

<?php
public function login(Request $request)
    {
        $this->validateLogin($request);

        // If the class is using the ThrottlesLogins trait, we can automatically throttle
        // the login attempts for this application. We'll key this by the username and
        // the IP address of the client making these requests into this application.
        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        if ($this->attemptLogin($request)) {
            return $this->sendLoginResponse($request);
        }

        // If the login attempt was unsuccessful we will increment the number of attempts
        // to login and redirect the user back to the login form. Of course, when this
        // user surpasses their maximum number of attempts they will get locked out.
        $this->incrementLoginAttempts($request);

        return $this->sendFailedLoginResponse($request);
    }

認証がOKならログイン処理が行われます。 その他にもスロットル処理が行われており、ここではログインを何度も失敗して一定の数を超えると、しばらくログインできないという処理が加わっています。

最後に

このようにLaravelでアカウント認証の処理をしたいと思ったらphp artisan make:authをするだけで簡単にできます!本当に便利だと思いました!

来週はアカウント認証系のスタイルを釣りの記録用に変更をしたり釣りの記録をユーザー別に管理をしてみようかなーと思います!

このブログと同時に本日12:00にNuxtMeetUp #5を公開しました!10/18日です!ぜひご参加ください!

nuxt-meetup.connpass.com

LaravelのEditとDelete

こんにちはscouterでフロントエンジニアをしているhirokinishizawaです! laravel勉強ブログもVersion4.0.0になりました!

前回投稿した記事 techblog.scouter.co.jp

前回は釣りの記録を作成する際にデータベースにレコードを追加できるようにしました。なので今回は編集と削除を出来るようにしました!

はじめに

作成したものの動き

トップページの各記録に編集する削除するを追加しました

gyazo.com

あとレイアウトとして追加したのが

編集ページと

gyazo.com

削除する時の確認ページです

gyazo.com

やったこと

1.記録を編集できるようにした

2.Requestファイルを使って作成時と編集時のvalidateを一つにまとめた

3.記録を削除できるようにした

記録を編集

web.php

<?php

Route::get('/posts/{post}/edit', 'PostsController@edit');
Route::patch('/posts/{post}', 'PostsController@update');

PostsController.php

<?php

  public function edit(Post $post)
    {
        return view('posts.edit')->with('post', $post);
    }

 public function update(PostRequest $request, Post $post)
    {
        $this->validate($request, [
            'fish_name' => 'required',
            'year' => 'required|integer|between:1990,2018',
            'month' => 'required|integer|between:1,12',
            'day' => 'required|integer|between:1,31',
            'prefecture' => 'required',
            'place' => 'required'
        ]);
        $post->fish_name = $request->fish_name;
        $post->year = $request->year;
        $post->month = $request->month;
        $post->day = $request->day;
        $post->prefecture = $request->prefecture;
        $post->place = $request->place;
        $post->save();
        return redirect('/');
    }

前回の@postのときとほぼ同じです。違うのは引数でpostを受け取るので$post = new Post();このように新しくPostを作らなくていいところです。

edit.blade.php作成

コード自体は作成ページと編集ページのレイアウト見てもらうとわかると思いますがほぼかわっていません。変わったのはpostではくupdateするためにメソッドにpatchをを使用しました。

編集ページ

@section('content')
    <h1>記録編集ページ<a href="{{ url('/') }}" class="header-menu">戻る</a></h1>

    <form method="post" action="{{ url('/posts', $post->id) }}">
        {{ csrf_field() }}
        {{ method_field('patch') }}
        <div class="flex-box">
            <div class="column">
                魚の名前
            </div>
            <div>
                <input type="text" name="fish_name" placeholder="ブラックバス" value="{{old('fish_name', $post->fish_name)}}">
                @if($errors->has('fish_name'))
                    <span class="error">{{ $errors->first('fish_name') }}</span>
                @endif
            </div>
        </div>...

新規作成ページ

@section('content')
    <h1>記録を作成する<a href="{{ url('/') }}" class="header-menu">戻る</a></h1>

    <form method="post" action="{{ url('/posts') }}">
        {{ csrf_field() }}
        <div class="flex-box">
            <div class="column">
                魚の名前
            </div>
            <div>
                <input type="text" name="fish_name" placeholder="ブラックバス" value="{{old('fish_name')}}">
                @if($errors->has('fish_name'))
                    <span class="error">{{ $errors->first('fish_name') }}</span>
                @endif
            </div>
        </div>...

見比べると編集ページの方には{{ method_field('patch')}}がありますね。これをformタグの中に書くことによりhttpメソッドのpatchが使えるようになって{{ url('/posts') }}にidを渡すことにより上書きできるようになります。

(後で出てくるdeleteでも似たようなことをしています)

Requestファイル使用

前回postする際に書いたvalidateですがeditでも同じ条件でvalidateを掛けたいのですが下のように重複するのはあまり好ましくないのでRequestファイルに共通部分を書きました。

<?php

public function post(PostRequest $request)
    {
        $this->validate($request, [
            'fish_name' => 'required',
            'year' => 'required|integer|between:1990,2018',
            'month' => 'required|integer|between:1,12',
            'day' => 'required|integer|between:1,31',
            'prefecture' => 'required',
            'place' => 'required'
        ]);
        
        $post = new Post();
        $post->fish_name = $request->fish_name;
        $post->year = $request->year;
        $post->month = $request->month;
        $post->day = $request->day;
        $post->prefecture = $request->prefecture;
        $post->place = $request->place;
        $post->save();
        return redirect('/');
    }

    public function update(PostRequest $request, Post $post)
    {
        $this->validate($request, [
            'fish_name' => 'required',
            'year' => 'required|integer|between:1990,2018',
            'month' => 'required|integer|between:1,12',
            'day' => 'required|integer|between:1,31',
            'prefecture' => 'required',
            'place' => 'required'
        ]);
        
        $post->fish_name = $request->fish_name;
        $post->year = $request->year;
        $post->month = $request->month;
        $post->day = $request->day;
        $post->prefecture = $request->prefecture;
        $post->place = $request->place;
        $post->save();
        return redirect('/');
    }
php artisan make:request PostRequest

こちらのコマンドでApp\Http\Requestsの下にRequestファイルを作成することが出来ます。

<?php
    public function rules()
    {
        return [
            'fish_name' => 'required',
            'year' => 'required|integer|between:1990,2018',
            'month' => 'required|integer|between:1,12',
            'day' => 'required|integer|between:1,31',
            'prefecture' => 'required',
            'place' => 'required'
        ];
    }

    public function messages()
    {
        return [
            'fish_name.required' => '魚の名前を入れてください',
            'year.required' => '1990年から2018年の間で記入してください',
            'month.required' => '1年の中で1月から12月までしかありませんよ',
            'day.required' => '1ヶ月の中で1日から31日までしかありませんよ。',
            'prefecture.required' => '情報提供してください',
            'place.required' => '情報提供してください',
        ];
    }

rules()は名前の通りルールを書いていきます。

下のmessages()はそのルールを破った時にでてくる文章を作ることが出来ます!

前回は文字の変更のやり方が分からなかったのでとても分かりやすく便利だなと思ったRequestファイルでした!

記録を削除

web.php

<?php
Route::get('/posts/delete', 'PostsController@delete')->name('post.delete');
Route::delete('/posts/remove', 'PostsController@remove')->name('post.remove');

削除の方ではnameを使ってみました。nameについては後で説明します。

PostsController.php

<?php

    public function delete(Request $request)
    {
        $post = Post::find($request->id);
       return view('posts.delete')->with('post',$post);
    }

    public function remove(Request $request)
    {
        $post = Post::find($request->id);
        $post->delete();
        return redirect('/');
    }

delete.blade.php作成

コードはこのようになっています

@extends('layouts.app')

@section('content')
    <h1>本当に削除しますか?<a href="{{ url('/') }}" class="header-menu">戻る</a></h1>

    <div class="fishing-record">
        <div class="flex-box">
            <div class="column">魚種</div>
            <div class="text">{{ $post->fish_name }}</div>
        </div>
        <div class="flex-box">
            <div class="column">釣った日付</div>
            <div class="text">{{ $post->year }}年{{ $post->month }}月{{ $post->day }}日</div>
        </div>
        <div class="flex-box">
            <div class="column">釣った場所</div>
            <div class="text">{{ $post->prefecture }}: {{ $post->place }}</div>
        </div>
    </div>
    <form action='{{ route('post.remove') }}' method='post'>
        {{ csrf_field() }}
        {{ method_field('delete') }}
        <input type='hidden' name='id' value='{{ $post->id }}'><br>
        <input type='submit' value='削除'>
    </form>
@endsection
  • {{ route('post.remove') }} 先程のweb.phpで使用したnameはこのようにrouteとして使用することが出来ます

  • {{ method_field('delete') }} 編集機能のpatchと同じです。httpメソッドを使用できるようにしています。

躓いた点

今回editやdeleteをするのにid毎にそれぞれページ遷移させるのでidを渡さなければいけませんでした。以前作成ページを作った時は作成するボタン(遷移ボタン)を押したら作成ページに飛ぶだけでルーティングはRoute::get('/posts/create'だったので遷移ボタンのコードを書く時はhref="{{ url('/posts/create') }}"とこのような書き方をしました。ですが今回idを渡すのでルーティングはRoute::get('/posts/{post}/edit'。遷移先のコードはhref="{{ url('/posts/edit', $post->id) }}"とかいたのですが、遷移した先は/posts/edit/1になってしまいページがないよと言われました。なので別のページ遷移のやり方を調べhref="{{ action('PostsController@edit', $post) }}"という書き方で無事'/posts/1/edit'となりidごとのeditページやdeleteページに遷移することができました! href="{{ action('PostsController@edit', $post) }}"と同様にhref="{{route('post.edit', $post) }}"とnameを決めてrouteで書いても出来ました!というよりこっちの方が一般的みたいです!

終わりに

前回のデータベースを使って記録の更新に続きちょっとだけ応用したeditとdeleteですが今回調べるにあたりいろいろな人のブログを見たら大体一緒にやってることが多かったので次回は脱初心者に向けてなにかいいネタないか考えたいと思います(勉強するにあたって何をすればいいかわからないのでネタ提供求めますww)

来週もよろしくおねがいしますー!

Babel7 OptionalChainingでLaravelのバリデーションをハンドリングする

f:id:kotamat:20180829111209j:plain# Babel7 OptionalChainingでLaravelのバリデーションをハンドリングする

こんにちは、@kotamatです。

昨日Babel7がリリースされましたね🎉

https://babeljs.io/blog/2018/08/27/7.0.0

Babel6から3年かかってのリリースです。コントリビューターの方々お疲れ様でした。 パッケージ名からの破壊的変更が入っていますが、babelのupgrade用スクリプトが用意されているので、手軽にupgradeすることができます(執筆時点だと、beta版までしかあげられませんが‥)

Babel7のリリースに伴い、TS39の新しいシンタクスがサポートされ、その中でもStage1のOptional Chainingが、Laravelのエラーハンドリングにとても相性がいいので紹介させていただきます。

Laravelのバリデーションエラー

Laravel側でFormRequestを用いて下記のようなバリデーションルールを実装した場合、

class StoreRelationRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'address.prefecture'    =>['required', 'string'],
            'address.zipcode'    =>['required', 'integer'],
            'staff.*.id'    =>['required', 'integer'],
            'staff.*.name'  =>['required', 'string'], 
        ];
    }
}

staffの0, 1がバリデーションに引っかかった場合、下記のようなバリデーションメッセージが帰ってきます

{
    "address.prefecture": ["The prefecture field is required."],
    "address.zipcode": ["The zipcode field is required."],
    "staff.0.id": ["The id field is required."],
    "staff.0.name": ["The name field is required."],
    "staff.1.id": ["The id field is required."],
    "staff.1.name": ["The name field is required."]
}

このままでは、フロント側では扱いづらいので、フロント側で扱いやすい形に整形します

reno-shelter/convert-laravel-validationを使うと、上記メッセージは下記のように修正されます

{
    address: {
      prefecture: ['The prefecture field is required.'],
      zipcode: ['The zipcode field is required.'],
    },
    staff: {
      0: {
        name: ['The name field is required.'],
        id: ['The id field is required.'],
      },
      1: {
        name: ['The name field is required.'],
        id: ['The id field is required.'],
      },
    },
  }

Optional Chainingでメッセージを取り扱う

上記オブジェクトでは、例えば、address.*系のエラーがない場合は、当然ながら参照できない形になってしまうため、メッセージ表示部分で存在可否のチェックを行わないとundefined indexのFatal Errorが発生してしまいます。

そこで便利なのがOptionalChainingです。

Optional chainingの仕組み

const obj = {
  foo: {
    bar: {
      baz: 42,
    },
  },
};

const baz = obj?.foo?.bar?.baz; // 42

const safe = obj?.qux?.baz; // undefined

// Optional chaining and normal chaining can be intermixed
obj?.foo.bar?.baz; // Only access `foo` if `obj` exists, and `baz` if
                   // `bar` exists

このように、存在しないパラメータがチェーンの途中にあるばあいは、そこでundefinedを返し、ランタイムでのエラーを防ぐ事ができます。

<template>
    <div>
        <input v-model="address.prefecture" />
        <error v-for="(msg, key) in error?.address?.prefecture || []" :key="key"  :msg="msg"/>
    </div>
</template>

上記のようにテンプレート内でerror?.address?.prefectureとすることによって、もしerror, error.address, error.address.prefectureがそれぞれ存在しなかったとしても問題なく実行されるため、無駄にif文で分岐して処理を複雑にする必要はなくなります。

インストール方法

babel7が入っている前提で話をすすめます。babel6以前からのアップデートはbabelupdateを参考にしてください。

まずはyarn (or npm)でプラグインをインストールし、

yarn add @babel/plugin-proposal-optional-chaining

.babelrcでpluginの設定をするだけです、

{
  "plugins": ["@babel/plugin-proposal-optional-chaining"]
}

Nuxt.jsであればnuxt.config.jsに下記のように記載します

export default {
  build: {
    babel: {
      plugins: ['@babel/plugin-proposal-optional-chaining']
    }
  }
}

まとめ

Babel7で新たに入ったOptionalChainingの紹介をさせていただきました。まだProposalの段階なので変更が入る可能性はありますが、より実装にフォーカスしたコーディングができるようになるので、是非導入してみてください!

Laravel:: Databaseを使用してデータの保管をしてみる

こんにちは最近laravelの勉強をしているSCOUTERでフロントエンドエンジニアをしているhirokinishizawaです!

前回はbladeを使って簡単なレイアウトを作成しました!

techblog.scouter.co.jp

今回はDatabaseを使ってデータの保管の練習をしてみたので最後まで見て頂けると嬉しいです!!

はじめに

今回何をどうゆう順序で作ったか箇条書き!

  1. テスト用のプロジェクト作成
  2. databaseを作成
  3. migration作成
  4. databaseにデータを送る
  5. databaseのデータを表示する

ざっと言うとこんな感じに作成していきました!

作成したもの

gyazo.com

これはまだデータベースに何も入っていない状態です!右上の作成するを押して作成画面に飛びます。

gyazo.com

これに記入していきアップロードするとデータが送られます。

gyazo.com

このように釣れた日付の古い順に表示するようにしました!そのやり方などは後ほど説明していきたいと思います!

新しいプロジェクトを作成した言い訳

前回のプロジェクトをそのまま使用してdatabaseを活用しようとしていたのですが、実際にやろうと思ったら頭の中でごちゃごちゃになってしまったので新しいプロジェクトを作成してアップロードして表示するまでの流れを理解するために新しいプロジェクトを作成しました!

データベース関連

データベース管理システムはMySQLを使用していて管理ソフトはSequel Proを使用しています。

やったこと

databaseの作成方法はSequel Proでクエリを開きcreate database DB_NAMEで作成できます!作成した後show databasesで作成出来ているか確認ができると思います!

databaseを作成したら次はmodelとmigrationを作成していきます!

php artisan make:model Post --migration

これを実行するとappディレクトリの直下にPost.phpというmodelと、database/migrationsの下にmigrationファイルが作成されます。

ではmigrationを書いていきましょうー

gyazo.com

このような感じに(魚の名前、釣った[年、月、日]、釣れた場所[都道府県、場所])を作成しました!

作成したら下のコマンドを実行することでmigrationがかかります

php artisan migrate
php artisan migrate:status

これを実行するといまmigrationがどのような常態かを見ることが出来ます!

次にdatabaseにデータを送ったり、取得してきて表示したりします!

まずはweb.phpとPostsControllerのなかを見ていこうかと思います!

web.php(ルーティング)

gyazo.com

今回はこちらの3つを使いました。使用用途をそれぞれ説明していきたいと思います!

  • '/'
    • これは釣りのデータを取得して表示する画面になります
  • '/posts/create'
    • これは送るためのデータを書き込む画面になります
  • '/posts'
    • これはdatabaseにpost送信して登録を行うurlになります。actionとしての内容はPostsController@postとなっているので次のControllerで説明します!

Controller

urlごとの役割をざっとweb.phpの方で書いたので、Controllerでどんな処理を行っているか説明をしていきたいと思います!

gyazo.com

index

  • orderBy('year', 'asc')
    • これは年が古い順にしてもらう処理です。続けて'month','day'を書くことで月日まで古い順に取得しています!下の写真が例です!
  • view('posts.index')->with('posts', $posts);
    • これは$postsという変数をindex.blade.phpでも使用できるようにしています。違う書き方だとview('posts.index', ['posts' => $posts]);でも同じように動きます!

gyazo.com

create

これはただcreate.blade.phpを表示しているだけですね笑

bladeは前回同様、表示のページと作成ページの共通部分をviews/layoutsのapp.blade.phpに書いてcontentに@yieldをセットしています!

gyazo.com

createのcontent部分はこのようになっています!

gyazo.com

formタグで囲いmethodをpost、actionを/postsにしました。

もしアップロードを押したときにエラーが発生した場合、直前に記入してあったものが消えないようにするためinputタグのvalue属性に{{ old('column名') }}を書いています。

error文に関してはController@postの説明でしたいと思います

post

gyazo.com

最初はこのようにvalidateを書かずにただデータベースに送ることだけを考えて作成。 return redirectは処理が終わった後にどこに遷移するかのurlです!

次にvalidateの設定をしていきます。validateの種類はこちらを確認してください!

gyazo.com

条件に当てはまらなかった時データベースには送られずエラーが発生し表示されます。

gyazo.com

躓いたポイント

maxやmin,betweenなどの数字の最大最小を活用したい時はその確認を実行する前に'integer'(整数値)をいれないと上手く最大最小値が確認できず送信できませんでした。

最後に

画像をアップロードしたりするにはどこかの保管場所が必要みたいなのでその辺の知識など増やして次は写真付きをチャレンジしてみようかと思います!

あと最近はtwitterで筋肉エンジニアの方々が活発に活動しているので、負けじと筋トレをしようかと思います!

hirokinishizawaでしたーーー!ありがとうございました!

データ収集の効率を圧倒的に高めるスクレイピングFW【Scrapy】

はじめまして、R&Dに所属している @shimada です。6月より業務委託として、データ分析・収集やマーケティング自動化などをやらせていただいています。

R&Dチームと何をやっているかについては、CTO id:kotamat のエントリーを参照していただければと思います。

R&Dと開発でチームを分けた理由とOKR

一部抜粋

SCOUTER社はSCOUTER、SARDINE共に人材紹介の事業を行っており、「転職者に寄り添う支援活動」を後押しするような機能開発を行っております。 この文脈において、すぐには結果に結びつかないかもしれないが、将来的にはやるべきな機能の検討から調査・開発までをR&Dチームは行っております。具体的には、上記リリースの通り、転職者属性の解析や求人のリコメンド、獲得をAIを用いて解決していくことを行っています。

上記エントリーにあるように、R&Dでは転職者・求人情報の解析やリコメンド・検索エンジンアルゴリズムの改良に取り組み始めているのですが、それらの機能を開発する前の段階として、SCOUTERが所有しているデータ以外にもWeb上から転職者や求人情報を解析するために必要な大量のデータを収集する必要が出てきました。またR&Dでは、データ分析・解析にはPHPではなく主にPythonを使用しています。このような背景から、今回はPython製の強固なスクレイピングフレームワークであるScrapyを採用するに至りました。

Scrapyって?

ScrapyはPython製のフルスタックなスクレイピングフレームワークです。スクレイピングに最低限必要な機能に加えて、スロットリングや非同期実行、リクエスト/レスポンスのフックなど、だいたいなんでもできちゃいます。

Scrapy | A Fast and Powerful Scraping and Web Crawling Framework

また、Djangoにインスパイアされた作りになっており、各コンポーネントの概念さえ把握してしまえば学習コストも高くはありません。

下の図がScrapyのアーキテクチャです。

f:id:tsmd44:20180816000246p:plain

Architecture overview — Scrapy 1.5.1 documentation

  • Engine:各コンポネーントを制御
  • Scheduler:キューイングとスケジューリング
  • Downloader:リクエストとレスポンスオブジェクトの生成
  • Spider:実際のデータ抽出処理を記述する部分、データをItemオブジェクトに詰めてPipelineに渡す
  • Pipeline:CSVやデータベースへの保存など、データの加工・出力を担当

一見複雑そうですが、SchedulerやDownloaderの部分はScrapyがやってくれるので、実際にコードを書くところはSpiderとPipelineぐらいです。

このエントリーでは、SARDINE人材紹介マガジンを例に、Scrapyの基本的な使い方について紹介していきたいと思います。

Scrapyのインストール

Python2.7にも対応していますが、スクレイピングでは特に文字コード問題に悩まされることになるので、3系を使いましょう。最新の3.7でも問題ありません。

$ pip3 install scrapy

今回は、tutorialという名前でプロジェクトを作成します。

$ scrapy startproject tutorial

下記のようなディレクトリが作成されたと思います。 コマンドもディレクトリ構成もDjangoそっくりですね。

tutorial/
    scrapy.cfg
    tutorial/
        __init__.py
        items.py
        middlewares.py
        pipelines.py
        settings.py       
        spiders/
            __init__.py

Spider

サンプルとして人材紹介マガジンのタイトルとURLを取得するSpiderを作成してみます。SARDINE人材紹介マガジンはNuxt.jsで作成されたプロジェクトですが、サーバーサイドでレンダリングされているため通常のサイトと同じようにスクレイピング可能です。

最初に、tutorial/spiders/ の下に magazine.py というファイル名でSpiderを作成します。

import scrapy

class MagazineSpider(scrapy.Spider):
    name = 'magazine'

    def start_requests(self):
        url = 'https://sardine-system.com/media/'
        yield scrapy.Request(url, callback=self.parse)

    def parse(self, response):
        for selector in response.css('.main-wrapper .container a'):
            yield {
                'title': selector.css('.main-text::text').extract_first(),
                'url': response.urljoin(selector.css('::attr(href)').extract_first())
            }

上から順番に見ていくと、スクレイピング開始直後に start_requests が呼ばれ、リクエストを作成し yield すると、レスポンス取得後のコールバックとして parse メソッドが呼ばれます。

parse メソッドでは、https://sardine-system.com/media/からのレスポンスを引数として受け取り、.css() メソッドでHTMLをパースする処理を記述しています。

response.css('.main-wrapper .container a') では、下の画像の部分のHTMLを抽出しています。

f:id:tsmd44:20180816000644j:plain

Devツールではこんな感じ

f:id:tsmd44:20180816000703j:plain

.css() メソッドでリターンされるのは、 Selector オブジェクト(のリスト)です。Selector オブジェクトから、さらに .css() メソッドをチェーンして、最終的に .extract() でテキストとして抽出するのが基本的な流れです。

Selectorの詳しい使い方については公式ドキュメントを参照してください。

Selectors — Scrapy 1.5.1 documentation

HTMLからのデータ抽出は、XPathで記述する方法もありますが、CSSの方が簡単です。普段CSSを書くのと同じようにクラス名やタグを指定すればOKです。

プロジェクトルートで下記のコマンドを入力すると、out.csv にタイトルとURLの一覧が出力されていると思います。

$ scrapy crawl magazine -o out.csv

簡単ですね!

Scrapy Shell

Python系のライブラリの強みは、iPythonやJupyter notebookでコードをテスト出来るところだと思います。いちいち確認のためにテストやプログラムを実行する必要はありません。

ScrapyにもDjango Shell(Laravelで言うところのtinkerみたいなやつ)のようなシェルが組み込まれており、iPython環境で気軽にターゲットのXPathCSSのテストを行うことができます。

下記のようにコマンドを入力すると、シェルが立ち上がり、https://sardine-system.com/media/ からのレスポンスが response オブジェクトに自動で格納されます。

$ scrapy shell 'https://sardine-system.com/media/'

試しに、最新記事の作成日を取得してみましょう。

In [x]: pub_date = response.css('.main-wrapper .container a time::text').extract_first()
In [x]: pub_date
Out [x]: '2018/08/09'

また、新しいリクエストオブジェクトを作成し、fetch することでshell上で新しいページに遷移することができます。

In [x]: latest_page = response.css('.main-wrapper .container a::attr(href)').extract_first()
In [x]: r = scrapy.Request(response.urljoin(latest_page))
In [x]: fetch(r)
In [x]: response.url
Out [x]: 'https://sardine-system.com/media/posts/p180809'

取得したCookieも自動で送信してれるので、ログイン後に保護されたページをテストしたい場合でも問題ありません。

Pipeline

スクレイピングしたデータは、CSVJSONよりデータベースに保存したい場合の方が多いと思います。Scrapyでは、Pipelineを作成し settings.py に追加することで複数のデータソースに出力することが可能です。

例として、MySQL保存用のPipelineを作成してみたいと思います。 最初にテスト用のテーブルを作成してください。

create database scrapy;
create table scrapy.magazine (
  `guid` varchar(32) not null primary key,
  `title` text null,
  `url` text null,
  `created` datetime default CURRENT_TIMESTAMP not null,
  `updated` datetime default CURRENT_TIMESTAMP not null
);

pipelines.pyMySQLPipeline という名前の新しいPipelineを作成します。

from datetime import datetime
import hashlib
import logging
from twisted.enterprise import adbapi

class MySQLPipeline(object):
    def __init__(self, db_pool):
        self.db_pool = db_pool

    @classmethod
    def from_settings(cls, settings):
        db_args = {
            'host': settings['DB_HOST'],
            'db': settings['DB_NAME'],
            'user': settings['DB_USER'],
            'passwd': settings['DB_PASSWORD'],
            'charset': 'utf8',
            'use_unicode': True
        }
        db_pool = adbapi.ConnectionPool('MySQLdb', **db_args)
        return cls(db_pool)

    def process_item(self, item, spider):
        d = self.db_pool.runInteraction(self._upsert, item, spider)
        d.addErrback(self._handle_error, item, spider)
        d.addBoth(lambda _: item)
        return d

    def _upsert(self, conn, item, spider):
        guid = self.get_guid(item['url'])
        now = datetime.utcnow().replace(microsecond=0).isoformat(' ')

        conn.execute("""
            SELECT EXISTS(
                SELECT 1 FROM magazine WHERE guid = %s
            )
        """, (guid,))
        ret = conn.fetchone()[0]

        if ret:
            conn.execute("""
                UPDATE magazine
                SET title=%s, updated=%s
                WHERE guid=%s
            """, (item['title'], now, guid))
        else:
            conn.execute("""
                INSERT INTO magazine (
                    guid, title, url, created, updated
                ) VALUES (
                    %s, %s, %s, %s, %s
                )
            """, (guid, item['title'], item['url'], now, now))

        spider.log('Saved!')

    def _handle_error(self, failure, item, spider):
        spider.log(failure, logging.ERROR)

    def get_guid(self, url):
        return hashlib.md5(url.encode('utf-8')).hexdigest()

Scrapyでは twisted という非同期ライブラリを使用しているため、ここではDBへの保存もノンブロッキングにしています。twistedのadbapi というモジュールを使っています。

Twisted RDBMS support — Twisted 15.3.0 documentation

settings.py に作成したPipelineを追加して、もう一度Crawlしてみましょう。

ITEM_PIPELINES = {
    'tutorial.pipelines.MySQLPipeline': 100
}
$ scrapy crawl magazine -o out.csv

out.csvmagazine テーブルにデータが出力されていれば完成です!

SPAサイトをスクレイピング

最後にScrapyでSPAサイトをスクレイピングする方法を簡単に紹介したいと思います。

前提として、SPAサイトに限らずJavascriptで動的にデータを取得している場合などは、seleniumを使ってブラウザ経由の(Javascript実行後の)HTMLを解析することになります。しかし、seleniumは本来Webアプリケーションのテスト自動化のためのツールであり、スクレイピングをする上で使い勝手がいい訳ではありません。

動的なサイトのスクレイピングを行う場合、Scrapyと同じscrapinghub社によって開発されたSplashというヘッドレスブラウザがおすすめです。

Splash

Splashは、高速なレンダリング以外にも、複数ページの非同期取得・カスタムJavasriptの実行など、スクレイピングに便利な多くの機能を備えています。

Splash - A javascript rendering service

また、"Javascript rendering service" と記述されるように、Javascriptレンダリング後のHTMLを返すAPIインターフェースを備えた "サービス" であるため、単なるブラウザと表現するのは正しくないのかもしれません。

インストールは、公式から提供されているdockerfileでコンテナを作成する方法が簡単です。

$ docker pull scrapinghub/splash
$ docker run -p 8050:8050 scrapinghub/splash

これで render.html エンドポイントに下のようなリクエストを投げることで、Javascriptレンダリング後のHTMLを取得することができるようになりました。

$ curl 'http://localhost:8050/render.html?url=https://sardine-system.com/media&wait=1.0'

ScrapyからSplashのAPIを使ってスクレイピング

Scrapyから直接APIを叩いてもいいのですが、いくつか問題があるようなので、scrapy-splash というプラグインが推奨されています。

pipでインストール

$ pip3 install scrapy-splash

scrapy-splash settings.pyミドルウェアとして追加します。

DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashCookiesMiddleware': 723,
    'scrapy_splash.SplashMiddleware': 725,
    'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}

SPLASH_URL = 'http://localhost:8050/'

HttpProxyMiddleware の優先度が750に設定されているため、scrapy_splashの優先度は750以下になるよう注意してください。

後はspiderをちょっと変更するだけで動的ページに対応です!

import scrapy
from scrapy_splash import SplashRequest

class MagazineSpider(scrapy.Spider):
    name = 'magazine'

    def start_requests(self):
        url = 'https://sardine-system.com/media/'
        yield SplashRequest(url,
                            callback=self.parse, 
                            endpoint='render.html',
                            args={'wait': 1.0})

    def parse(self, response):
        for selector in response.css('.main-wrapper .container a'):
            yield {
                'title': selector.css('.main-text::text').extract_first(),
                'url': response.urljoin(selector.css('::attr(href)').extract_first())
            }

終わりに

今回はScrapyの紹介的な内容で終わってしまいましたが、機会があればスクレイピングをする上で実際の業務でつまずいた点やインフラを交えた話もできればなと思います。

Scrapyは、Pythonを技術スタックとして置いていない企業でも採用する価値のあるフレームワークです。もし業務で(趣味でも)クローリング・スクレイピングを行う機会が出てきたら是非Scrapyを検討してみてください。

VueとBladeを使い簡単なレイアウトを作成!

こんにちはSCOUTERでフロントエンドエンジニアをしているhirokinishizawaです!

前回bladeを使いテンプレートを作ろうと言っていたのですが、弊社ではフロントでvueを使っているということもありせっかくなのでvueも使って自分が大好きな釣りの写真を年ごとにページを作成してみました!

はじめに

今回作ったものがこちらになります!

gyazo.com

画像をクリックするとその年に釣った魚の写真一覧が表示されますj!完全に自己満の写真ファイルみたいな感じになっています!笑

gyazo.com

弊社でlaravel + vueを使ってプロジェクトを作成しているので、vueを入れてから躓いたことは会社のプロジェクトを参考にしながら解決していきました!

Laravelとして使ったもの

今回Controllerファイルは使用していません。 Laravelの何を使っているのかと言いますと、ルーティングとbladeを使用しています!

環境

PHP Composer Laravel node npm Vue
7.0.9 1.6.5 5.5.0 9.1.0 5.5.1 2.7.1

仕様エディタ: PhpStorm

Vueの導入

Laravelのプロジェクトを作成した際にLaravelMix(Laravel で CSS や JavaScript をビルドするための API を提供するもの)にvue.jsが組み込まれています。

なのでlaravelプロジェクトを作成した後にnpm installを実行することにより、vue.jsを導入することができます!

vue.jsを導入した後

vue.jsを導入した後に躓いた点があったので少し紹介

vueを使用する際に使ったファイル

  • 'resources/assets/js'にあるapp.jsファイル
  • 'resources/views'にあるblade.phpファイル
  • vueのcomponents内にある.vueファイル
  • web.php

こちらを使用しました。

ディレクトリ構造

resources
 ├── assets
 │   └── js
 │       ├── app.js
 │       └──components
 │           ├── ContentComponent.vue
 │           ├── HeaderComponent.vue
 │           └── imageComponents
 │               ├── Images_2016.vue
 │               ├── Images_2017.vue
 │               └── Images_2018.vue
 └── views
     ├── home
     │   └── content.blade.php
     ├── layouts
     │   └── app.blade.php
     └── memoryImage
         ├── 2016.blade.php
         ├── 2017.blade.php
         └── 2018.blade.php

app.js

vue.jsを導入した際にapp.jsというファイルが入るのですが、最初はこのようになっています。

gyazo.com

この設定のおかげで最初から

<example-component/>

というタグでExampleComponent.vueをbladeや.vueファイルで使用することが出来ます

上記のディレクトリ構造でapp.jsを書くと

gyazo.com

このような感じでvueコンポーネントをセットできます!

blade.php

※ 'layouts/app.blade.php' ->ベースとなるbladeファイル

親となるapp.blade.phpの中身はこのように記述しました

gyazo.com

headerは固定したかったのでベースファイルであるapp.blade.phpにそのままvueコンポーネントをを呼び出していますが、ベースのcontentの部分には@yield('content')と記述されています。

@yieldの使用方法は子コンポーネントの方でexdendsはファイルの場所(書き方は"ディレクトリ名.bladeファイル名")、sectionはそのファイル(今回で言う'app.blade.php')の@yieldと同じkey('content')を記述します。

gyazo.com

これでurlがcontent.blade.phpの設定のときにapp.blade.phpに読み込まれます。

ルーティングはweb.php

Route::get('/', function () {
//view('ディレクトリ名.bladeファイル名')
    return view('home.content');
});

と設定します。

なのでbladeでレイアウト作成する場合はControllerファイルは使用しないでできます。

これでtopページが作成完了です!

次に年ごとにページを作成していきます。やることは同じです。

ファイルを作成してapp.jsでvueコンポーネントを使用できる状態にしbladeで読み込み、app.blade.phpの@yield("content")で読み込めるようにしルーティングを作成すれば、レイアウトは完成です!

ルーティングはこんな感じになっています!

gyazo.com

2017年のページを開く場合は'/images/2017'となります。

この'memoryImage.2017'はviews/memoryImage/2017.blade.phpになっていて中身は

gyazo.com

と、content.blade.phpの中とほとんど同じです! 唯一違うところは呼び出しているvueコンポーネントが違います!

先程のルーティングの画像を見てもらうとわかると思いますが、

content.blade.phpを見るとき開発環境urlはlocalhost:8000

2017.blade.phpを見るときはlocalhost:8000/images/2017となります。

f:id:hiroki-nishizawa:20180813010533p:plain

f:id:hiroki-nishizawa:20180813024636p:plain

app.blade.phpでヘッダーと背景は固定しているので、@yield('content')の部分だけを作成してルーティングを設定してあげればいろんなページが作成できます!

躓いた点

上記で説明したようにやればレイアウトは作成できるはずだったのですが、なぜかbladeは呼ばれているのにvueコンポーネントが反映されていませんでした。 解決方法は、app.blade.php<body>内に

<script src="{{ asset('js/app.js') }}"></script>

を付けるて 一番最初のdivのidをappで指定してあげてれば解決できました。

gyazo.com

自分は会社のプロジェクトと比較をして間違え探しのような形で見つけたのですが理由を調べてみるとapp.jsのデフォルトで記述されている、

const app = new Vue({
    el: '#app'
});

でidを最初に指定されていて<script>タグで呼ばなければいけなかったみたいです。

今はapp.blade.phpにcontentを作成していく感じなのでapp.jsを呼び出すための<script>タグはapp.blade.phpのみの記述で大丈夫です!

最後に

今はただ画像を挿入して表示しているだけですが、ゆくゆくは年ごとにアップロード、ダウンロードしていけるような仕組みにしてきたいと考えています!

自分が書いているLaravelのブログは自分の成長記録みたいなものを書いているので最初から見ていただけたらとてもうれしく思います!

techblog.scouter.co.jp