Laravelの基本はこちらから学べます

Laravel でスレッド掲示板を作成(中級)。6 章リファクタリング




今回は前回に続けて開発を進めていきましょう。
前章をまだご覧になっていない方は以下から開発を始めましょう。

6 章:リファクタリング

トランザクションとエラーハンドリング

ThreadController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Requests\ThreadRequest;
use App\Message;
use App\Thread;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;

class ThreadController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except('index');
    }

    // 略

    /**
     * Store a newly created resource in storage.
     *
     * @param  App\Http\Requests\ThreadRequest $request
     * @return \Illuminate\Http\Response
     */
    public function store(ThreadRequest $request)
    {
        // save Thread
        $thread = new Thread();
        $thread->name = $request->name;
        $thread->user_id = Auth::id();
        $thread->latest_comment_time = Carbon::now();
        $thread->save();

        // save Message
        $message = new Message();
        $message->body = $request->content;
        $message->user_id = Auth::id();
        $message->thread_id = $thread->id;
        $message->save();

        // redirect to index method
        return redirect()->route('threads.index')->with('success', 'スレッドの新規作成が完了しました。');
    }
油そば

これ先ほどの store メソッドだけど、どこがだめなんだ?

ThreadController.php

    // 略

    /**
     * Store a newly created resource in storage.
     *
     * @param  App\Http\Requests\ThreadRequest $request
     * @return \Illuminate\Http\Response
     */
    public function store(ThreadRequest $request)
    {
        // save Thread
        $thread = new Thread();
        $thread->name = $request->name;
        $thread->user_id = Auth::id();
        $thread->latest_comment_time = Carbon::now();
        $thread->save();

        // save Message
        $message = new Message();
        $message->body = $request->content;
        $message->user_id = Auth::id();
        $message->thread_id = $thread->id;
        $message->save();

        // redirect to index method
        return redirect()->route('threads.index')->with('success', 'スレッドの新規作成が完了しました。');
    }

上記の store 処理がポイントだね。
上記は、新しい Thread と Message を保存しているけれど、Message 保存中にエラーがでたらどうなるだろうか。

油そば

Thread が保存されて、Message は保存されないな。
それだと、DB の整合性が保てないな。。


そうだね。つまりは、 Thread と Message の保存処理は一連の処理としたいね。
そこで使用するのが「トランザクション」です。

一連の操作をデータベーストランザクション内で実行するには、DBファサードのtransactionメソッドを使用してください。トランザクション「クロージャ」の中で例外が投げられると、トランザクションは自動的にロールバックされます。「クロージャ」が正しく実行されると、自動的にコミットされます。transactionメソッドを使用すれば、ロールバックやコミットを自分でコードする必要はありません。

データベース:利用開始 https://readouble.com/laravel/6.x/ja/database.html

また、エラーが出た際にはエラーログとしてログファイルに残しておきたいですね。
そこでキーワードとして「例外処理」があげられます。

ログ
https://readouble.com/laravel/6.x/ja/logging.html
エラー処理
https://readouble.com/laravel/6.x/ja/errors.html

上記のドキュメントに目を通してみましょう。
これから、Thread と Message の保存処理を一連のものとし、例外が発生した際にはエラーログとしてログに残す処理を書いていきます。

挑戦してみよう

ThreadController.php にて、Thread と Message の保存処理を一連のものとし、例外が発生した際にはエラーログとしてログに残す処理を書いてみましょう。

それでは答え合わせです。(書き方はいくつかありますので、一例として私のコードを示します)

ThreadController.php

    // 略

    public function store(ThreadRequest $request)
    {
        \DB::beginTransaction();
        try {
            // save Thread
            $thread = new Thread();
            $thread->name = $request->name;
            $thread->user_id = Auth::id();
            $thread->latest_comment_time = Carbon::now();
            $thread->save();
    
            // save Message
            $message = new Message();
            $message->body = $request->content;
            $message->user_id = Auth::id();
            $message->thread_id = $thread->id;
            $message->save();
        } catch (\Exception $error) {
            \DB::rollBack();
            \Log::error($error->getMessage());
            return redirect()->route('threads.index')->with('error', 'スレッドの新規作成に失敗しました。');

        }
        \DB::commit();

        // redirect to index method
        return redirect()->route('threads.index')->with('success', 'スレッドの新規作成が完了しました。');
    }

いかがでしょうか。では実際にエラーが起きた際にはどのような挙動になるのかを確認してみましょう。エラーが発生するようにコードを一箇所変更します。

