0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(バックエンド実装⑪)~排他制御・トランザクション処理~

Posted at

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その17)

0. 初めに

こんにちは!
このシリーズでは、実務未経験の僕がゼロからWebアプリケーションを作る様子をお届けしています!

今回は、排他制御・トランザクション処理を実装していきたいと思います!

編集機能の問題点

バックエンド実装編⑦では、大学・学部・研究室の編集機能を作成しました。
これは、ログインしていれば誰でも編集することができる機能でした。

ところが、「誰でも編集できる」というのが問題を生みます。
例えば、以下の図をご覧ください。
1.jpg
Aさんが大学を編集するとします。
実際はもっと複雑だと思いますが、仮に図のように「編集開始」、「編集中」、「保存処理」の3つのフェーズがあるとします。

次に以下の図をご覧ください。
2.jpg
Aさんが編集作業中に別のユーザーであるBさんが同じ大学を編集し始めてしまいました。

ここで問題なのが、Bさんが編集を開始した時点では、Aさんの編集が完了・保存されていないため、当然BさんはAさんの編集した内容を知らないまま編集してしまいます。
いわば、Gitのコンフリクト似ている状態になります。
Aさんは同じものに対して「〇〇」という編集をしてきた一方で、Bさんは「△△」という編集をしましたと言われたら、どっちの変更を採用してよいのかわからず、場合によってはバグの原因となります。

そこで、必要になる考え方が排他制御及びトランザクション処理というものです。

排他制御

小見出しを追加.jpg
そこで、このようにBさんが編集して保存しようとしても「他の人が編集しています!だめですよ」とメッセージを出して止めてあげようというのが排他制御の考え方です。

ここで、矢印が断片的な複数のもではなく連続的な一本のものになっていることに注目してください。
これは、トランザクション処理のイメージであり、先ほどの矢印の断片はそうではない処理を表しています。

今回は説明のため「編集開始」、「編集中」、「保存処理」の3つだけにしてありますが、実際のWebサービス上においてデータベースへの処理はもっと複雑でしょう。
そう考えると、Aさんの編集が全体の途中までの処理しか完了していない段階でそこまでの内容が確定してしまった場合、Bさんが途中から編集を開始すると、もしかしたらそのAさんの途中までの変更を誤って取り込んでしまうかもしれません。

ちょっと何言っているかわからないって感じかもしれませんね。(笑)

他によくある例えとしては、銀行の送金処理ですね。
CさんがDさんに10万円を送金しようします。
まず、Cさんの口座から-10万円し、次にDさんの口座に+10万円するとします。
ところが、何らかの不具合で、Cさんの口座から-10万円した段階で処理が止まってしまったらどうでしょうか?
Dさんは10万円をもらえず、Cさんは10万円を失っただけです。
ところが、Cさんの口座の減額からDさんの口座の増額までの一連の処理をひとまとまりにすることで、もし途中で何らかのエラーが発生しても途中までの処理を確定させずに処理全体の開始前に巻き戻すことができます。

このようにトランザクション処理は、データベースに対するいくつかの一連の処理をひとまとまりにします。
排他制御と非常に相性が良いです!

今回は、かなりざっくりとした説明しかしていません。
よりで詳しい話を聞きたい方は、以下の記事を参考にするとよいかもしれません!
今回は、記事の中にある楽観的ロックを実現したいと思います!

1. ブランチ運用

例によって、develop ブランチを最新化して、feature/15-exclusive-control という名前で新規ブランチを切って作業しましょう!

2. 訂正: テーブル設計

たびたび申し訳ないのですが、テーブル設計を修正します。
image.png

このように 'version' というカラムを追加してください。
これは何かと言いますと、楽観的ロックを実装するうえで必要なものです。

誰かが大学に編集を加えるたびにこの 'version' カラムに数字が1つ足されていきます。

そうすることで、自分が編集中に別の人が編集した場合、自分の 'version' と現在のデータベースの 'version' がずれているため、それをもとに実行が中止され、メッセージが表示されるという仕組みです。

(例)

  1. 現在のDBの 'version' カラムの値は 5
  2. Aさんが編集開始
  3. Bさんも後から編集開始
  4. Aさんが編集を完了・更新
  5. 現在のDBの 'version' カラムの値は 6
  6. Bさんが編集を完了・更新しようとする...
  7. Bさんが編集していたのは 'version'5 の状態だが、現在のDBの 'version' カラムの値は 6 なので、更新は中断され、エラーメッセージが表示される

このようにして、楽観的ロックは「複数ユーザーの同時更新はめったに怒らないだろう」という想定の下、データベース自体をロックはせずに、ユーザーの同時更新問題に対処することができます。

3. マイグレーション修正

したがって、マイグレーションファイルを編集して、'version' カラムを追加し、マイグレーションを再実行しましょう!

