Friction River Software

  • お問い合わせ

CakePHP5入門【CakePHP5実用編⑦】さまざまな改良

B美

さて今回は、気になった箇所をちょこちょこっと改良していこうか

まずはチャットルームに入室したとき、テーマの名称(チャットの議題)をチャット画面の上のほうに表示しよう

C菜

これは簡単ですね~
「src/Controller」の中にある「ChatController.php」の「index」メソッドにテーマ名を取得する処理を書いて、Viewビュー側に渡すだけです~

もちろん、Viewビュー側(「templates/Chat」の中にある「index.php」)のほうにも、それを表示する処理を書き加えますけど~


A子

同じ「index.php」ファイルだけどさー
CSSの箇所に「」を直接指定してるよね

あれを定数化しよう
(あとで好きな色に変更するために…)


B美

問題は「現在入室している人の名前を表示」し、「退室したらその名前を消す」処理なのよね
チャットルームに一人しかいないのに、延々としゃべり続けるという(間抜けな)事態を回避するためにも…(笑)

まずは「WebSocketServerCommand」クラスに新たなフィールド「$usernames」を追加して、初期化処理を書くよ

class WebSocketServerCommand extends Command implements MessageComponentInterface
{
    protected array $clients = [];
    protected array $rooms = [];
    protected array $usernames = [];

    private $Comments;