ThreadController.php

    // 略

    public function store(ThreadRequest $request)
    {
        // 略
            $message->user_id = 10; // 現状 ID = 10 のユーザーは存在しないのでエラー。
        // 略

上記のようにコードを変更後に、localhost/threads にアクセス。新規投稿をしてみましょう。

適当に入力します
エラーが返ってきましtたた

それではログを確認しましょう。
Laravel のログは app \ storage \ logs \ laravel.log に出力されます。

[2021-04-20 07:32:07] local.ERROR: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`2ch`.`messages`, CONSTRAINT `messages_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)) (SQL: insert into `messages` (`body`, `user_id`, `thread_id`, `updated_at`, `created_at`) values (123456789123456789, 10, 3, 2021-04-20 07:32:07, 2021-04-20 07:32:07))  

上記のように、整合性エラーが出ました。OKです!想定通り、エラーがログに出力されるようになりました。

ThreadController.php の $message->user_id = 10; をエラーが出ないコードに戻しておきましょう。

挑戦してみよう

Laradock の MySQL コンテナに入り、実際に Thread 及び Message の新規保存がされていないことを確認しよう。

Service / Repository Pattern

油そば

お、まだリファクタリングする箇所があるのか。
Service / Repository Pattern? 初めて聞いたよ。

Service / Repository Pattern は Laravel の design pattern の一つです。
controller, service, repository とそれぞれの役割で切り離し、読みやすく、再利用可能で、機能拡張しやすいコードにすることができます。

The concept of repositories and services ensures that you write reusable code and helps to keep your controller as simple as possible making them more readable.

Implement CRUD with Laravel Service-Repository Pattern
https://dev.to/safventure/implement-crud-with-laravel-service-repository-pattern-1dkl

では、Controller, Service, Repository の 3 つにどのように処理を分けるかを私がどのように認識しているのかを、簡潔に記載します。

① Controller : アプリケーションとユーザのやりとりを担当する。
リクエストを受け取って、適切な Blade や JSON を返す。

② Service : いわゆるビジネスロジックを担当する。

③ Repository : データベースとのやりとりを担当する。
Model を使い、DB の更新処理等を行う。

参考:「ビジネスロジック」とは何か、どう実装するのか
https://qiita.com/os1ma/items/25725edfe3c2af93d735

Service

それではまずは Service クラスにビジネスロジックを切り出してみましょう。
まずは下記のように、Services ディレクトリに ThreadService.php を作成しましょう。

2ch
 ├─ app
 │   ├─ Services
 │   │   └─ ThreadService.php

ThreadService.php の中身は下記のようにします。

ThreadService.php

<?php

namespace App\Services;

use Exception;
use App\Thread;
use App\Message;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ThreadService
{
    /**
     * Create new thread and first new message.
     *
     * @param array $data
     * @return Tread $thread
     */
    public function createNewThread(array $data, string $user_id)
    {
        DB::beginTransaction();
        try {
            $thread_data = $this->getThreadData($data['name'], $user_id);
            $thread = Thread::create($thread_data);
    
            $message_data = $this->getMessageData($data['content'], $user_id, $thread->id);
            Message::create($message_data);
        } catch (Exception $error) {
            DB::rollBack();
            Log::error($error->getMessage());
            throw new Exception($error->getMessage());
        }
        DB::commit();

        return $thread;
    }

    /**
     * get thread data
     *
     * @param string $thread_name
     * @param string $user_id
     * @return array
     */
    public function getThreadData(string $thread_name, string $user_id)
    {
        return [
            'name' => $thread_name,
            'user_id' => $user_id,
            'latest_comment_time' => Carbon::now()
        ];
    }

    /**
     * get message data
     *
     * @param string $message
     * @param string $user_id
     * @param string $thread_id
     * @return array
     */
    public function getMessageData(string $message, string $user_id, string $thread_id)
    {
        return [
            'body' => $message,
            'user_id' => $user_id,
            'thread_id' => $thread_id
        ];
    }
}

油そば

3 つのメソッドがあるな。1 つはメインのメソッドで保存処理をしているな。
他の 2 つは、受け取ったデータを保存しやすいように配列に変換しているのか。

正解です。今回、データを保存するために、create メソッドを使用しています。
create メソッドを用いることで、配列を渡し一行だけでモデルを作成することができます。

Eloquent 「複数代入」
https://readouble.com/laravel/6.x/ja/eloquent.html

では次に ThreadController.php を変更していきましょう。
ビジネスロジックをサービスに切り出した、コントローラーはどのような実装になっているか確認します。

ThreadController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Requests\ThreadRequest;
use App\Services\ThreadService;
use Illuminate\Support\Facades\Auth;
use Exception;

class ThreadController extends Controller
{
    /**
     * @var threadService
     */
    protected $thread_service;

    /**
     * Create a new controller instance.
     *
     * @param  ThreadService  $thread_service
     * @return void
     */
    public function __construct(
        ThreadService $thread_service // インジェクション
    ) {
        $this->middleware('auth')->except('index');
        $this->thread_service = $thread_service; // プロパティに代入する。
    }
    
    // 略

    /**
     * Store a newly created resource in storage.
     *
     * @param  App\Http\Requests\ThreadRequest $request
     * @return \Illuminate\Http\Response
     */
    public function store(ThreadRequest $request)
    {
        try {
            $data = $request->only(
                ['name', 'content']
            );
            $this->thread_service->createNewThread($data, Auth::id()); // new せずとも $this-> の形で呼び出せる(インジェクションした為)。
        } catch (Exception $error) {
            return redirect()->route('threads.index')->with('error', 'スレッドの新規作成に失敗しました。');
        }

        // redirect to index method
        return redirect()->route('threads.index')->with('success', 'スレッドの新規作成が完了しました。');
    }

    // 略
}
油そば

「インジェクション」?「注入」?

依存注入というおかしな言葉は主に「コンストラクターか、ある場合にはセッターメソッドを利用し、あるクラスをそれらに依存しているクラスへ外部から注入する」という意味で使われます。

サービスコンテナ https://readouble.com/laravel/6.x/ja/container.html

上記の記事をみていただくと、少し分かった気になれると思います。
少々簡単に解説をしていきます。
以下のコードは ThreadController.php の抜粋です。

書き方 ①
ThreadController.php

// 略

$this->thread_service->createNewThread($data, Auth::id());

// 略

この部分は、

書き方 ②
ThreadController.php

// 略

$thread_service = new ThreadService;
$thread_service->createNewThread($data, Auth::id());

// 略

と書いても良いわけです。
ですが、② の書き方ですと、もしも store メソッド以外でもこのサービスを使いたい!!
となった時に、毎度毎度 new, new, new しないといけなくなりますよね。。

油そば

なるほどね。$this-> の形で呼び出せた方が楽だ。さらに、もしも ThreadService ではなく、違うサービスを使いたい!となった際も、一箇所変更するだけで済ませられるな。

そうなんです。「疎結合」と言ったりもしますが、それぞれのクラスが硬く結ばれている(依存しあう)よりも、緩やかに結合してる方が拡張性もあがりますね。

では、ここまでで動作確認を行いましょう。
ログイン後に新規投稿、MySQL コンテナでデータが入っているかを確認しましょう。

Repository

それでは次に Repository クラスにデータベースの処理を切り出してみましょう。
まずは下記のようにRepositories ディレクトリに ThreadRepository, MessageRepository を作成しましょう。

2ch
 ├─ app
 │   ├─ Repositories
 │   │   └─ ThreadRepository.php
 │   │   └─ MessageRepository.php
ThreadRepository.php

<?php

namespace App\Repositories;

use App\Thread;

class ThreadRepository
{
    /**
     * @var Thread
     */
    protected $thread;

    /**
     * ThreadRepository constructor.
     *
     * @param Thread $thread
     */
    public function __construct(Thread $thread)
    {
        $this->thread = $thread;
    }

    /**
     * Create new Thread.
     *
     * @param array $data
     * @return Thread $thread
     */
    public function create(array $data)
    {
        return $this->thread->create($data);
    }
}
MessageRepository.php

<?php

namespace App\Repositories;

use App\Message;

class MessageRepository
{
    /**
     * @var Message
     */
    protected $message;

    /**
     * MessageRepository constructor.
     *
     * @param Message $message
     */
    public function __construct(Message $message)
    {
        $this->message = $message;
    }

    /**
     * Create new Message.
     *
     * @param array $data
     * @return Message $message
     */
    public function create(array $data)
    {
        return $this->message->create($data);
    }
}
油そば

こちらもインジェクトしているな。
そして、各リポジトリは、create メソッドを持っていて、配列で受け取った $data で新規レコードを作っていると言うことか。

全くその通りです。こうすることで、先ほど作成した、ThreadService からデータベースのロジックを切り離すことができるね。

ThreadService.php

<?php

namespace App\Services;

use Exception;
use App\Repositories\MessageRepository;
use App\Repositories\ThreadRepository;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ThreadService
{
    /**
     * @var MessageRepository
     */
    protected $message_repository;

    /**
     * @var ThreadRepository
     */
    protected $thread_repository;

    /**
     * ThreadService constructor.
     *
     * @param MessageRepository $message_repository
     * @param ThreadRepository $thread_repository
     */
    public function __construct(
        MessageRepository $message_repository,
        ThreadRepository $thread_repository
    ) {
        $this->message_repository = $message_repository;
        $this->thread_repository = $thread_repository;
    }
    
    /**
     * Create new thread and first new message.
     *
     * @param array $data
     * @return Tread $thread
     */
    public function createNewThread(array $data, string $user_id)
    {
        DB::beginTransaction();
        try {
            $thread_data = $this->getThreadData($data['name'], $user_id);
            $thread = $this->thread_repository->create($thread_data);
    
            $message_data = $this->getMessageData($data['content'], $user_id, $thread->id);
            $this->message_repository->create($message_data);
        } catch (Exception $error) {
            DB::rollBack();
            Log::error($error->getMessage());
            throw new Exception($error->getMessage());
        }
        DB::commit();

        return $thread;
    }

データベースとのやりとりを切り離した結果、Service はビジネスロジックだけを担当するように記述できましたね。

また、リポジトリを作成した結果、他のサービスでも使えるようになり重複したコードを書かなくてすむようになりましたね。

では、ここで再度動作確認を行いましょう。
ログイン後に新規投稿、MySQL コンテナでデータが入っているかを確認しましょう。

6 章での変更点は以下からご確認いただけます。
https://github.com/t-aburasoba/thread-board/pull/5

もしこの記事をいいねと思ったたり、ご参考になりましたら下記ボタンからサポートしていただけますと、とても励みになります!




コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です