LaravelとReactでWebSocketを使ったブロードキャストアプリを作ってみる

# はじめに
LaravelにはWebSocketを介して、ブロードキャストする仕組みが実装されています。
WebSocketを使ったアプリケーションを作成すると、サーバーアプリケーション側からクライアントアプリケーションに対してPush型通信ができます。

Push型通信ができるアプリケーションにすることで、Aさんが更新したデータをBさんがリアルタイムに参照することができます。

この記事では、サーバーサイドアプリケーションをLaravelで開発し、WebSocketを使ってクライアントアプリケーションと通信する開発手法をご紹介します。

なお、クライアント側のフレームワークは最近の流行りにのって、Reactで作ってみました。

### 対象読者
* Laravelでブロードキャストするアプリを作りたい方
* WebSocketを使ったサーバーサイドアプリケーションを作りたい方
* PHP、Reactで開発したことがある方

### 開発環境
* Laravel v9.2
* React v18.2
* MySQL v8
* PHP v8.0.3
* Docker

# この記事のゴール
* Laravel9で、WebSocketサーバーを作成し、クライアント側をReactで作成する
* サーバーとクライアント側でWebSocket通信したアプリケーションを完成させる

まずは、WebSocketサーバーとしてLaravelアプリケーションを完成させていきます。

# Laravelでサポートしているドライバ
LaravelではWebSocketを使った通信をするためのドライバを２つ提供しています。

* Pusher
* Ably

この２つは商用ブロードキャストプロバイダを必要とします。

一方、コミュニティ主導のパッケージである以下の2つを使うと、プロバイダアカウントを必要とせずにWebSocketアプリケーションが開発できます。

* laravel-websockets
* soketi

一番お手軽なのは「Pusherドライバ」を使った開発です。

今回はお手軽にWebSocketアプリケーションが作れる「Pusher」を使ってみます。
いずれ、「laravel-websockets」も使ってみたいと思いますが、それは次回のお楽しみに。

# WebSocketを使ったLarabelプロジェクトの準備
まずはLaravelプロジェクトを作成しましょう。
```
composer create-project laravel/laravel=9.* websocket-app
```

プロジェクトフォルダ直下でpusher-php-serverパッケージのインストールを行います。
```
composer require pusher/pusher-php-server
```
これがないと、Pusherドライバが使えず、ブロードキャストすることができません。

# 作成手順
作成していく手順は以下の通りです。

1. Pusherアカウントの作成
1. アカウントのApp Keyなどを.envファイルに設定
1. モデルクラスの作成と、マイグレーション
1. イベントクラスの作成
1. コントーラークラスの作成とルーティング
1. フロント側アプリケーションの作成

# Pusherアカウントの作成
Pusherは無料アカウントで登録できます。
無料ながら以下のようなサービスが利用できます。

* 最大100接続可能
* 作成チャンネル数は無制限
* 1日に200,000メッセージまで可能
* SSLで暗号化可能

なにげに至れり尽くせりです。

###  アカウント作成
下記のページからアカウント作成します。
メールアドレスと、パスワードだけ入力します。
https://dashboard.pusher.com/accounts/sign_up

入力したメールアドレスあてに認証用のURLが送られてきます。
![スクリーンショット 2022-12-07 18.06.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/98c41fa0-8dbc-6202-04af-2474cc7b925c.png)

リンクをクリックするとチャンネル作成画面が開きます。
![スクリーンショット 2022-12-07 18.06.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/3b5cda3c-56e3-de9d-39e6-f89ae0c9da2d.png)

ChannelsのGet startedボタンで、チャンネル作成を行います。
![スクリーンショット 2022-12-07 18.07.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/60f649ca-1902-2714-7be2-98f3462fb927.png)

your appの項目はデフォルトのままで構いません。
clusterはTokyoにしておくといいでしょう。
Front end、Back endで利用する言語や、フレームワークが選べますが、デフォルトのまま進んでも問題ありません。

Create appボタンを押して作成します。

作成したら、メニューからAppKeysのリンクをクリックします。
![スクリーンショット 2022-12-07 18.12.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/40d508f1-e28e-ee67-6bf6-6530811e2525.png)
app_idなどの設定情報が見れます。
この情報をLaravelの環境設定ファイル.envに記述します。

# .envファイルに設定

.envを編集します。
変更箇所はBROADCAST_DRIVER、PUSHER_APP_ID、PUSHER_APP_KEY、PUSHER_APP_SECRET、PUSHER_APP_CLUSTERの５箇所です。

```text:.env
.
.
BROADCAST_DRIVER=pusher
.
.
PUSHER_APP_ID=app_idを指定 
PUSHER_APP_KEY=keyを指定
PUSHER_APP_SECRET=secretを指定
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=clusterを指定
```

