【Laravel + Pusher】みんなで共同編集できる機能をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、先日 paiza が「緊急事態宣言解除後のITエンジニアのリモートワーク実態調査」というアンケート結果を公開しました。

この中で私が興味深かったのが、「転職の条件にリモートワークが必要かどうか?」という質問で、結果は

    • 週の半分ぐらい必要(30.6%)
    • フルリモート(27.9%)
    • 週1あればOK(13.6%)

と、少なくともリモートワークが企業を選ぶ条件の1つになっているようでした。

(逆に出社したい考えている人はたった2.1%でした)

どうやらリモートワークを実施するといろいろと課題もあるそうですが、やはりネット環境が整ったおかげでこういった意見が増えている部分も大きいんだろうなと考えてました。

そしてそんな中、あるつくってみたかったことを思い出しました。

それが・・・・・・

「リアルアイム共同編集」

機能です。

というのも、以前「Googleスプレッドシート」を2人で別環境から編集していたのですが、リアルタイムで向こう側の変更点が更新されて、とても驚いたからです。(なんとフォーカスしている位置まで共有されてました😳)

そこで❗

今回は、「Laravel + Pusher」を使って「リアルタイム共同編集」機能をつくってみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。😄✨

※ なお、記事を書いていて悩みましたが、最終的にわかりやすさを重視して「超シンプル構造」にしています。そのため、ユーザビリティ・セキュリティ・メンテナンスは一切考慮していません。お気をつけください。m(_ _)m

「よこすか海軍カレー最高でした❗
(レトルトですが 🍛✨)」

開発環境: Laravel 8.x、Vue 3

前提として

今回はリアルタイム編集を実現するためにPusherというサービスを使います。そのため、LaravelPusherが使えるようになっていることが前提です。

もし、まだの方は以下を参考にして先に準備しておいてください。(ちょっと古い記事かもですが…😅)

📝 参考ページ: リアルタイム・チャットをつくる:リアルタイムに更新する部分をつくる

必要なファイルを用意する

では、今回の機能に必要なファイルをつくっていきましょう。
以下のコマンドを実行してください。

php artisan make:model JointDocument -msc

すると「モデル」「マイグレーション」「Seeder」、そして「コントローラー」ファイルが一気に作成されるのでそれぞれ中身を変更していきます。

マイグレーションを準備する

では、まずはDBテーブルをつくる設計図になる「マイグレーション」をつくります。