\project-root\src\database\migrations\2025_05_30_072009_create_universities_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('universities', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->timestamps();
            $table->softDeletes();
            $table->unsignedBigInteger('version')->default(1); // 追加
        });
    }

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

実行コマンド

/var/www
$ php artisan migrate:fresh --seed

例によって、本番環境では実行してはいけないやつですね。
この辺は正直自分でもちょっと微妙だと思っていますw

4. コントローラー修正

大学コントローラーの update() メソッドを修正します!

\project-root\src\app\Http\Controllers\UniversityController.php
    // 修正: versionの更新処理・トランザクション処理
    public function update(Request $request, University $university)
    {
        $this->authorize('update', University::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $university->id,
            'comment' => 'required|string|max:255',
            'version' => 'required|integer', // 追加
        ]);

        // トランザクション開始
        DB::beginTransaction();

        try {
            // 他のユーザーが更新している可能性がある
            // そのため、最初に最新の university を取得
            $current = University::find($university->id);

            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの大学情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // データ更新
            $current->name = $validated['name'];
            $current->version += 1; // バージョンを1増やす
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $university->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit(); // トランザクション処理終了

             // リダイレクト
            return redirect()->route('faculties.index', ['university' => $university])->with('success', '大学情報が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack(); // エラー時はロールバック
            throw $e;
        }
    }

use 宣言をお忘れなく!

use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

基本的な保存処理はそのままですが、トランザクション処理になるように修正しています!
以下に中身の解説を付け加えておきます。

トランザクション開始

以下のようにDBファサードを記述することで、トランザクション処理が開始されます!

DB::beginTransaction();

try-catch構文

try {
// 一連の処理
} catch {
// 失敗したらロールバック
}

try-catch構文は大丈夫でしょうか!
基本文法なので解説は割愛しますが、トランザクション処理と非常に相性の良い書き方です!

というのも、トランザクション処理の特徴として、

データベースに対する一連の操作をひとまとまりにして、もし途中でどこか一つでもう>まくいかなかったら、すべてなかったことにする

というものがありましたね。
実は、try-cathc構文で、この「一連の処理」を try { } の中に、「なかったことにする(巻き戻し・ロールバック)」を catch { } の中に書けばそれが実現されるということです!

もちろん、Laravelにはトランザクション処理特有の書き方があるみたいですが、今回は分かりやすさ重視でtry-catch構文で書いてみました。

また、try { ] の中で、現在の version を取得して、$current という変数に代入する処理が追加されます。
保存処理もこの $current に対して行います。

トランザクション処理終了

DB::commit();

このように書くことで、トランザクション処理の完了(確定・コミット)となります!

5. 編集ページ修正

\project-root\src\resources\js\Pages\University\Edit.jsx
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';

export default function Edit({ university }) {
    const { data, setData, put, processing, errors } = useForm({
        name: university.name || '',
        comment: '',
        version: university.version // 追加
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        put(route('university.update', university.id));
    };

    return (
        <>
            <Head title={`${university.name} - 編集`} />
            
            <div>
                <h1>{university.name} - 編集</h1>
                
                <form onSubmit={handleSubmit}>
                    {errors.version && <div style={{ color: 'red' }}>{errors.version}</div>}
                    <div>
                        <label htmlFor="name">大学名:</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            required
                        />
                        {errors.name && <div>{errors.name}</div>}
                    </div>
                    
                    <div>
                        <label htmlFor="comment">編集理由:</label>
                        <textarea
                            id="comment"
                            value={data.comment}
                            onChange={(e) => setData('comment', e.target.value)}
                            placeholder="編集理由を入力してください"
                            required
                        />
                        {errors.comment && <div>{errors.comment}</div>}
                    </div>
                    
                    <div>
                        <button type="submit" disabled={processing}>
                            {processing ? '更新中...' : '更新'}
                        </button>
                        
                        <Link href={route('faculties.index', university.id)}>
                            <button type="button">キャンセル</button>
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

6. 動作確認

image.png
image.png

2つの異なるブラウザでそれぞれ異なるユーザーでログインしてみます。
上の「田辺 桃子」さんは、Google Chromeを、下の「高橋 涼平」さんは、Microsoft Edgeを使用してします。

①田辺 桃子さんで編集

「荒木大学」にアクセスして、「大学を編集」をクリックしましょう。
http://localhost/universities/1/faculties
image.png

適当に編集しましょう。
まだ、「更新」ボタンは押さないでください。
image.png

②高橋 涼平さんで編集

次に、Edgeの方で、同じく「荒木大学」を編集します。
こっちは、編集内容を少し変化させてみます。
image.png

③田辺 桃子さんで更新

それでは、Chromeの方に戻っていただいて、「更新」ボタンをクリック。
変わりましたね。
image.png

データベースに登録できました!
image.png

④高橋 涼平さんで更新

最後に、高橋 涼平さんの画面に戻って、「更新」ボタンをクリック。
すると、エラーメッセージが現れます!
image.png

データベースをリフレッシュしても内容は、田辺 桃子さんが編集したもののままです!
どうやらうまくいっているみたいですね。(≧◇≦)
image.png

7. 学部・研究室の排他制御実装

お気づきかと思いますが、これで終わりではありません!
複数人が編集できるのは、大学だけではなく学部研究室もでしたね。
同じブランチのままで続きの作業をしていきましょうか!

テーブル設計を修正する

学部テーブル
image.png

研究室テーブル
image.png

それぞれ、'version' カラムを追加してください!

マイグレーションを修正して実行する

\project-root\src\database\migrations\2025_05_30_072525_create_faculties_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('faculties', function (Blueprint $table) {
            $table->id();
            $table->foreignId('university_id')->constrained('universities')->onDelete('cascade');
            $table->string('name');
            $table->timestamps();
            $table->unsignedBigInteger('version')->default(1); // 追加
        });
    }

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

\project-root\src\database\migrations\2025_05_30_074257_create_labs_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('labs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('faculty_id')->constrained('faculties')->onDelete('cascade');
            $table->string('name');
            $table->text('description')->nullable();
            $table->text('url')->nullable();
            $table->text('professor_url')->nullable();
            $table->unsignedTinyInteger('gender_ratio_male');
            $table->unsignedTinyInteger('gender_ratio_female');
            $table->timestamps();
            $table->unsignedBigInteger('version')->default(1); // 追加
        });
    }

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