これでPushrに接続する設定ができました。

# モデルクラスの作成と、マイグレーション

モデルクラスを作らずとも、Pusherから受け取るデータの纏まりをクラスで定義するやりかたもあります。

今回は登録されたDBの商品データをブロードキャストで受け取って、リアルタイムに一覧に反映されるようにします。
Itemモデルクラスを作成します。

```
php artisan make:model Item -m
```

作成したら、idカラムだけ$guardedで、createメソッドで指定できないようにしておきます。
これを忘れると、DBに登録するとき、createメソッドで登録できません。（ハマりました。）


```php:/app/Models/Items.php
namespace App\Models;

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

class Item extends Model
{
    use HasFactory;
    protected $guarded = ['id'];
}
```

マイグレーションファイルを編集して、カラムの定義を行います。

```php:/database/migrations/****_**_**_******_create_items_table.php.php
public function up()
{
    Schema::create('items', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->integer('price');
        $table->timestamps();
    });
}
```

マイグレーションして、DBにテーブルを作成しておきましょう。
```
php artisan migrate
```

# イベントクラスの作成

Pusherへイベントを発行するためのクラスを作成します。
```
php artisan make:event ItemCreated
```
イベントクラスには必ずShouldBroadcastインタフェースを実装しておきます。
実装しておかないと、Pusherへのチャンネルが開通せず、イベントを通知できません。
（これを忘れてハマりました）
```php:
class ItemCreated implements ShouldBroadcast
```

イベントから渡されてくるデータはpublicメンバーでないと扱えません。
そのため、コンストラクタでItemモデルクラスのインスタンスを、$itemメンバーに渡します。

```php:
    public $item;

    public function __construct(Item $item)
    {
        $this->item = $item;
    }
```

ShouldBroadcastインタフェースを実装すると、broadcastOnメソッドをオーバーライドしなければいけません。メソッドはこのイベントクラスのイベントをどのチャンネルにブロードキャストするのかを設定します。
ブロードキャストの配信はChannelクラスをインスタンス化することで行えます。引数でチャンネル名を指定します。

チャンネル名は自由に決めて構いません。今回は'items-channel'にしています。
```php:
public function broadcastOn()
    {
        return new Channel('items-channel');
    }
```

完成形です。
```php:/app/Events/ItemCreated.php
namespace App\Events;

use App\Models\Item;
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 ItemCreated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $item;

    public function __construct(Item $item)
    {
        $this->item = $item;
    }

    public function broadcastOn()
    {
        return new Channel('items-channel');
    }
}
```


# コントーラークラスの作成とルーティング

初期画面のアクションメソッドと、、Ajaxで通信するためのアクションメソッドを定義していきます。
まずはコントローラーを作成しましょう。
```
php artisan make:controller ItemAdminController
```

Ajax用のコントローラーと分けるのも良いのですが、今回は１つのコントローラーで定義しておきます。
今回は商品を登録すると、Pusherにイベントを発行し、イベントが起きたら監視しているクライアント側で取得し、一覧を更新するようにします。

イベントを発行してくれるのがdispatchメソッドです。
```php:
ItemCreated::dispatch($message);
```
dispatchメソッドに渡した引数が、ItemCreatedを通して、Pusherに配信されます。
ちなみにLaravel8からdispatchメソッドを使うようになりました。
それ以前はevent関数にイベントクラスのインスタンスを渡していました。
```php:Laravel8以前
event(new ItemCreated($message));
```

完成形です。
```php:/app/Http/Controllers/ItemAdminController.php
namespace App\Http\Controllers;

use App\Events\ItemCreated;
use App\Models\Items;
use Illuminate\Http\Request;

class ItemAdminController extends Controller
{
    public function index() {
        return view('itemadmin.index');
    }

    public function list() {
        return Items::orderBy('id', 'desc')->get();
    }

    public function create(Request $request) {

        $item = Item::create([
            'name' => $request->name,
            'price' => $request->price
        ]);

        ItemCreated::dispatch($item);

        return $item;
    }
}
```

コントローラが完成したので、ルーティングしておきましょう。
Laravel9から、コントローラーにまとめてルーティングできる記述が可能になりました。
```php:/routes/web.php
Route::controller(ItemAdminController::class)->group(function () {
    Route::get('/', 'index');
    Route::get('/api/items', 'list');
    Route::post('/api/create', 'create');
});
```

この後、フロント側の開発に着手します。
その前にいくつか準備しておくことがあります。

# フロント側アプリケーションの作成

## Viteビルドツールを使ったクライアント側開発の準備