database/migrations/****_**_**_******_create_joint_documents_table.php

// 省略

public function up()
{
    Schema::create('joint_documents', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('version')->default(1)->comment('バージョン');
        $table->text('body')->comment('文章内容');
        $table->timestamps();
    });
}

Seeder を準備する

続いて、マイグレーションでDBテーブルをつくったあとにテストデータを追加するための「Seeder」ファイルです。

database/seeders/JointDocumentSeeder.php

<?php

namespace Database\Seeders;

use App\Models\JointDocument;
use Illuminate\Database\Seeder;

class JointDocumentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        for($i = 1; $i <= 10; $i++) {

            $document = new JointDocument();
            $document->body = "テスト文書内容\nテスト文書内容\nテスト文書内容";
            $document->save();

        }
    }
}

Seederファイルはつくっただけでは実行できないので、Laravel側へ登録します。

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Document;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        // 省略

        $this->call(JointDocumentSeeder::class);
    }
}

では、この状態でDBを再構築してみましょう。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

※ ちなみに私の場合は、エイリアスを登録してpam:fsで上のコマンドが実行されるようにしています。ホント楽 😄✨

実際のテーブルはこうなりました。

モデルを準備する

続いて、モデルに「保存時に自動でバージョンを+1する」ようコードを追加します。

これは後ほどご紹介しますが、「最新バージョンのチェック」が必要になるからです。

// 省略

class JointDocument extends Model
{
    use HasFactory;

    protected static function booted()
    {
        static::saving(function ($joint_document) {

            $joint_document->version++; // 保存時にバージョンを +1 する

        });
    }
}

※ なお、今回はモデルに直接savingイベントをセットしていますが、専用イベントとしてセットしてもOKです。状況によっては切り分けた方がいいかもですね。

Pusher の通知イベントをつくる

次にコントローラーなのですが、先にその中で必要なイベントを作っておきます。要は、Pusherに「データ更新されたから、通知ヨロシク❗」を伝える部分です。

以下のコマンドを実行してください。

php artisan make:event JointDocumentUpdated

すると、イベントファイルが作成されるので、中身を以下のように変更します。

app/Events/JointDocumentUpdated.php

<?php

namespace App\Events;

use App\Models\JointDocument;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class JointDocumentUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    private $joint_document;

    public function __construct(JointDocument $joint_document)
    {
        $this->joint_document = $joint_document;
    }

    public function broadcastOn()
    {
        return new PrivateChannel('joint-document-updated.'. $this->joint_document->id);
    }

    public function broadcastWith() // ここに JS 側へ送るデータをセットする
    {
        return [
            'joint_document' => $this->joint_document
        ];
    }
}

なお、この中のbroadcastWith()の返り値がJavaScript側へ送信されることになります。

コントローラーを準備する

では、コントローラーです。
中身はこうなります。

app/Http/Controllers/JointDocumentController.php

<?php

namespace App\Http\Controllers;

use App\Events\JointDocumentUpdated;
use App\Models\JointDocument;
use Illuminate\Http\Request;

class JointDocumentController extends Controller
{
    public function edit(JointDocument $joint_document)
    {
        return view('joint_document.edit')->with([
            'joint_document' => $joint_document,
        ]);
    }

    public function update(Request $request, JointDocument $joint_document)
    {
        // バリデーションは省略しています

        $result = false;
        $version = $request->version;

        if(intval($version) === intval($joint_document->version)) { // 最新バージョンかどうかチェック

            $joint_document->body = $request->body;
            $result = $joint_document->save();

            if($result === true) {

                JointDocumentUpdated::dispatch($joint_document); // Pusher へイベントを送る

            }

        }

        if($result === false) { // 失敗の場合は最新版のデータを返す

            $joint_document = $joint_document->refresh();

        }

        return [
            'result' => $result,
            'latest_joint_document' => $joint_document
        ];
    }
}

この中で重要なのは、「編集しようとしている文書が最新版かどうか」のチェックです。

なぜなら、バージョン・チェックをしないと「古い文書なのに編集してしまう = 誰かの努力が消えてしまう」可能性があるからです。

例えば、以下のようなケースです。

  1. A さんが1時間頑張ってレポートを書き保存。そして、パソコンをスリープ状態にした
  2. 直後にBさんが「Aさんのレポート」を3時間気合で修正した
  3. その後、Aさんが寝る前に「あ!あれ書き忘れた」とスリープ状態からパソコンを復活させて文章を追加した
  4. 結果、Bさんの気合の3時間は失われてしまった…😫

つまり、古いバージョンから変更をしようとすると拒否する必要があるわけですね。この理由から最新バージョンしか更新できなくしています。

※ 本来はsetInterval() を使って定期的に最新バージョンかどうかチェックするなど、何重にもチェックをいれておくべきですが、今回は「シンプル構造」でお届けしているので、省略しています。また、各バージョンを残す機能もあるといいかもしれません。

ビューを準備する

では、ブラウザで表示されることになるビューをつくります。

resources/views/joint_document/edit.blade.php

<html>
<head>
    <!-- ① Pusher 用認証用に csrf トークンをセット -->
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">
    <div class="row">
        <div class="col-md-6">
            <div class="h3 mb-3">リアルタイム共同編集のサンプル</div>
            <textarea class="form-control mb-3 p-4" rows="15" v-model="params.body"></textarea>
            <div class="text-end">
                <button class="btn btn-primary btn-lg" @click="onSubmit">保存する</button>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-md-6">
            バージョン(テスト用)<br>
            <input type="text" v-model="params.version">
        </div>
    </div>
</div>
<!-- ↓↓↓ Laravel Echo・Vue・axios は npm でビルドして固めています -->
<script src="/js/app.js"></script>
<script>

    createApp({
        data() {
            return {
                params: @json($joint_document->only(['version', 'body']))
            }
        },
        methods: {
            init() {

                // ② Pusher からのプライベート通知を受け取る部分
                Echo.private('joint-document-updated.{{ $joint_document->id }}')
                    .listen('JointDocumentUpdated', e => {

                        this.params = {
                            version: e.joint_document.version,
                            body: e.joint_document.body
                        };

                    });

            },
            onSubmit() {

                const url = '{{ route('joint_document.update', $joint_document->id) }}';
                axios.put(url, this.params)
                    .then(response => {

                        const result = response.data.result;

                        if(result === false) { // ③ 保存に失敗したときは最新バージョンへ更新

                            const joint_document = response.data.latest_joint_document;
                            this.params = {
                                version: joint_document.version,
                                body: joint_document.body
                            };

                        }

                    });

            }
        },
        mounted() {

            this.init();

        }
    }).mount('#app');

</script>
</body>
</html>

では、この中で重要な部分をひとつずつ見ていきましょう❗

① Pusher 用認証用に csrf トークンをセット

今回Pusherで使うのは「プライベート」な接続ですので、ページが開いた時に認証URL( https://******/broadcasting/auth )からデータを取得することになります。

