実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その17)
0. 初めに
こんにちは!
このシリーズでは、実務未経験の僕がゼロからWebアプリケーションを作る様子をお届けしています!
今回は、排他制御・トランザクション処理を実装していきたいと思います!
編集機能の問題点
バックエンド実装編⑦では、大学・学部・研究室の編集機能を作成しました。
これは、ログインしていれば誰でも編集することができる機能でした。
ところが、「誰でも編集できる」というのが問題を生みます。
例えば、以下の図をご覧ください。
Aさんが大学を編集するとします。
実際はもっと複雑だと思いますが、仮に図のように「編集開始」、「編集中」、「保存処理」の3つのフェーズがあるとします。
次に以下の図をご覧ください。
Aさんが編集作業中に別のユーザーであるBさんが同じ大学を編集し始めてしまいました。
ここで問題なのが、Bさんが編集を開始した時点では、Aさんの編集が完了・保存されていないため、当然BさんはAさんの編集した内容を知らないまま編集してしまいます。
いわば、Gitのコンフリクト似ている状態になります。
Aさんは同じものに対して「〇〇」という編集をしてきた一方で、Bさんは「△△」という編集をしましたと言われたら、どっちの変更を採用してよいのかわからず、場合によってはバグの原因となります。
そこで、必要になる考え方が排他制御及びトランザクション処理というものです。
排他制御
そこで、このように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. 訂正: テーブル設計
このように 'version'
というカラムを追加してください。
これは何かと言いますと、楽観的ロックを実装するうえで必要なものです。
誰かが大学に編集を加えるたびにこの 'version'
カラムに数字が1つ足されていきます。
そうすることで、自分が編集中に別の人が編集した場合、自分の 'version'
と現在のデータベースの 'version'
がずれているため、それをもとに実行が中止され、メッセージが表示されるという仕組みです。
(例)
- 現在のDBの
'version'
カラムの値は5
- Aさんが編集開始
- Bさんも後から編集開始
- Aさんが編集を完了・更新
- 現在のDBの
'version'
カラムの値は6
に - Bさんが編集を完了・更新しようとする...
- Bさんが編集していたのは
'version'
が5
の状態だが、現在のDBの'version'
カラムの値は6
なので、更新は中断され、エラーメッセージが表示される
このようにして、楽観的ロックは「複数ユーザーの同時更新はめったに怒らないだろう」という想定の下、データベース自体をロックはせずに、ユーザーの同時更新問題に対処することができます。
3. マイグレーション修正
したがって、マイグレーションファイルを編集して、'version'
カラムを追加し、マイグレーションを再実行しましょう!
<?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');
}
};
実行コマンド
$ php artisan migrate:fresh --seed
例によって、本番環境では実行してはいけないやつですね。
この辺は正直自分でもちょっと微妙だと思っていますw
4. コントローラー修正
大学コントローラーの update()
メソッドを修正します!
// 修正: 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. 編集ページ修正
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. 動作確認
2つの異なるブラウザでそれぞれ異なるユーザーでログインしてみます。
上の「田辺 桃子」さんは、Google Chromeを、下の「高橋 涼平」さんは、Microsoft Edgeを使用してします。
①田辺 桃子さんで編集
「荒木大学」にアクセスして、「大学を編集」をクリックしましょう。
http://localhost/universities/1/faculties
適当に編集しましょう。
まだ、「更新」ボタンは押さないでください。
②高橋 涼平さんで編集
次に、Edgeの方で、同じく「荒木大学」を編集します。
こっちは、編集内容を少し変化させてみます。
③田辺 桃子さんで更新
それでは、Chromeの方に戻っていただいて、「更新」ボタンをクリック。
変わりましたね。
④高橋 涼平さんで更新
最後に、高橋 涼平さんの画面に戻って、「更新」ボタンをクリック。
すると、エラーメッセージが現れます!
データベースをリフレッシュしても内容は、田辺 桃子さんが編集したもののままです!
どうやらうまくいっているみたいですね。(≧◇≦)
7. 学部・研究室の排他制御実装
お気づきかと思いますが、これで終わりではありません!
複数人が編集できるのは、大学だけではなく学部と研究室もでしたね。
同じブランチのままで続きの作業をしていきましょうか!
テーブル設計を修正する
それぞれ、'version'
カラムを追加してください!
マイグレーションを修正して実行する
<?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');
}
};
<?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');
}
};
マイグレーションファイルにもそれぞれ一行追加してください。
できたら、以下を実行です。
実行コマンド
$ php artisan migrate:fresh --seed
コントローラーを修正する
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;
}
}
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()
メソッドを修正してください!
編集ページを修正する
const { data, setData, put, processing, errors } = useForm({
name: faculty.name || '',
comment: '',
version: faculty.version // 追加
});
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構文も出てきましたね。
怪しい方は是非復習しておきましょう~!
次回は、マイページ機能を作成したいと思います。
バックエンド実装編、結構長くなっていますが、最後まで走り切りましょう!(*´ω`)
参考
これまでの記事一覧
--- 要件定義・設計編 ---
--- 環境構築編 ---
- その2: 環境構築編① ~WSL, Ubuntuインストール~
- その3: 環境構築編② ~Docker Desktopインストール~
- その4: 環境構築編③ ~Dockerコンテナ立ち上げ~
- その5: 環境構築編④ ~Laravelインストール~
- その6: 環境構築編⑤ ~Gitリポジトリ接続~
--- バックエンド実装編 ---
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~