Laravel+JavaScriptで非同期型のチャットを作ってみる①

テーマ

こんにちは!naoto555です。
今回は、Laravel+JavaScriptで非同期型のチャットを作成する方法をご紹介します。

チャットアプリは、Webアプリケーションに欠かせない機能の1つで、リアルタイムでのコミュニケーションに利用されます。この記事では、LaravelとJavaScriptを使用して、簡単なチャットアプリを作成する手順を説明していきます。

ここで言う非同期とは、一つの処理が完了するまで待たずに、他の処理を同時に実行することができる処理のことです。
今回のLaravel+JavaScriptで非同期型のチャットでは、画面表示の更新を非同期で行うことでメッセージを打っている最中に画面の表示更新が始まり、打っていたメッセージが途中で消えてしまうなどのがないようなチャットアプリを作成していきます。

ボリュームが多いので2回に分けて手順のほう解説していきます。(後編はこちら)
第1回目の今回はコントローラとビューの作成および送信したメッセージをデータベースに登録する処理までを行っていこうと思います。

今回は、動作確認しやすいようにチャットで対話している2人分の画面を左右に並べて表示しています。メッセージを入力し送信ボタンを押すとデータベースへの登録を行い、コントローラからconsoleに’送信完了’とメッセージが返ってくるところまでを作成していきます。今回の内容では、まだリアルタイムにメッセージ画面の更新はされません。(2023.3記載)

開発環境

統合開発環境 aws cloud9
PHP 8.1.17
Laravel 9.52.4
login機能 Laravel/jetstream(livewire)
Database SQlite(version 3.7.17)

解説

①事前準備

「AsynchronousChat」というアプリ名でLaravelプロジェクトを作成します。Laravelの導入の方法に関しては、以前公開した”Laravel開発環境構築~ログイン機能の作成“に詳細なやりかたをまとめていますので、こちらの記事をご参照下さい。
以下の手順で、Tinkerを使用してUserを登録します。User1とUser2を作ってこのふたりにチャット対話をさせていこうと思います。

# Tinkerの起動
php artisan tinker

# User1の登録
$user = new User();
$user->name = 'USER1';
$user->email = 'email1@example.com';
$user->password = $user->password = Hash::make('password');
$user->save();

# User2の登録
$user = new User();
$user->name = 'USER2';
$user->email = 'email2@example.com';
$user->password = $user->password = Hash::make('password');
$user->save();

# Tinkerの終了
quit

 

②Chatモデルとテーブル、コントローラの作成

まず、以下のコマンドを打ち、ChatモデルとChatテーブルを作成していきます。

# モデル、テーブル、コントローラを一気に作成
php artisan make:model Chat --all

③テーブルの編集

作成されたテーブルを編集しマイグレーションしていきます。

database/migrations/XXXX_XX_XX_XXXXXX_create_chats_table.php

public function up()
{
    Schema::create('chats', function (Blueprint $table) {
        $table->id();
        $table->bigInteger('send')->unsigned();    //送信ユーザーのID
        $table->bigInteger('recieve')->unsigned(); //受信ユーザーのID
        $table->text('message');                   //メッセージ本文
        $table->timestamps();
    });
}

 

ターミナルに以下のコマンドを打って、マイグレーションの実行

php artisan migrate

④コントローラの編集

続いて、コントローラーをの中身を編集していきます。

app/Http/Controllers/ChatController.php

//use App\Http\Requests\StoreChatRequest;
//use App\Http\Requests\UpdateChatRequest;
use App\Models\Chat;
use App\Models\User;                   //追加
use Illuminate\Http\Request;           //追加

//------------------------------中略------------------------------

public function store(Request $request)
{
    $chat = new Chat();

    $chat->send = $request->input('send');
    $chat->recieve = $request->input('recieve');
    $chat->message = $request->input('message');

    $chat->save();

    $param = [
        'done' => '送信完了', 
    ];

    return response()->json($param);
}

public function showchat()
{
    $messages = Chat::all();
    $user1 = User::find(1);     //user1の情報取得
    $user2 = User::find(2);     //user2の情報取得
    return view('chats.showchat', compact('messages'));
}

 

