Laravel + React でリアルタイム・チャットをつくる

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

さてさて、皆さんもご存知の通りLaravelは多方面に渡って機能追加がやってくるので、嬉しい半面、こんなことになったりするんじゃないでしょうか。

「えっ、そんなのあったの😲❗」

まさに少し前の私がこれなのですが、公式ドキュメントであることに気が付きました。

それは・・・・・・

ブロードキャスト機能に Ably(えいぶりー) というサイトが追加になっている

というものでした。

ちなみに「ブロードキャスト」機能とは、シンプルに言うと「リアルタイムで同期できる」機能のことで、例えばPHPの動作をリアルタイムでJavaScript側に伝えたりすることができます。

そして、このブロードキャスト機能で思いつくのが、

チャット機能

ですね。

そこで❗

今回は、「Laravel + React + Ably」でチャット機能を実装してみたいと思います。

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

「えっ、VanillaJSって素のJSなの!?
もう歴15年超なんですが…
(& 私の基準はチョコなんですが…)」

開発環境: Laravel 9.x、React、InertiaJS、Vite

前提として

今回は他のユーザーとやりとりをするチャットをつくりますので、ログイン機能がインストールされていることが前提になります。

もしまだの方は以下ページを参考にして先にログイン機能をインストールしておいてください。

📝参考ページ: 【Laravel】Vite + Inertia + React でログイン機能をインストールする

Ably に登録する

まずは AblyPusher互換)のブロードキャスト・サービスに登録します。

※ ちなみに、Ablyの料金は こちら から確認できます。
(無料枠は、2022.08.31 現在で「600万/月」メッセージ😲❗です。個人開発ぐらいだとほぼ使いきれませんね😊 )

まずトップページ右上にある「Sign up free」ボタンをクリック。

すると、ユーザー登録フォームが表示されるので、必要事項を入力して「Get your free account」ボタンをクリック。

そして、ページが移動すると「どんなことに Ably を使うの?」と聞かれるので、今回は「Real-time chat system.」と入力し「Complete sign up」ボタンをクリックします。(ここ、省略できませんでした…😅)

これでアカウント作成は完了です。

では、次にプロジェクト(アプリ)の登録です。
ページ右側に「+ Create New App」というボタンがあるのでこれをクリック。

以下のようなポップアップが表示されるので、Nameに「Real-time chat」と入力し、「Create app」ボタンをクリックします。

すると、プロジェクト(アプリ)が作成され「プライベート API キー」が表示されるので、これをLaravel.envへ登録しておきましょう。

なお、少し説明が難しいのですが、Ablyのプライベート API キーは以下のような文字列で構成されています。

プライベートキー = (公開部分の文字列)+ (それ以降の文字列)

そして、「公開部分」と「それ以降」はコロン:によって繋がれています。
例えば、以下のような文字列です。

P_12345:67890

そして、この場合公開部分はコロンより前の部分「P_12345」になります。
これを踏まえて.envへ登録をします。

.env

ABLY_KEY=(プライベートAPIキー全体)
VITE_ABLY_PUBLIC_KEY="(公開部分のAPIキー)"

※ 2行目は、Vite側へ送信するためのものです。

※ ちなみにこのキーは全ての権限が許可されたルートキーです。今回はテストなので使っていますが、もし権限周りが心配な場合はご自身でキーを作ってください。

また、ついでなので同じく.env内にあるBROADCAST_DRIVERを「ably」へ変更しておきましょう。

BROADCAST_DRIVER=ably

Pusher 互換にする

AblyPusherと互換性がありますが、初期状態では有効になっていません。
そのため、この互換性をもたせる設定変更をします。

まず、API キーを取得したページ上部にある「Settings」リンクをクリックします。

そして、ページ中程にある「Protocol Adapter Settings」の中にある「Pusher protocol support」にチェックを入れ、「Save settings」ボタンをクリックします。

ポップアップ表示が出るので「Create default namespaces」ボタンをクリック。

これでAbly本体での作業は完了です❗

パッケージをインストールする

次に、必要になるパッケージをインストールしておきましょう。
以下のコマンドを実行してください。