Laravel9から、フロントエンド開発のビルドツールがWebpackからViteに変更されました。
せっかくなのでViteを使ってReactでクライアント側を開発してみようと思います。

npmを使って開発するので、Node.jsをインストールしておきましょう。
ちなみに今回はDockerで作成した開発環境を利用しています。

![スクリーンショット 2022-12-06 14.43.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/7529c54f-6360-1c21-9241-70456e7937db.png)

Dockerコンテナにnginxサーバーと、PHP環境、MySQLを繋げた環境にしています。
PHP環境のイメージ内で必要なパッケージなどを揃えていきます。

今回はcomposer環境は準備済みの想定で進めていきます。
まずはNode.jsをインストールしておきます。
Node.jsのインストール方法はいろいろありますが、setup.shを使ったインストールを採用します。
（npmも一緒にインストールしてくれるので、手間が省けます。）

```
curl -sL https://deb.nodesource.com/setup_14.x -o nodesource_setup.sh
```

nodesource_setup.shファイルが作成されるので、シェルを実行します。

```
bash nodesource_setup.sh
```

実行したら、aptコマンドでインストールします。

```
apt install nodejs
```

今回は最新版（この記事の2022/12/06時点））でインストールしてあります。

```
>node --version
v14.21.1
```

```
>npm --version
9.1.3
```

Vite用のReactビルド用パッケージ、React本体、ReactDomなど必要なパッケージをインストールしていきます。

```
npm install @vitejs/plugin-react
```

プロジェクトフォルダ直下のvite.config.jsを編集して、Reactでビルドできるように設定します。

```javascript:vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from "@vitejs/plugin-react";　// <=ここを追加

export default defineConfig({
    plugins: [
        react(), // <=ここを追加
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.jsx'],
            refresh: true,
        }),
    ],
});
```

inputプロパティの起点プログラムファイル名が'resources/js/app.js'になっていますが、これを.jsxに変更しておきます。変更しないと、Viteによるビルドが通りません。
ReactはJSXという拡張言語でHTMLを記述します。そのため、ファイル拡張子を.jsxにしておかないと、Parse Errorになってしまいます。

React、ReactDOM、ReactRouterをインストールします。

```
npm install react react-dom react-router-dom
```

MUIもインストールしておきます。
```
npm install @mui/material @emotion/react @emotion/styled
```

Viteを使ってビルドする時は下記のコマンドを実行します。
```
npm run build
```

ビルドされたファイルはLaravelプロジェクトの/public/build/assates配下に置かれます。
ビルドしたファイルを読み込んでもらうためには以下の２行をテンプレートHTMLに追加します。

```php:
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/app.jsx'])
```

これで、Reactで開発したコードがLaravelアプリケーションから実行されます。

## bootstrap.jsのPusher接続設定

Pusherとのつながるために必要なLaravelEchoライブラリをインストールしておきます。
LaravelではEchoオブジェクトを使うことで、Pusherと接続できます。

```
npm install laravel-echo pusher-js
```

bootstrap.jsではすでにEchoライブラリを使うコードが記述済みで、コメントアウトされています。
コメントアウトを外して、以下のようにしておきましょう。
```javascript:/resources/js/bootstrap.js
import Echo from 'laravel-echo';

import Pusher from 'pusher-js';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
});
```

## React
今回はLaravel側の話がメインなので、Reactの細かい内容は書きません。
ポイントだけ記述しておきます。

### bladeテンプレートを編集

今回はReactMUIも使うので、MUI用のCSSも読み込んでいます。
また、ビルドしたJSファイルを読み込んでもらうための@viteReactRefresh、@viteも記述してあります。
Reactのプログラムが描画される起点となるdiv要素にid属性appを振っています。
```php:/resources/views/itemadmin/index.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel Vite Vue.js 3</title>
        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/>
        <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
        @viteReactRefresh
        @vite(['resources/css/app.css', 'resources/js/app.jsx'])
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>
```

Ajaxで商品データの送信を行うのですが、token認証が必要になります。
今回はちょっと危ないですけど、お試しなので、token認証を外しておきます。
Kernel.phpのVerifyCsrfTokenクラスを利用する箇所をコメントアウトしておきます。
```php:/app/Http/Kernel.php
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            // \App\Http\Middleware\VerifyCsrfToken::class,  <= ここをコメントアウト
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
```