上記storeの部分の処理に関して、簡単に説明します。
このコードは、HTTP POSTリクエストを受け取り、その内容をデータベースに保存します。そして、処理が完了した後にJSON形式でレスポンスを返します。

まず、$requestオブジェクトを使用してPOSTされたデータを取得しています。
$request->input(‘send’)はPOSTされたデータの中からsendという名前のフィールドを取得することを意味しています。次にChatモデルの新しいインスタンスを作成し、POSTされたデータを使用してデータベースに取得します。

この場合、doneという名前のフィールドに「送信完了」という文字列を設定しています。最後に、response()->json()関数を使用して、$param変数をJSON形式で返しています。これにより、JavaScriptなどのフロントエンド側で、レスポンスとして返されたJSONデータを簡単に処理できるようになります。

 

⑤ルーティングの設定

web.phpを編集してURIを設定していきます。

 

routes/web.php

use App\Http\Controllers\ChatController;

//----------------------------------------中略----------------------------------------

Route::get('/showchat', [ChatController::class, 'showchat'])->name(chat.showchat);

 

⑥ビューの作成

以下のコマンドを打ってビューを作成し、作成されたビューを編集していきます。

#フォルダの作成
mkdir resources/views/chats

#ビューファイルの作成
touch resources/views/chats/showchat.blade.php

resources/views/chats/showchat.blade.php

<!DOCTYPE html>
<html>
    <head>
        <link href="{{ secure_asset('css/styles.css') }}" rel="stylesheet">
        <!-- CSRF Token -->
        <meta name="csrf-token" content="{{ csrf_token() }}">
    </head>

    <body>
        <div class="content">
            <div class="chat_content" id="chat_content1">
                <div class="chat_content">
                    <h3>User1</h3>
                    @foreach($messages as $message)
                        @if($message->send == $user1->id)
                            <div class="my_message">
                                <div class="chatting">
                                    <p class="message">{{ $message->message }}</p>
                                    <p class="date">{{ $message->created_at->format('Y-m-d H:i') }}</p>
                                </div>
                            </div>
                        @else
                            <div class="others_message">
                                <div class="chatting">
                                    <p class="message">{{ $message->message }}</p>
                                    <p class="date">{{ $message->created_at->format('Y-m-d H:i') }}</p>
                                    <input class="message_id1" style="display:none;" value={{ $message->id }}>
                                </div>
                            </div>
                        @endif
                    @endforeach
                </div>
                <div class="send_message" id="send_message1">
                    <form id="send_message_form1">
                         <input name="send" type="hidden" value="{{ $user1->id }}">
                         <input name="recieve" type="hidden" value="{{ $user2->id }}">
                         <textarea name="message" type="textarea" class="input_message"></textarea>
                         <button type="button" class="send_button" onclick="new_message1()">送信</button>
                    </form>
                </div>
            </div>

            <div class="main_chat">
                <div class="chat_content" id="chat_content2">
                    <h3>User2</h3>
                    @foreach($messages as $message)
                        @if($message->send == $user2->id)
                            <div class="my_message">
                                <div class="chatting">
                                    <p class="message">{{ $message->message }}</p>
                                    <p class="date">{{ $message->created_at->format('Y-m-d H:i') }}</p>
                                </div>
                            </div>
                        @else
                            <div class="others_message">
                                <div class="chatting">
                                    <p class="message">{{ $message->message }}</p>
                                    <p class="date">{{ $message->created_at->format('Y-m-d H:i') }}</p>
                                    <input class="message_id2" style="display:none;" value={{ $message->id }}>
                                </div>
                            </div>
                        @endif
                    @endforeach
                </div>
                <div class="send_message" id="send_message2">
                    <form id="send_message_form2">
                        <input name="send" type="hidden" value="{{ $user2->id }}">
                        <input name="recieve" type="hidden" value="{{ $user1->id }}">
                        <textarea name="message" type="textarea" class="input_message"></textarea>
                        <button type="button" class="send_button" onclick="new_message2()">送信</button>
                    </form>
                </div>
            </div>
        </div>

        <script type="text/javascript">
            //ここにJavaScriptを記載
        </script>
    </body>