composer require ably/ably-php
npm install --save-dev laravel-echo pusher-js

※ ちなみに、(これは公式ドキュメントにも書いていますが)なぜnpmパッケージにpusher-jsをインストールしているかというと、AblyPusherと互換性があるため、同じパッケージで問題ないからなんですね👍

Laravel Echo を有効にする

続いては、Laravel Echoが使えるように設定していきます。

resources/js/bootstrap.js

// 省略

// Ably
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_ABLY_PUBLIC_KEY,
    wsHost: 'realtime-pusher.ably.io',
    wsPort: 443,
    disableStats: true,
    encrypted: true,
});

Pusherの場合はすでにコードが書かれていてコメントアウトするだけで使えますが、今回はAblyなので上のコードを追加してください。

そして、今回はプライベート通信をしますので、認証ができるようにします。

config/app.php

// 省略

/*
 * Application Service Providers...
 */
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\BroadcastServiceProvider::class, // 👈 ここのコメント化を解除しました
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,

// 省略

routes/channels.php

// 認証

Broadcast::channel('chat-message', function ($user) {

    return (auth()->check() === true); // ログイン中ならOK

});

※ なお、chat-messageはブロードキャストで通信する際の名前です。もちろん自由に変更できますが、他の場所に書かれているものも全て変更してください。

これでLaravel Echoが有効になりました❗

ルートをつくる

次にチャット用のルートをつくっていきます。

routes/web.php

use App\Http\Controllers\ChatController;

// 省略

Route::prefix('chat')->middleware('auth')->controller(ChatController::class)->group(function(){

    Route::get('/', 'index')->name('chat.index');
    Route::get('/list', 'list')->name('chat.list');
    Route::post('/', 'store')->name('chat.store');

});

なお、各ルートの内容は次のとおりです。

  • index: ブラウザで表示される部分
  • list: Ajax を通してチャットデータを取得する部分(※)
  • store: チャットメッセージを新規追加する部分

※ なお、Laravelからデータ取得することもできますが、入力した内容が失われないようAjaxを通してデータ取得しています。(ページリロードがなくなるため)

モデル&マイグレーションをつくる

そして、モデル&マイグレーションです。
以下のコマンドを実行してください。

php artisan make:model ChatMessage -m

すると、モデルとマイグレーションのファイル2つが作成されるので中身をそれぞれ次のように変更します。

app/Models/ChatMessage.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ChatMessage extends Model
{
    use HasFactory;

    // Relationship
    public function user()
    {
        return $this->belongsTo(User::class, 'user_id', 'id');
    }
}

database/migrations/****_**_**_******_create_chat_messages_table.php

// 省略

public function up()
{
    Schema::create('chat_messages', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->text('message')->comment('メッセージ');
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
    });
}

// 省略

では、この状態でマイグレーションを実行しましょう。

php artisan migrate

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

これでDBまわりはOKです❗

API Resource をつくる

なお、Ajaxでデータ取得する際に気をつけておかないといけないのが「外に出してはいけないデータ」です。

例えば、今回のケースで言うとリレーションシップでユーザー情報も一緒に取得しますが、そうなるとユーザーのメールアドレスが関係のない人に送信されることになります。

そして、これはAPI Resourceをつくることで予防すると便利です。
では、以下のコマンドでAPI Resourceをつくりましょう。

php artisan make:resource ChatResource

するとファイルが作成されるので中身を次のように変更します。

app/Http/Resources/ChatResource.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ChatResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'message' => $this->message,
            'created_at' => $this->created_at,
            'user' => [
                'name' => $this->user->name,
            ]
        ];
    }
}

これで、ChatResourceを通したデータ提供にはユーザーのメールアドレスやIDは含まれないようになりました❗

ブロードキャスト実行用のイベントをつくる

続いてLaravel側からチャットメッセージをAblyへ伝えるイベントをつくります。以下のコマンドを実行してください。

php artisan make:event ChatMessageCreated

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

app/Events/ChatMessageCreated.php

<?php

namespace App\Events;

use App\Http\Resources\ChatResource;
use App\Models\ChatMessage;
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 ChatMessageCreated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    private $chat_message;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(ChatMessage $chat_message)
    {
        $this->chat_message = $chat_message;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('chat-message');
    }

    /**
     * Get the data to broadcast.
     *
     * @return array
     */
    public function broadcastWith()
    {
        return [
            'chat_message' => new ChatResource($this->chat_message),
        ];
    }
}