ルートコンポーネントの定義です。
ReactRouterを使って、一覧画面、登録画面に分けています。
ルートパスでは、ItemListコンポーネントが表示されるようにしています。
```react:app.jsx
mport './bootstrap';
import ReactDOM from "react-dom";
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import ItemForm from './components/ItemForm';
import ItemList from './components/ItemList';

function App() {

    return (
        <BrowserRouter>
            <AppBar position="static">
                <Toolbar>
                    <Typography variant="h6">
                        商品管理
                    </Typography>
                </Toolbar>
            </AppBar>
            <div>
                <Link to="/">[商品一覧]</Link>
                <Link to="/create">[商品登録]</Link>
            </div>
            {/* Routesコンポーネントで、指定されたURLに合致する
            コンポーネントにページが切り替わるようにする */}
            <Routes>
                {/* Routeコンポーネントでルーティングの設定を行う */}
                <Route path="/" element={<ItemList />} />
                <Route path="/create" element={<ItemForm />} />
            </Routes>
        </BrowserRouter>
    );
}

ReactDOM.render(<App />, document.getElementById("app"));
```

一覧画面です。ItemListコンポーネントとしています。
ここでEchoオブジェクトを使って、チャンネルを監視します。

```javascript:
Echo.channel('items-channel')
    .listen('ItemCreated', (e) => {
        getItems(); 
    });
```

Echoオブジェクトのchannelメソッドを使って引数のチャンネルを監視します。
監視中に、listenメソッドの第一引数のイベント（Laravelのイベントクラス名）が起きたら、第二引数のコールバック関数を実行します。今回はPusherから'ItemCreated'というイベントが発生したら、WebAPIからデータを取得して一覧を再表示しています。
```react:ItemList.jsx
import { useState, useEffect } from 'react';
import { Paper, List, ListItem } from '@mui/material';


const ItemForm = () => {
    const [items, setItems] = useState([]);

    useEffect(() => {
        const getItems = async () => {
            const res = await fetch('/api/items');
            const data = res.json();
            setItems(data);
        }

        Echo.channel('items-channel')
        .listen('ItemCreated', (e) => {
            getItems(); // 全メッセージを再読込
        });

        getItems();

    }, []);

    return (

        <Paper>
            <List sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}>
                {items.length ? (
                    items.map((item, idx) => {
                        return (
                            <List key={idx}>
                                <ListItem>{item.name}({item.price})</ListItem>
                            </List>
                        )
                    })
                ) : (
                    <div>データなし</div>
                )}
            </List>
        </Paper>

    );
};

export default ItemForm;
```

商品登録画面のコンポーネントです。
bootstrap.jsではaxiosも使える様になっているのですが、普段講義してる時はfetch APIを使ってるので、こちらにしました。
```react:ItemForm.jsx
import { Paper, TextField, Typography, Button } from '@mui/material';
import { styled } from '@mui/system';

const StyledDiv = styled('div')({
    display: 'flex',
    height: '100vh',
    backgroundColor: '#eeeeee',
});
const StyledPaper = styled(Paper)({
    width: '300px',
    height: '200px',
    margin: 'auto',
    padding: '10px',
});
const StyledTextField = styled(TextField)({
    width: '80%'
});
const StyledButton = styled(Button)({
    margin: '15px'
});

const ItemForm = () => {

    const handleSendClick = async () => {
        const name = document.querySelector('#name').value;
        const price = document.querySelector('#price').value;

        const sendData = { name, price };

        const res = await fetch('/api/create', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(sendData)
        })

        const data = await res.json();

        alert(`${data.id}で${data.name}を登録しました。`);
        document.querySelector('#name').value = '';
        document.querySelector('#price').value = '';
    };

    return (
      <StyledDiv>
        <StyledPaper>
          <Typography>商品情報を入力してください。</Typography>
          <StyledTextField label="Name" variant="standard" id="name" />
          <StyledTextField label="Price" variant="standard" id="price" />
          <StyledButton variant="contained" color="primary" onClick={handleSendClick}>
            送信
          </StyledButton>
        </StyledPaper>
      </StyledDiv>
    );
  };

  export default ItemForm;

```

これで完成です。
実行結果の画面です。

|Aさん|Bさん |
|:-----------------|------------------:|
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/48e2a86d-e213-ed42-420d-ee7751ed9f31.png" width=80%> | ![スクリーンショット 2022-12-07 21.18.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/83396562-d194-4854-3eba-c64b8a446f99.png) |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/48e2a86d-e213-ed42-420d-ee7751ed9f31.png" width=80%> | ![スクリーンショット 2022-12-07 21.20.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/947b0b3f-4b41-feec-1cff-9e1c1517ae0e.png)送信します |
| ![スクリーンショット 2022-12-07 21.20.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/8264fbb6-9fce-c3a4-416d-6638957adffd.png) Aさんの一覧画面がリアルタイムに更新されます | ![スクリーンショット 2022-12-07 21.18.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3024247/83396562-d194-4854-3eba-c64b8a446f99.png) |