</html>

 

⑥CSSファイルの作成

以下のコマンドをターミナルで打ち、CSSを作成します。

# publicフォルダの配下にcssフォルダを作成
mkdir public/css

# cssフォルダの配下にstyle.cssファイルを作成
touch public/css/styles.css

作成したスタイルシートを編集していく。

public/css/styles.css

.content {
    display: flex;
    justify-content: space-between;
    margin: 50px auto;
    width: 80%;
}

.main_chat {
    width: 100%;
    height: 80vh;
    margin: 0 20px;
    border-radius: 10px;
    padding: 0 0 0 10px;
    box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
    background-color: #f2f2f2;
}

.chat_content {
    height: 75%;
    overflow: scroll;
}

.chat_content::-webkit-scrollbar {
    width: 5px;
    height: 5px;
}

.chat_content::-webkit-scrollbar-thumb {
    background: rgba(0, 0, 0, 0.5);
}

.chat_content::-webkit-scrollbar-track {
    background: rgba(0, 0, 0, 0.1);
}

.my_message {
    width: 100%;
    padding-top: 20px;
    text-align: right;
}

.my_message .chatting {
    display: inline-block;
    position: relative;
    margin: 0 25px 30px 0;
    padding: 0 20px;
    max-width: 80%;
    min-width: 50%;
    border-radius: 12px;
    background-color:#e0ffe0;
    text-align: left;
}

.my_message .chatting:before {
    content: "";
    display: inline-block;
    position: absolute;
    top: 15px;
    right: -30px;
    border: 15px solid transparent;
    border-left: 25px solid #e0ffe0;
}

.message {
    white-space: pre;
}

.send_user, .date {
    margin: 0 20px 0 0;
    color: #c0c0c0;
    font-size: 13px;
}

.date {
    margin-top: -15px;
}

.others_message {
    width: 100%;
    padding-top: 20px;
    text-align: left;
}

.others_message .chatting {
    display: inline-block;
    position: relative;
    margin: 0 0 30px 15px;
    padding: 0 20px;
    max-width: 80%;
    min-width: 50%;
    border-radius: 12px;
    background-color:white;
    text-align: left;
}

.others_message .chatting:after {
    content: "";
    display: inline-block;
    position: absolute;
    top: 15px;
    left: -30px;
    border: 15px solid transparent;
    border-right: 25px solid white;
}

.send_message {
    position: relative;
    width: 100%;
    height: 25%;
    border-top:solid 1px gray;
    overflow-y: scroll;
}

.send_message::-webkit-scrollbar {
    width: 5px;
    height: 5px;
}

.send_message::-webkit-scrollbar-thumb {
    background: rgba(0, 0, 0, 0.5);
}

.send_message::-webkit-scrollbar-track {
    background: rgba(0, 0, 0, 0.1);
}

.input_message {
    width: 98%;
    height: 18vh;
    margin: 0 auto;
}

.send_message .send_button {
    position: absolute;
    bottom: 20px;
    right: 20px;
    background-color: lightblue;
    color: white;
}

以下のコードでは吹き出しの三角形の部分を作っています。

.my_message .chatting:before {
    content: ""; 
    display: inline-block;
    position: absolute;
    top: 15px;
    right: -30px;
    border: 15px solid transparent;
    border-left: 25px solid #e0ffe0;
}

.chatting::befor“は、.chattingクラスに対して、疑似要素を追加するように指定しています。疑似要素とは存在しない架空の要素のことで::beforeや::afterなどで疑似要素を追加する方法があります。なお、contentプロパティの値には、必ず何かしらの値を指定する必要があるため、ここでは”content: “”;“にて空の文字列を指定しています。
::beforeと::afterの違いに関しては下図のように要素の手前側に疑似要素が配置されるのがbefore,要素の奥側に疑似要素が配置されるのがafterです。