※ ちなみに、今回はサンプルなのでbroadcastWith()をつけていますが、実際のところ不要です。

コントローラーをつくる

そして、コントローラーです。
以下のコマンドを実行してください。

php artisan make:controller ChatController

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

app/Http/Controllers/ChatController.php

<?php

namespace App\Http\Controllers;

use App\Events\ChatMessageCreated;
use App\Http\Resources\ChatResource;
use App\Models\ChatMessage;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ChatController extends Controller
{
    public function index()
    {
        return Inertia::render('Chat/Index');
    }

    public function list()
    {
        $chat_messages = ChatMessage::with('user')
            ->limit(10) // 10件だけ
            ->latest()  // 新着順
            ->get();

        return ChatResource::collection($chat_messages);
    }

    public function store(Request $request)
    {
        // 注: バリデーションは省略してます

        $chat_message = new ChatMessage();
        $chat_message->user_id = auth()->id();
        $chat_message->message = $request->message;
        $chat_message->save();

        ChatMessageCreated::dispatch($chat_message); // ブロードキャストを実行

        return to_route('chat.index'); // リダイレクト
    }
}

ビューをつくる

では、ブラウザで実際に表示される部分の「ビュー」をつくっていきましょう。

resources/js/Pages/Chat/Index.jsx

import {useState, useEffect} from "react";
import {Inertia} from "@inertiajs/inertia";
import ChatMessageHeader from "@/Components/ChatMessageHeader";
import ChatMessageFooter from "@/Components/ChatMessageFooter";

export default function Index(props) {

    // Data
    const userName = props.auth.user.name;
    const [chatMessages, setChatMessages] = useState([]);
    const [chatMessage, setChatMessage] = useState('');

    // Methods
    const handleMessageChange = e => { // メッセージ入力したとき

        const message = e.target.value;
        setChatMessage(message);

    };
    const handlerSubmit = () => { // 送信したとき

        const url = route('chat.store');
        const data = { message: chatMessage };

        Inertia.post(url, data, {
            onSuccess() {

                setChatMessage(''); // 成功したらメッセージをリセット

            }
        });

    };
    const getChatMessages = () => { // チャットメッセージを取得する

        axios.get(route('chat.list'))
            .then(response => {

                const chatMessages = response.data;
                setChatMessages(chatMessages);

            });

    };

    // Effects
    useEffect(() => { // ページを読み込んだ時

        getChatMessages();

        // ブロードキャスト受信
        Echo.private('chat-message')
            .listen('ChatMessageCreated', e => {

                console.log(e);
                getChatMessages(); // ブロードキャスト通知が来たら再読込みする

            });

    }, []);

    return (
        <div className="p-5">
            <h1 className="mb-2 font-bold">
                Laravel + React + Ably でチャット機能
            </h1>

            <div className="p-1">
                <small>
                    <strong>{userName}</strong> さんでログイン中
                </small>
            </div>

            {/* メッセージ部分 */}
            <div className="p-4 bg-gray-100">
                {chatMessages.length > 0 && chatMessages.map(chatMessage => (
                    <div key={chatMessage.id} className="bg-white border mb-2 p-3 rounded">
                        <ChatMessageHeader name={chatMessage.user.name} />
                        <div className="whitespace-pre mt-2">{chatMessage.message}</div>
                        <ChatMessageFooter chatMessage={chatMessage}></ChatMessageFooter>
                    </div>
                ))}
                {chatMessages.length === 0 && (
                    <div className="text-center">
                        <div className="text-gray-500">まだメッセージはありません</div>
                    </div>
                )}
            </div>

            {/* フォーム部分 */}
            <div className="py-3">
                <small>&#x1F4AC; チャットへ投稿</small>
                <textarea
                    rows="4"
                    className="block p-2.5 w-full text-sm text-gray-900 border border-gray-400 rounded mb-3"
                    value={chatMessage}
                    onChange={e => handleMessageChange(e)} autoFocus />
                <button
                    type="button"
                    className="px-4 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg"
                    onClick={handlerSubmit}>送信する</button>
            </div>
        </div>
    );
}