マイグレーションファイルにもそれぞれ一行追加してください。
できたら、以下を実行です。

実行コマンド

/var/www
$ php artisan migrate:fresh --seed

コントローラーを修正する

\project-root\src\app\Http\Controllers\FacultyController.php
    public function update(Request $request, Faculty $faculty)
    {
        $this->authorize('update', Faculty::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $faculty->id,
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        // トランザクション
        DB::beginTransaction();

        try {
            // 現在のバージョンを取得して比較
            $current = Faculty::find($faculty->id);
            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの学部情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // 更新
            $current->name = $validated['name'];
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $current->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();

            return redirect()->route('labs.index', ['faculty' => $current])->with('success', '大学情報が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }
\project-root\src\app\Http\Controllers\LabController.php
    public function update(Request $request, Lab $lab)
    {
        $this->authorize('update', Lab::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,' . $lab->id . ',id,faculty_id,' . $lab->faculty_id,
            'description' => 'nullable|string|max:500',
            'url' => 'nullable|url|max:255',
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female !== 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        DB::beginTransaction();

        try {
            // 現在のバージョンを取得して比較
            $current = Lab::find($lab->id);
            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの研究室情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // 更新
            $current->name = $validated['name'];
            $current->description = $validated['description'];
            $current->url = $validated['url'];
            $current->professor_url = $validated['professor_url'];
            $lab->gender_ratio_male = $validated['gender_ratio_male'];
            $lab->gender_ratio_female = $validated['gender_ratio_female'];
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $lab->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();
            
            return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }

それぞれ update() メソッドを修正してください!

編集ページを修正する

\project-root\src\resources\js\Pages\Faculty\Edit.jsx
    const { data, setData, put, processing, errors } = useForm({
        name: faculty.name || '',
        comment: '',
        version: faculty.version // 追加
    });
\project-root\src\resources\js\Pages\Lab\Edit.jsx
    const { data, setData, put, processing, errors } = useForm({
        name: lab.name || '',
        description: lab.description || '',
        url: lab.url || '',
        professor_url: lab.professor_url || '',
        gender_ratio_male: lab.gender_ratio_male || 5,
        gender_ratio_female: lab.gender_ratio_female || 5,
        comment: '',
        version: lab.version, // 追加
    });

useForm() の中に一行追加です。
あとは、form の直下に以下を追加です。
大学の時と全く同じです。

{errors.version && <div style={{ color: 'red' }}>{errors.version}</div>}

動作確認をする

割愛!(笑)
大学の時と同じように、二つのブラウザでそれぞれ異なるユーザーでログインして、順番に操作してみて、画面やデータベースの様子を観察してみてください!

できたら、これまでの変更をコミット・プッシュして、リモートの develop ブランチに対して、プルリクエスト作成・マージをしておきましょう!

8. まとめ・次回予告

今日は、新しい概念として、排他制御トランザクション処理楽観的ロックについて勉強し、実装までしました。
また、try-catch構文も出てきましたね。
怪しい方は是非復習しておきましょう~!

次回は、マイページ機能を作成したいと思います。
バックエンド実装編、結構長くなっていますが、最後まで走り切りましょう!(*´ω`)

参考

これまでの記事一覧

--- 要件定義・設計編 ---

--- 環境構築編 ---

--- バックエンド実装編 ---

0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?