    public function initialize(): void
    {
        parent::initialize();

        $this->clients = [];
        $this->rooms = [];
        $this->usernames = [];

        $this->Comments = $this->fetchTable('Comments');
    }

次に「onOpen」メソッドを書き換えるんだけど、ここでは初期化処理を追加しただけね

public function onOpen(ConnectionInterface $conn): void
{
    $conn->roomId = null;
    $conn->username = null;

    $this->clients[$conn->resourceId] = $conn;
}

次は中核となる「onMessage」メソッドなんだけど…
本当の中核処理はprivateのメソッドにまとめて、ここからそれ(「broadcast」と「broadcastUserList」の二つのメソッド)を呼び出しているわ

public function onMessage(ConnectionInterface $from, $msg): void
{
    $data = json_decode($msg, true);

    if (!isset($data['type']) || !isset($data['room_id'])) {
        return;
    }

    $roomId = $data['room_id'];
    $userId = $data['user_id'];

    if ($data['type'] === 'join') {
        $from->roomId = $roomId;
        $from->username = $data['user'];

        if (!isset($this->rooms[$roomId])) {
            $this->rooms[$roomId] = [];
        }
        $this->rooms[$roomId][$from->resourceId] = $from;

        $this->usernames[$from->resourceId] = $data['user'];
        $this->broadcastUserList($roomId);
        $this->broadcast($roomId, [
            'type' => 'system',
            'message' => "{$data['user']} が入室しました。"
        ]);


    } elseif ($data['type'] === 'message' && $from->roomId !== null) {
        $messageData = [
            'room_id' => $roomId,
            'user_id' => $userId,
            'user' => $data['user'],
            'message' => $data['message']
        ];

        //データベースへの格納
        $comment = $this->Comments->newEmptyEntity();
        $comment->content = $data['message'];
        $comment->room_id = $roomId;
        $comment->user_id = $userId;
        $this->Comments->save($comment);

        $this->broadcast($roomId, $messageData);
    }
}

あと、退室時の処理を記述するから、「onClose」メソッドについても書き換えるわね

public function onClose(ConnectionInterface $conn): void
{
    if ($conn->roomId !== null && isset($this->rooms[$conn->roomId][$conn->resourceId])) {
        $roomId = $conn->roomId;
        $username = $this->usernames[$conn->resourceId] ?? 'Unknown';

        unset($this->rooms[$roomId][$conn->resourceId]);
        unset($this->usernames[$conn->resourceId]);

        $this->broadcastUserList($roomId);
        $this->broadcast($roomId, [
            'type' => 'system',
            'message' => "{$username} が退室しました。"
        ]);

    }
    unset($this->clients[$conn->resourceId]);
}

最後に、privateメソッドとして「broadcast」と「broadcastUserList」の二つを追加するよ

private function broadcast(int $roomId, array $message): void
{
    if (!isset($this->rooms[$roomId])) {
        return;
    }

    foreach ($this->rooms[$roomId] as $client) {
        $client->send(json_encode($message));
    }
}

private function broadcastUserList(int $roomId): void
{
    if (!isset($this->rooms[$roomId])) {
        return;
    }

    $userList = array_values(array_map(fn($client) => $client->username, $this->rooms[$roomId]));

    $this->broadcast($roomId, [
        'type' => 'user_list',
        'users' => $userList
    ]);
}





A子

うへぇ、めっちゃ大変じゃん

B美がいなかったら絶対無理だったよ

B美

いや、私も「ChatGPT」から教えてもらったんだけどね(苦笑)

A子

…って、おい!

私の称賛を返せや(苦笑)

B美

いやいや、「ChatGPT」が提示するコードって、(いつも通り)そのままでは動かなかったからね(笑)
(割と試行錯誤してるから…)

まぁ、そんなことはともかくとして、まだ終わりじゃないわよ

Viewビュー側(JavaScriptのコード)を書き換えなきゃいけないからね
(「templates/Chat」の中にある「index.php」)

const ws = new WebSocket("ws://<?= WEBSOCKET_IP ?>:<?= WEBSOCKET_PORT ?>");
const userId = <?= json_encode($userId) ?>;
const roomId = <?= json_encode($roomId) ?>;
const userName = <?= json_encode($userName) ?>;

ws.onopen = function() {
    ws.send(JSON.stringify({ type: "join", room_id: roomId, user_id: userId , user: userName}));
};

ws.onmessage = function(event) {
    const data = JSON.parse(event.data);

    if (data.type === "user_list") {
        updateUserList(data.users);
    } else if (data.type === "system") {
        addSystemMessage(data.message);
    } else {
        addMessage(data.user_id, data.user, data.message);
    }

};

document.getElementById("sendBtn").addEventListener("click", function () {
    const input = document.getElementById("message");
    if (input.value.trim() !== "") {
        ws.send(JSON.stringify({
            type: "message",
            room_id: roomId,
            user_id: userId,
            user: userName,
            message: input.value
        }));
        input.value = "";
    }
});

function addMessage(senderId, user, message) {
    const chatBox = document.getElementById("chat-box");
    const msgDiv = document.createElement("div");

    msgDiv.classList.add("message");
    if (senderId == userId) {
        msgDiv.classList.add("sent"); //自分のメッセージ
    } else {
        msgDiv.classList.add("received"); //他人のメッセージ
    }

    msgDiv.innerHTML = `<p><strong>${user}</strong></p><p>${message}</p>`;
    chatBox.appendChild(msgDiv);

    chatBox.scrollTop = chatBox.scrollHeight;
}

function updateUserList(users) {
    const userList = document.getElementById("user-list");
    userList.innerHTML = users.map(user => `<li>${user}</li>`).join("");
}

function addSystemMessage(message) {
    const chatBox = document.getElementById("chat-box");
    const msgDiv = document.createElement("div");

    msgDiv.classList.add("system-message");
    msgDiv.textContent = message;
    chatBox.appendChild(msgDiv);

    chatBox.scrollTop = chatBox.scrollHeight;
}


document.addEventListener("DOMContentLoaded", function () {
    const messageInput = document.getElementById("message");
    const sendButton = document.getElementById("sendBtn");

    //Enterキーで送信(Shift + Enterで改行)
    messageInput.addEventListener("keydown", function (event) {
        if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault();
            sendButton.click();
        }
    });
});
赤字が追加・変更箇所)

あと、CSSのクラスを一つ追加して、HTML部分にも一行を書き加えるよ

.system-message {
    max-width: 100%;
    padding: 10px;
    margin: 5px;
    border-radius: 10px;
    display: inline-block;
    word-wrap: break-word;
    position: relative;
    background-color: #ffffff;
    color: #ff0000!important;
    text-align: center;
}

<div style="width: 100%;text-align: center;color: <?= THEME_COLOR ?>;">【 <?= $chat_room->theme ?> 】</div>
<br />
<div id="chat-container">
    <div id="user-list" style="background-color: <?= USER_LIST_COLOR ?>;margin: 10px;padding: 10px;"></div>
    <div id="chat-box">
赤字が追加・変更箇所)

ちなみに、このページの冒頭でテーマの色を(greenに)固定していたけど、これもついでに定数化しておこう
(あとで変更しやすいように…)