ちょっとコードが長いですが、やっているのは、以下2つだけです。

  • チャットデータを Ajax で取得して表示
  • 新規メッセージを送信(して保存)

なお、新しいチャットメッセージが登録されたときAblyからの通知が来るのがEcho.private('chat-message').listen( ... )の部分になります。

コンポーネントをつくる

では、先ほどのビューで使った2つのコンポーネントがまだ用意できていないのでつくっていきます。(アトミックデザインの場合はもっと細かくなると思いますがテストなのでご容赦くださいm(_ _)m)

チャット・メッセージのヘッダー(ChatMessageHeader)

resources/js/Components/ChatMessageHeader.jsx

export default function ChatMessageHeader(props) {

    const name = props.name;
    const firstNameCharacter = name.charAt(0);

    return (
        <>
            <span className="bg-red-400 text-red-50 rounded-full px-1.5 py-1 text-xs mr-1 font-bold">
                {firstNameCharacter}
            </span>
            <small>{name} さん</small>
        </>

    );

};

チャット・メッセージのフッター(ChatMessageFooter)

resources/js/Components/ChatMessageFooter.jsx

export default function ChatMessageHeader(props) {

    const chatMessage = props.chatMessage;
    const dt = new Date(chatMessage.created_at);
    const date = dt.toLocaleString('ja-JP');

    return (
        <>
            <small className="text-gray-400">{date}</small>
        </>

    );

};

テストしてみる

では実際にテストしてみましょう❗(動画は こちら から)

まずは以下のコマンドでViteを起動します。

npm run dev

そして、通常のブラウザとシークレットモードのブラウザを用意します。(別環境として扱われるため)

そして、それぞれ「太郎さん」と「次郎さん」でログイン後「http://******/chat」へアクセスします。(左が太郎さんで、右が次郎さん)

各ログインユーザーの名前が表示されました。

では、この状態で太郎さんの方でメッセージを「テストです from 太郎」と入力し、送信してみます。

すると・・・・・・

はい❗

太郎さんのページだけでなく、次郎さんの方も操作していないのに、送信した内容が追加されました。

成功です😊✨

では、逆に次郎さんの方から「返信です from 次郎」と送り返してみましょう。

どうなったでしょうか・・・・・・

はい❗

今度は太郎さんの方が自動でチャットメッセージが更新されました。

すべて成功です✨😊👍

企業様へのご提案

今回のようにブロードキャスト機能を使うと、リアルタイムで他のユーザーとページを同期をすることができます。

例えば、チャット以外では以下のような機能が考えられます。

  • 決済が完了したことをリアルタイムで管理者ページへ伝える
  • 複数のスタッフで共同編集するページをつくる
  • リアルタイムで各ユーザーのログイン状態を知らせる

などなど。

もしこういった機能をご希望でしたらぜひお問い合わせからご相談くださいませ。お待ちしております。😊✨

おわりに

ということで、今回は「Laravel + React + Ably」でリアルタイム・チャット機能をつくってみました。

いろいろと設定する部分は多かったですが、Laravelが用意してくれている部分のことを考えると、とても簡単に実装ができると言っていいんじゃないでしょうか。

なお、今回途中で変更した部分はチャット・データを取得する部分です。

当初は直接テンプレートへ送る(index()で取得する)形にしていましたが、これだと新規メッセージが投稿されたときにページをリロードする必要があり、そうなると、現在入力中のメッセージが消えてしまうことになります。

もちろんlocalStorageなどを使えばいいのですが、よりシンプルさを考えてAjaxを使うようにしました。

それでは、ぜひ皆さんもチャット機能で楽しんでみてくださいね。

ではでは〜❗

「記事書くためにmailgun登録したら、
放置してて$27.24のお支払い💦
円安…」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