MENU

blog
スタッフブログ

dot
【Laravel 11】Laravel Reverbを使ってWebSocketでリアルタイム通信【後編】
技術

【Laravel 11】Laravel Reverbを使ってWebSocketでリアルタイム通信【後編】

みなさん、こんにちは。
ソリューションSecの長谷川です。

今回は前回書いた記事【laravel-11】laravel-reverbを使ってwebsocketでリアルタイム通信【前編】の続きを書いていきたいと思います。
前回は、Laravel Reverbを使って、ブロードキャストのイベントを定義し
同じページを同時に開いたときにコンソール上でそれぞれにメッセージが表示されるところまでを実装しました。

今回の範囲では、それをちょこっとだけ拡張してチャット機能を作ってみたいと思います。
なお、注意していただきたいのは、本記事ではReverbを使えば例えばこんなことが出来るよという内容であり
きっちりと作り込むにはもっといろいろなことを調べて作らなければいけないということです。

そのあたりを踏まえたうえで見ていただければと思います。

とりあえずチャット用のフロントエンドを作成していく

今回は、チャット画面ということもあり、パパっと作りたいのでVue.jsを使っていくことにします。

もともと、Laravelのartisanを利用して、Vueをプロジェクトに追加しようとしましたが
Viteのバージョンが6.0になっており、現在@vitejs/plugin-vueがそのバージョンに対応していないので
今回は別ディレクトリにVueのプロジェクトを作って対応します。

とりあえずLaravelとは別のフォルダを作って、下記コマンドでNuxtプロジェクトを作成し、Nuxt UIを導入しておきます。

npx nuxi@latest init frontend
cd frontend
npx nuxi@latest module add ui
npm install laravel-echo pusher-js

Nuxt.jsのページもとりあえず下記のような感じで作っていきます。

<template>
  <div>
    <NuxtRouteAnnouncer />
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>
<template>
    <div class="w-full h-screen flex justify-center items-center">
        <UCard class="w-[500px]">
            <template #header>
                サンプルチャット
            </template>
            名前を入力してください。
            <UInput v-model="name" placeholder="名前" />
            <template #footer>
                <UButton @click="onEnter">入室</UButton>
            </template>
        </UCard>
    </div>
</template>

<script setup lang="ts">
const route = useRoute();
const name = ref<string>('');

const onEnter = () => {
    if (name.value === '') {
        alert('名前を入力してください');
        return;
    }

    localStorage.setItem('name', name.value);
    navigateTo('/chat');
}
</script>
<template>
    <div class="w-full h-screen flex flex-col justify-center items-center">
        <div class="grow w-full p-4 overflow-scroll-y flex flex-col gap-2">
            <div v-for="message in messages" :key="message.datetime" class="flex flex-col right-0">
                <div class="p-2 bg-gray-200 rounded-lg">
                    <div class="text-sm">{{ message.name }}</div>
                    <div>{{ message.message }}</div>
                    <div class="text-xs text-right">{{ message.datetime }}</div>
                </div>
            </div>
        </div>
        <UButtonGroup class="w-full p-4">
            <UInput class="w-full" v-model="message" placeholder="メッセージ" />
            <UButton @click="onSend">送信</UButton>
        </UButtonGroup>
    </div>
</template>

<script setup lang="ts">
const message = ref<string>('');
const messages = ref<Message[]>([]);
const config = useRuntimeConfig()
const participants = ref<Record<string, string>>({});
const myself = ref<Participant>();

interface Message {
    name: string;
    message: string;
    datetime: string;
    side: 'left' | 'right';
}

interface Participant {
    uuid: string;
    name: string;
}

onMounted(() => {
    myself.value = {
        name: localStorage.getItem('name') ?? '名無し',
        uuid: crypto.randomUUID()
    }

    addMessage('', 'ようこそ ' + myself.value.name + ' さん')

    fetch(config.public.API_URL + '/join', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(myself.value)
    }).then(res => {
        return res.json()
    }).then(data => {
        console.log(data)
        participants.value = data.participants
    })

    window.Echo.channel('channel-name')
        .listen('RoomJoin', (e: any) => {
            const message = e.message
            console.log(message)
            if (message.uuid === myself.value?.uuid) {
                return
            }

            const participant = {
                name: message.name,
                uuid: message.uuid,
            }

            participants.value[message.uuid] = message.name

            addMessage('', message.name + 'さんが入室しました')
        }).listen('RoomComment', (e: any) => {
            const message = e.message
            addMessage(message.uuid, message.message)
        })

})

const onSend = function () {
    if (message.value === '') {
        return;
    }

    fetch(config.public.API_URL + '/comment', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            'uuid': myself.value?.uuid,
            'message': message.value,
        })
    })

    message.value = '';
}

const addMessage = function (uuid: string, message: string) {
    const name = participants.value[uuid] ?? 'システム';
    const side = uuid === myself.value?.uuid ? 'right' : 'left'

    messages.value.push({
        name: name,
        message: message,
        datetime: new Date().toLocaleString(),
        side: side
    })
}

</script>

本来は、インタフェースなどは別途tsファイルで作成すべきですし
もっと色々と整理するのがいいのですが、今回はサクッと作りたかったので
手抜きになってしまいますが、ご容赦ください。

Laravelに戻ってバックエンドを作る

チャットのサンプルなので、ただ単にメッセージを送信できればいいのですが
今回は一応入室イベントとコメント送信イベントの2つを作ります。
(本当は退室イベントなどもあったほうが良いですね)

./vendor/bin/sail artisan make:event RoomJoin
./vendor/bin/sail artisan make:event RoomComment

イベントを作ったら、前回のTestEvent同様にちょっと中身を書き換えます。
なお、何度も言いますが今回はこんなこともできるよレベルですので
中身はかなり適当なため、上記で作成したRoomJoin.phpとRoomComment.phpはクラス名以外はまったく同じコードです。

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

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

    public $message;

    /**
     * Create a new event instance.
     */
    public function __construct($request)
    {
        $this->message = $request;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new Channel('channel-name'),
        ];
    }
}
<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

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

    public $message;

    /**
     * Create a new event instance.
     */
    public function __construct($request)
    {
        $this->message = $request;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new Channel('channel-name'),
        ];
    }
}

さて、イベントを作ったらフロントエンド側で予防としているAPIの定義をします。
Laravel11のためか、routes/api.phpがなかったので、下記のコマンドで追加しました。

./vendor/bin/sail artisan install:api

では、api.phpを下記のように作成します。

<?php

use App\Events\RoomComment;
use App\Events\RoomJoin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/join', function (Request $request) {
    RoomJoin::dispatch($request->all());

    $participants = cache()->get('participants', []);
    $participants[$request->get('uuid')] = $request->get('name');
    cache()->forever('participants', $participants);

    return response()->json(['status' => 'OK', 'participants' => $participants]);
});

Route::post('/comment', function (Request $request) {
    RoomComment::dispatch($request->all());
    return response()->json(['status' => 'OK', 'params' => $request->all()]);
});

すっごくざっくりと書いていっていますが、とりあえずこれでOKです。
あとは、再度routeのキャッシュをします。

./vendor/bin/sail artisan route:cache

出来たものがこちら

最後に

今回は簡単なチャットにしましたが、これを使えばいわゆるスプレッドシートのような
共同作業をするためのアプリを作成することも出来ますね。

また、近年はReactやVueなどフロントエンドの開発フレームワークも発展してきたことから
だいぶフロントエンドアプリケーションの開発のしやすさは向上したので
とっつきやすくなったのではないでしょうか。

それでは、今回はこのへんで。

dot
dot
PAGETOP