define("THEME_COLOR", "#006400");    //テーマの文字色
define("USER_LIST_COLOR", "#fffacd");    //ユーザ一覧の背景色

この二行を「config/const.php」に追記する…ってことね








A子

うーん、めっちゃ大変だなー(棒)

あー、あとさ
チャットの入力欄なんだけど、毎回「半角/全角」キーを押して「日本語入力モード」に切り替えるのが面倒くさいんだよね
それって、なんとかならないかな?

それと、「ページネーション」の件数を設定してない気がする
(「ユーザ管理」でも「チャットルーム管理」でも…)

んー、あとは、そうだなー
スマホ(レスポンシブ)対応くらいかな?

B美

入力欄のデフォルト状態を「日本語入力モード」にするのは難しいわね
(できなくはないんだけど、ブラウザに依存する処理になるの)

ただ、「ページネーション」と「レスポンシブ」については、前にもやったから難しくはないでしょう
あなたたちでやってみなさい(復習を兼ねて)

C菜

わかりました~

今回はここまでずっとB美部長頼りでしたからね~

A子

わかったよ
んじゃ、まずは「ページネーション」だね

define("USER_MAX_PAGE", 10);    //ユーザ管理の一覧件数
define("CHAT_MAX_PAGE", 10);    //チャットルームの一覧件数

を「config/const.php」に定義して…
(ついでに「APPLICATION_NAME」「HEAD_MAIN」「HEAD_SUB」「VERSION」も少し書き換えた)

そのあと、「src/Controller」の中にある「UsersController.php」と「ChatRoomsController.php」を修正するよ

$users = $this->paginate($query, ['limit' => USER_MAX_PAGE, 'order' => ['id' => 'asc']]);

上が「UsersController.php」で、下が「ChatRoomsController.php」ね
(どちらも「index」メソッド内)

$chatRooms = $this->paginate($query, ['limit' => CHAT_MAX_PAGE, 'order' => ['created' => 'desc']]);

リストの並び順(order)は適当に設定したけど、これで良いかな?
(【CakePHP5応用編④】を参照)




C菜

ユーザ管理は「id」の昇順で、チャットルームのほうは「created(作成日)」の降順ですね~
良いと思います~

それでは次は「レスポンシブ」対応ですけど~
「bbsapp(画像投稿掲示板)」の「templates/layout」の中にある「default.php」にあるCSSの記述を、「tr」を削除してから「authapp」のほうへコピーしますね~
(具体的には「templates/layout」の中にある「default.php」のheadタグ内です~)

A子

「レスポンシブ」対応を行うべき箇所は、複数あるね

1.ログインページ(「templates/Users」の中にある「login.php」)
2.一人目のユーザを作成するためのページ(「templates/Users」の中にある「first_user.php」)
3.ユーザ管理の一覧ページ(「templates/Users」の中にある「index.php」)
4.チャットルームの一覧ページ(「templates/ChatRooms」の中にある「index.php」)

…ってところかな?
(そのほかのページについては、多分大丈夫っぽい)

C菜

それでは最初は「ログインページ」からです~

レスポンシブ対応自体はメディアクエリを使えば簡単ですけど~
(【コラム⑥】を参照)

「'required' => true」は削除しないとPOST送信ができないですね~

A子

え?
なんで?

C菜

CSSの「display: none;」で隠したとしても、そこに入力用コントロールが存在するのは確かなんですよ~

隠したほうは未入力のままになっちゃいますから、必須入力(required)にはできないってわけです~
(ちなみに「'id' => 'email'」や「'id' => 'password'」についても削除しました…使ってないし、値が重複するので…)


A子

うまくいくか試してみよう

ん?
ログインできないよ?

いや、正確に言えば「narrow」ではログインできるけど、「wide」ではできない…???

B美

name属性が「email」であるものが二つ、同じく「password」であるものが二つ存在するからねぇ

POSTされる値は(自動的に)配列になっちゃうわよ

C菜

あ、チェックボックスやラジオボタンのパターンと同じですね~

うーん、なんとかControllerコントローラー側を修正せずにうまいことできませんかね~?

B美

JavaScriptを使って、非表示側のコントロールのname属性を削除しちゃえば良いのよ
(もちろん、一時的に…だけど)