しかし、Laravelには標準でCSRFチェックがあるので、もしトークンがない場合は419エラーが発生することになります。その対処のためにセットしているわけですね。(なお、おそらくlayoutsBladeテンプレートにはすでにセットされています)

② Pusher からのプライベート通知を受け取る部分

ここがPusherからの通知を受け取る部分になります。

取得できるデータは、JointDocumentUpdatedイベントのbroadcastWith()でセットされたものになります。

③ 保存に失敗したときは最新バージョンへ更新

ここが先ほど紹介した「最新バージョンしか更新できないようにする」部分に関連しています。

つまり、Laravel側で「いやいや、これ古いバージョンだから更新させないよ😤」となった場合に、その古いデータの代わりに最新バージョンへ置き換えるようになっています。(そして、次回からは最新バージョンなので更新できる、という流れです)

※ なお、今回はテストのためバージョン番号をいつでも変更できる入力ボックスもつけています。

ルートを準備する

では、最後にルートを作ります。

routes/web.php

use App\Http\Controllers\JointDocumentController;

// 省略

// 共同編集
Route::resource('joint_document', JointDocumentController::class)->only(['edit', 'update']);

// Pusher 認証
Route::post('broadcasting/auth', function(){

    $pusher = new \Pusher\Pusher(
        config('broadcasting.connections.pusher.key'),
        config('broadcasting.connections.pusher.secret'),
        config('broadcasting.connections.pusher.app_id'),
    );
    $request = request();
    $channel_name = $request->channel_name;
    $socket_id = $request->socket_id;

    return $pusher->socket_auth($channel_name, $socket_id);

});

この中で重要なのがbroadcasting/authの部分です。

ここにJavaScript側からアクセスがあり、認証トークンを取得することになります。

テストしてみる

では、実際にテストしてみましょう❗

2つのブラウザで「https://******/joint_document」へそれぞれアクセスしてみます。(Chromeの場合、シークレットモードで開くと別ブラウザとして使えます)

すると、DB内の文章が表示され、さらにバージョンは1になっています。

では、まずは左側から「左からの送信です!」と入力し保存してみましょう。

すると・・・・・・

はい❗
右側のブラウザの文章が自動的に更新されました。

(さらに、バージョンが2になっていることも注目してください👍)

では、続いて右側から同じく「右からの送信です♪」と入力し、保存してみましょう。

結果はどうなったでしょうか・・・・・・??

はい❗
左側にも変更が通知されました。

では、最後に「古いバージョンからの保存を想定したテスト」をしてみましょう。

まず、左側に「古いバージョンから更新するテストです。」と入力し、さらにバージョンを「」に変更して送信してみましょう。

すると・・・・・・???

はい❗
想定したとおり、古いバージョンなので保存が拒否され、さらに文章&バージョンが最新のものに入れ替えになりました。

すべて成功です😄✨

企業様へのご提案

今回の機能を使うと、今ご利用中のシステムでも「リアルタイム共同編集」機能が使えるようになります。

さらに、文章だけでなくあるテーブル内のデータリストや、やりかたによっては画像の共同編集なども可能になるかと思います。

そういった機能をご希望でしたら、ぜひお問い合わせからお気軽にご連絡ください。

どうぞよろしくお願いいたします。m(_ _)m

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

とうことで、今回はLaravel + Pusherで「リアルタイム共同編集」機能をつくってみました。

当初は、変更された部分だけを更新するつもりだったのですが、時間差のことを考えるとどうしてもコンフリクトしてしまうので、今回のような形にしました。

ちなみに今回は変更箇所が1か所だけでしたが、エクセルのように分割されている場合はよりリアルタイム性が強いシステムがつくれるんじゃないでしょうか。

また、相手側が入力しているかどうかもPusherで通知できるのでユーザビリティを考えるならそういった「ちょっとした」機能を追加して方がいいかもしれませんね。

ぜひ皆さんもいろいろと改造してみてください。

ではでは〜❗

「もはやブログ書くときも
Copilotが欲しいです(笑)」

このエントリーをはてなブックマークに追加       follow us in feedly