この疑似要素を使い、”border: 15px solid transparent;“と”border-left: 25px solid #e0ffe0;“で吹き出しの三角形を作成しています。
まず、”border: 15px solid transparent;“で15px平方の透明な正方形を作成します。このとき疑似要素はこの正方形の中心が疑似用になります。

次に、”border-left: 25px solid #e0ffe0;“で疑似要素の左側25pxのところに線を引き、#e0ffe0で塗りつぶすことで三角形を作って言います。
最初に設定した、透明な正方形の15pxが完成した三角形の底辺になっています。

 

少し複雑で分かりにくいかもしれませんが、吹き出しの三角形の部分作るためにいくつかの技巧を駆使する必要があります。他にも”clip-path”プロパティを使用して、ポリゴンを定義して吹き出しを作る方法などもあります。

⑦JavaScript部分の作成

showchat.blade.phpにの<script type=”text/javascript”>の中に、コントローラーにフォームデータを送信する部分と、コントローラーから送信されたJSONを受け取る部分をJavaScriptで作成していきます。

resources/views/chats/showchat.blade.php(JavaScript部)

<script type="text/javascript">
    function new_message1() {
        var form = document.getElementById("send_message_form1");

        var formData = new FormData(form);
        formData.append("_token", document.querySelector('meta[name="csrf-token"]').getAttribute('content'));

        fetch('{{ route('chats.store') }}', {
            method: 'POST',
            headers: {'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')},
            body: formData
        })
        .then(response => response.json())
        .then(data => {
            //テキストエリアのクリア
            var textarea = document.querySelector("#send_message_form1 > textarea")
            textarea.value = "";

            //コンソールに「送信完了」のメッセージを表示
            console.log(data.done);
        })
        .catch(error => {
            console.error('Error:', error);
        });
    }

    function new_message2() {
        var form = document.getElementById("send_message_form2");

        var formData = new FormData(form);
        formData.append("_token", document.querySelector('meta[name="csrf-token"]').getAttribute('content'));

        fetch('{{ route('chats.store') }}', {
            method: 'POST',
            headers: {'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')},
            body: formData
        })
        .then(response => response.json())
        .then(data => {
            //テキストエリアのクリア
            var textarea = document.querySelector("#send_message_form2 > textarea")
            textarea.value = "";

            //コンソールに「送信完了」のメッセージを表示
            console.log(data.done);
        })
        .catch(error => {
            console.error('Error:', error);
        });
    }
</script>

 

上記コードではfetchというメソッドを使って、コントローラーにフォームの内容をPOST送信しています。

Laravelでは、リクエストに含まれる特別なトークンを検証することで、クロスサイトリクエストフォージェリ(CSRF)攻撃を防止します。CSRF攻撃とは、自分のサイトとは関係のない別のサイトから、誤ってフォーム送信されてしまったり、悪意を持ったユーザーによって別サイトからフォーム送信されてしまうことです。

トークンとは、ランダムな文字列で生成された、フォーム送信元を特定するための秘密の値です。このトークンを検証して問題がなければ、フォーム送信が受理されます。

formData.append(“_token”, document.querySelector(‘meta[name=”csrf-token”]’).getAttribute(‘content’));“と”fheaders: {‘X-CSRF-TOKEN’: document.querySelector(‘meta[name=”csrf-token”]’).getAttribute(‘content’)},“の部分は、フォームデータに含まれるトークンと、fetchメソッドのヘッダーに含まれるトークンの2つを設定しているところです。
なお、このコードを有効にするためにはビューもしくはレイアウトファイルのヘッドに"<meta name="csrf-token" content="{{ csrf_token() }}">"を書き忘れないようにご注意下さい。

また、フォームデータを送信した後に”.then(response => response.json())“と”.then(data => {  “でコントローラから渡された$paramを受け取り、JavaScript上の変数名「data」として扱っています。

完成図

上記手順にて、下図のような画面が作成されました。引き続き「Laravel+JavaScriptで非同期型のでチャットを作ってみる②」にて非同期でリアルタイムにチャット画面を更新する部分を作成していきます。