document.getElementById('login_form').addEventListener('submit', function() {
    const emailInputs = document.getElementsByName('email');
    const passwordInputs = document.getElementsByName('password');

    for (let input of emailInputs) {
        if (input.offsetParent === null) {
            input.removeAttribute('name');
        }
    }
    for (let input of passwordInputs) {
        if (input.offsetParent === null) {
            input.removeAttribute('name');
        }
    }
});

…って感じ

C菜

「templates/Users」の中にある「index.php」の末尾に、scriptタグとして追加してみますね~

A子

おぉ!

「wide」と「narrow」、どっちでもログインできたよ
(下の二つの画面は、最初が「wide」で次が「narrow」ね)


C菜

次は、2番の「first_user.php」を書き換えますよ~

ただし、JavaScriptの部分については、以下のようになりますね~

document.getElementById('login_form').addEventListener('submit', function() {
    const emailInputs = document.getElementsByName('email');
    const passwordInputs = document.getElementsByName('password');
    const handleNameInputs = document.getElementsByName('handle_name');

    for (let input of emailInputs) {
        if (input.offsetParent === null) {
            input.removeAttribute('name');
        }
    }
    for (let input of passwordInputs) {
        if (input.offsetParent === null) {
            input.removeAttribute('name');
        }
    }
    for (let input of handleNameInputs) {
        if (input.offsetParent === null) {
            input.removeAttribute('name');
        }
    }

});
赤字が追加した箇所)



A子

んじゃ、テストしてみよう

ちょっと面倒だけど、以下の手順でデータベースを新しくリニューアルするよ
(まじで面倒だけど…(苦笑))

mysql -u root -p chatdb[Enter]
drop database chatdb;[Enter]
quit[Enter]
mysql -u root -p < chatdb.sql[Enter]

C菜

あ、念のためキャッシュクリアもやっておきますね~

cd html/authapp[Enter]
bin/cake cache clear_all[Enter]

A子

それじゃ、最初のユーザを作成しよう

このテストを「wide」と「narrow」の2パターンやるために、データベースの削除と新規作成(及びキャッシュクリア)をもう一度(通算2回)やらないといけないんだよね(苦笑)

面倒くせぇ…
(下記は「narrow」画面)

C菜

どちらもバッチリ成功しました~

A子

んじゃ、次は3番目のユーザ管理の一覧画面だね



C菜

良い感じです~


A子

最後は、チャットルームの一覧だよ



C菜

こちらもバッチリだと思います~


B美

ふむ、あとは「faviconファビコン」くらいかしら

まぁ、それは置いといて、チャットのテストをやっておきましょう
(三人で、違う部屋または同じ部屋に入室したり、退室したり…ってテストね)

まずは私が「雑談3」という部屋に入るわよ

A子

んじゃ、続けて私が同じ部屋に入ると…

おぉ、上に入室者リストが表示されてるじゃん

B美

では、他愛もない発言をして…

A子

私がそれに応えると…

C菜

このタイミングで、私も同じ部屋に入りますね~

入室メッセージが同じ部屋にいる他の方々に送られて、上にある入室者リストも更新されてます~
あと、過去ログが私の画面にも出てますし、私の発言もお二人へと届いてますよ~



A子

それじゃ、一言断ってから退室してみよう



B美

退室通知も来たし、入室者リストからも消えたわね

んじゃ、私も退室してみるわ



C菜

一人ぼっちになっちゃいました~(苦笑)

A子

んじゃ、さっきとは別の部屋(雑談1)に入室するよ

B美

私もA子と同じ部屋(雑談1)に入ってみましょう

C菜

「雑談3」の部屋には誰もいませんけど、発言した内容がお二人(A子社長とB美部長)に影響ないかどうかを確認してみますね~

B美

うん、問題ないみたいね

それじゃ、この部屋(雑談1)を出て、C菜のいる部屋(雑談3)に入り直してみましょう





C菜

ウェルカムメッセージを送ってみるです~

A子

私もこの部屋(雑談1)を出て、最初の部屋(雑談3)に移動(再入室)してみるよ





C菜

すごいです~

ほんとに完璧な動作だと思います~

B美

ふむ、きちんと動いて良かったわ
(まじでホッとした(苦笑))