Viết ứng dụng chat trong Laravel sử dụng Private và Presence channel
Cập nhật gần nhất: 25/11/2024
Chào mừng các bạn quay trở lại với series viết ứng dụng chat realtime sử dụng Laravel, VueJS, Laravel Echo của mình. 👋👋
Ở bài trước mình đã hướng dẫn các bạn viết ứng dụng chát đơn giản, dùng public
channel trong Laravel, nhược điểm của public
channel là khi 1 user gửi tin nhắn thì toàn bộ tất cả các user khác đều có thể nhận được. Thực tế thì ta sẽ thường muốn 1 user gửi tin nhắn đến chỉ 1 hoặc 1 số user khác.
Và để làm điều đó thì bìa hôm nay mình sẽ hướng dẫn các bạn sử dụng private
và presence
channel trong laravel để xác thực user và gửi tin nhắn đến 1 số user nhất định, thay vì toàn bộ như trước kia.
Ở bài này ta sẽ xây dựng:
- Một chatroom có 2 phòng, user có thể gửi tin nhắn khi ở trong phòng
- User ở phòng nào sẽ chỉ nhận được tin nhắn ở phòng đó (phần private channel)
- Tiếp đó ta sẽ hiển thị thông tin chi tiết từng user có trong phòng (phần presence channel)
Và có 1 điều đặc biệt là ở bài này ta sẽ dùng Laravel Reverb mới toanh từ Laravel nha:
Chuẩn bị
Điều kiện tiên quyết
Nghe tiên quyết như học sinh cấp 3 🤣🤣
Các bạn cần phải cài đặt redis
. Gõ command redis-cli
để check nhé. Nếu thấy báo lỗi không có thì search google cách cài với từng nền tảng Win, Mac, Linux nhé.
Tiếp đó là các bạn cũng cần phải có MySQL cài sẵn đó nhé
Và cuối cùng không thể thiếu đó là PHP >= 8.2
Rất nhiều bạn thiếu bước này đó. Lời khuyên của mình là các bạn dùng Docker để chạy Redis + MySQL cho lành nha 😘
Thiết lập project
Ở bài này để tiết kiệm thời gian, mình đã tạo sẵn một project mẫu cho các bạn rồi nhé.
Các bạn clone source code ở đây: https://github.com/maitrungduc1410/viblo-private-chat-app
Sau khi clone các bạn chạy lần lượt command sau để setup project:
composer install
npm install
cp .env.example .env
php artisan key:generate
Sau đó các bạn setup lại thông số database ở file .env
cho phù hợp với máy của các bạn nha, ví dụ bên dưới của mình nom như sau:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=chat_app
DB_USERNAME=root
DB_PASSWORD=
Ta có thể tạo sẵn database chat_app
(hoặc không thì tí nữa đoạn migrate Laravel làm thay cũng được) 😁
Oke thì ta migrate và seed dữ liệu nhé:
php artisan migrate --seed
Cuối cùng chạy app để test thử coi xem sao nhen, các bạn chạy các command sau:
php artisan serve
npm run dev
Sau đó ta mở trình duyệt ở địa chỉ http://localhost:8000/
, tiến hành tạo tài khoản login, zô gửi vào tin nhắn các kiểu đảm bảo mọi thứ okela nha 😎:
Tổng quan project
Mình sẽ giải thích một lượt về project để các bạn nếu muốn chỉnh sửa có thể làm dễ dàng nhé.
Các mục cần quan tâm:
- File
routes/web.php
ở đây mình định nghĩa vài routes đơn giản. Ở cuối cùng của file này mình có bắtroute::any
nhằm fix lỗi của Vue-router trả về 404 nếu các bạn đểmode: 'history'
nhé. - Mình chỉ có 2 model là User và Message. Mỗi message có room_id (message được gửi ở phòng nào, khoá ngoại tới bảng
chatrooms
), user_id (ai là người gửi, khoá ngoại tới bảngusers
) và content (nội dung tin nhắn) - Có 2 controller là MessageController (hiển thị và lưu message) và AppController (trả về view chứa frontend kèm theo data). Mục đích trả kèm theo data là mình muốn các thông tin như
user
,chatroom
được trả về ngay khi user login thành công, trước khi gọi vào VueJS, để lát nữa mình không phải tốn request gọi lại nữa, vì đây là 2 thành phần không đổi trong suốt quá trình sử dụng. - File
resources/views/app.blade.php
là file trả về frontend VueJS. các bạn có thể thấy mình có biếnwindow.__app__ = @json($data)
, đây là data được trả về kèm với view (xem AppController). biến__app__
này lát nữa sẽ được dùng ở fileapp.js
của VueJS nhé 😁
Nói chung project ở bước này khá đơn giản, chỉ là dạng CRUD, chưa có realtime. Các bạn có thể chọn 1 chatroom, nhập 1 dòng tin nhắn và bấm gửi, mở tab trình duyệt khác sẽ không thấy realtime mà phải load lại trình duyệt.
Bắt đầu vào phần realtime thôi nào 🚀🚀
Realtime với Private Channel
Âu cây, hiện tại ở Laravel 11 thì phần Broadcasting đã không được cài sẵn mà ta phải tự cài nó với command sau:
php artisan install:broadcasting
Khi được hỏi có cài Reverb + Node dependencies không thì ta chọn Yes
nhé:
Khi cài Oke thì ở .env
ta phải thấy xêm xêm như sau:
Chú ý: BROADCAST_CONNECTION=reverb nhé
Tiếp theo ta cài thêm 2 packages sau cho Frontend nhé:
npm install --save-dev laravel-echo pusher-js
Lý do ở trên ta cài
pusher-js
là vì Reverb nó cũng follow theo protocol của Pusher
Tiếp theo ta mở file resources/js/bootstrap.js
, kéo xuống dưới cùng sẽ thấy là ở đoạn install broadcasting vừa nãy nó đã tự động thêm:
import './echo';
Và file echo.js
có nội dung như sau cũng đã được tạo ra:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
Oke ngon rồi, với Laravel Reverb thì Laravel làm hết cho ta luôn, nãy giờ ta chả phải setup mấy 😂 (khác hẳn so với việc dùng laravel-echo-server)
Ý tưởng làm realtime ở bài này như sau: khi 1 user join vào 1 chatroom, gửi đi 1 tin nhắn, khi tin nhắn được lưu thành công sẽ thông báo (broadcast) với các user chỉ ở trong chatroom đó, ta dùng Event trong Laravel để broadcast nhé.
Ta tạo event MessagePosted
(event này sẽ được gọi mỗi khi có tin nhắn được lưu thành công):
php artisan make:event MessagePosted
Sau đó ta mở file app/Events/MessagePosted
và sửa lại như sau:
<?php
namespace App\Events;
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;
use App\Models\Message;
class MessagePosted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
/**
* Create a new event instance.
*/
public function __construct(Message $message)
{
$this->message = $message;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn()
{
return new PrivateChannel('room.'.$this->message->room_id);
}
}
Chú ý đoạn
implements ShouldBroadcast
vàuse App\Models\Message;
nha 😉
Giải thích chút nhé: ở event này ta nhận vào 1 tham số là message
vừa được lưu, sau đó broadcast cho các users khác qua Private channel
với tên channel là room
của message.
Tiếp theo, vì là bây giờ ta dùng Private Channel, nên ta phải login trước thì lát nữa mới có thể lắng nghe sự kiện nhé. Đồng thời, khi login xong user có tuỳ quyền chọn vào 1 trong các chatroom định sẵn do đó ở file routes/channels.php
ta thêm vào như sau nhé:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('room.{id}', function ($user, $id) {
return true; // user có thể join vào bất kì chatroom nào
});
Chú ý rằng ở callback ta có $user
là user sau khi đã authenticated, tức là đã đảm bảo rằng họ đã login
Setup phần broadcast ổn rồi đó. Giờ ta quay lại file MessageController.php
ở hàm store
(để lưu tin nhắn) ta sửa lại như sau:
use App\Events\MessagePosted; // thêm dòng này ở đầu
...
public function store (Request $request) {
$message = new Message();
$message->room_id = $request->input('room_id', -1);
$message->user_id = Auth::user()->id;
$message->content = $request->input('content', '');
$message->save();
// Thêm dòng bên dưới
// Gửi đến các user khác trong phòng TRỪ user tạo tin nhắn này
broadcast(new MessagePosted($message->load('user')))->toOthers();
return response()->json(['message' => $message->load('user')]);
}
Ổn rồi đó, giờ ta qua frontend để lắng nghe sự kiện bắt tin nhắn mới nhé 😘
Các bạn mở file resources/js/pages/Room.vue
ở onBeforeMount
các bạn sửa lại như sau:
onBeforeMount(() => {
getMessages()
const index = rootData.rooms.findIndex(item => item.id === parseInt(route.params.roomId))
if (index > -1) {
currentRoom.value = rootData.rooms[index]
Echo.private(`room.${currentRoom.value.id}`)
.listen('MessagePosted', (e) => {
messages.value.push(e.message)
scrollToBottom()
})
}
})
Giải thích chút nhé:
- Ở
onBeforeMount
đầu tiên ta lấy danh sách tin nhắn ứng với room ID hiện tại, room ID được trichs từ URL hiên tại (dùngroute.params.roomId
) - vẫn ở
onBeforeMount
ta check xem room ID có tồn tại trong danh sách room hay không (đề phòng user tự sửa room id và cho load lại trang) - nếu có thì ta mới tiến hành lắng nghe tin nhắn dùng
Echo.private
, khi có tin nhắn mới thì ta đẩy vào mảng danh sách message
Tiêp theo đó vẫn ở Room.vue
ta thêm vào:
import { onBeforeMount, ref, getCurrentInstance, nextTick, onBeforeUnmount } from 'vue'
//...
onBeforeUnmount(() => {
// huỷ lắng nghe tin nhắn ở chatroom hiện tại
// nếu như user chuyển qua route/chatroom khác
Echo.leaveChannel(`room.${currentRoom.value.id}`)
})
Ô kê đến giờ test rồi. Các bạn tắt hết các cửa sổ terminal đang chạy, clear config bằng command php artisan config:clear
và ta tiến hành khởi động lại bằng các command sau nhé, mỗi command chạy ở 1 terminal nhé:
php artisan serve
npm run dev
php artisan reverb:start --debug
php artisan queue:work
ta chạy Reverb với option
--debug
để xem đầy đủ log ở terminal nha
Âu cây ròi, giờ ta mở trình duyệt, 2 tab, 1 tab thường 1 tab ẩn danh, login với 2 account, vô cùng 1 phòng và chat với nhau rồi xem thành quả nhen 😘.
Note: mỗi khi gửi tin nhắn thì các bạn check lại 2 terminal chạy Reverb và queue:work
hiển thị như sau là oke nhé:
Tiếp theo các bạn thử cho mỗi user join vào 1 phòng và lại thử nhắn tin tiếp, ta để ý là user ở phòng nào thì chỉ nhận được tin nhắn realtime từ phòng đó. Tuyệt vời 😘
Giờ vấn đề tiếp theo là làm cách nào để lấy được thông tin chi tiết từng user trong phòng và hiển thị. Cùng đi tiếp tới phần sau nhé các bạn
Buggg
Ầu... có gì lạ lạ à nha.
Đúng là từ A gửi tin nhắn sang B thì phía B realtime rồi, cơ mà phía A cũng nhận được tin nhắn, dẫn đến tình trạng tin nhắn bị lặp.
Ê mà ở phía Laravel ta có boadcast()->toOthers
rồi mà ta 🤔🤔🤔
Đọc lại documentation của Laravel tí coi nha: https://laravel.com/docs/master/broadcasting#only-to-others-configuration
Àaaaaaaa...thì ra là ta phải gửi cả socketId theo request (axios) lên cho Laravel nữa, thì khi broadcast nó mới bỏ qua socketId của người gửi (sender)
Tức là ở file Room.vue -> saveMessage()
, đoạn axios.post ta phải sửa như sau:
const saveMessage = async (content) => {
try {
const response = await axios.post('/messages', {
room_id: parseInt(route.params.roomId),
content
}, {
headers: {
'X-Socket-ID': window.Echo.socketId(),
}
})
messages.value.push(response.data.message)
scrollToBottom()
} catch (error) {
console.log(error)
}
}
Ta lưu lại và quay lại trình duyệt F5 là sẽ thấy oke nha, không còn hiện tượng bị lặp tin nhắn 👍️👍️
Ta thử gửi 1 message và inspect API kiểm tra header xem nha:
Presence Channel
Giờ để hiển thị danh sách user đang có trong phòng thì ta sẽ dùng Presence Channel nhé
Presence channel cũng chính là Private channel nhưng ở đó ta lấy được thông tin cụ thể từng user
Ở file routes/channels.php
, ta sửa lại như sau nhé:
Broadcast::channel('room.{id}', function ($user, $id) {
// // giờ đây ta trả về thông tin user chứ không trả về true/false nữa
return $user;
});
Tiếp theo ta quay lại file app/Events/MessagePosted.php
và sửa lại hàm broadcastOn
như sau:
public function broadcastOn()
{
return new PresenceChannel('room.'.$this->message->room_id);
}
Sau đó ta quay lại file Room.vue
ở created
ta sửa lại như sau:
onBeforeMount(() => {
getMessages()
const index = rootData.rooms.findIndex(item => item.id === parseInt(route.params.roomId))
if (index > -1) {
currentRoom.value = rootData.rooms[index]
Echo.join(`room.${currentRoom.value.id}`)
.here((users) => { // gọi ngay thời điểm ta join vào phòng, trả về tổng số user hiện tại có trong phòng (cả ta)
usersOnline.value = users
})
.joining((user) => { // gọi khi có user mới join vào phòng
usersOnline.value.push(user)
})
.leaving((user) => { // gọi khi có user rời phòng
const index = usersOnline.value.findIndex(item => item.id === user.id)
if (index > -1) {
usersOnline.value.splice(index, 1)
}
})
.listen('MessagePosted', (e) => {
messages.value.push(e.message)
scrollToBottom()
})
}
})
Giờ ta đóng tất cả terminal đi và start mới:
php artisan serve
npm run dev
php artisan reverb:start --debug
php artisan queue:work
Ngay sau đó các bạn load lại trang, và BÙM, ta có kết quả như sau:
Lấy thông tin user dùng Presence Channel
Presence channel cũng chính là Private channel nhưng ở đó ta lấy được thông tin cụ thể từng user
Thế có bạn sẽ hỏi: thế vậy tôi cần cái Private channel
làm gì, dùng luôn Presence channel
đi, thông tin user sau này cần thì dùng không thì thôi 😘
Đúng mình cũng có câu hỏi như vậy và hiện tại chưa tìm được 1 câu trả lời chính thống cụ thể nào. Nhưng mình đã sớm nhận ra là Presence channel
khi broadcast sẽ chậm hơn Private channel
1 chút, cái "1 chút" này đủ dài để ta có thể cảm nhận được 😁
Thôi tiếp tục phần này nào 🚀🚀.
Ở file routes/channels.php
, ta sửa lại như sau nhé:
Broadcast::channel('room.{id}', function ($user, $id) {
// // giờ đây ta trả về thông tin user chứ không trả về true/false nữa
return $user;
});
Tiếp theo ta quay lại file app/Events/MessagePosted.php
và sửa lại hàm broadcastOn
như sau:
public function broadcastOn()
{
return new PresenceChannel('room.'.$this->message->room_id);
}
Sau đó ta quay lại file Room.vue
ở created
ta sửa lại như sau:
onBeforeMount(() => {
getMessages()
const index = rootData.rooms.findIndex(item => item.id === parseInt(route.params.roomId))
if (index > -1) {
currentRoom.value = rootData.rooms[index]
Echo.join(`room.${currentRoom.value.id}`)
.here((users) => { // gọi ngay thời điểm ta join vào phòng, trả về tổng số user hiện tại có trong phòng (cả ta)
usersOnline.value = users
})
.joining((user) => { // gọi khi có user mới join vào phòng
usersOnline.value.push(user)
})
.leaving((user) => { // gọi khi có user rời phòng
const index = usersOnline.value.findIndex(item => item.id === user.id)
if (index > -1) {
usersOnline.value.splice(index, 1)
}
})
.listen('MessagePosted', (e) => {
messages.value.push(e.message)
scrollToBottom()
})
}
})
Ngay sau đó các bạn load lại trang, và BÙM, ta có kết quả như sau:
Bài tập về nhà
Nghe bài tập về nhà lại giống cấp 3 🤣🤣
Giờ đây ta có thông tin của toàn bộ user trong phòng rồi. Các bạn thử tự mình làm thêm 1 chức năng nhắn tin riêng 1-1 với 1 user nào đó trong phòng. Ví dụ: bấm click chọn một user trong danh sách user thì xuất hiện của sổ chat riêng với user đó (giống Facebook chẳng hạn), dùng Private hoặc Presence Channel chẳng hạn, nhưng theo mình chat 2 người dùng Private channel là đủ, nhanh hơn Presence channel.
Đồng thời ta thêm các sự kiện nhỏ nhỏ kiểu: "user này đang gõ", "đã xem lúc mấy giờ", báo tin nhắn đến. Để làm được những điều này các bạn cần đọc thêm chút phần sự kiện whisper
ở trang chủ Laravel đây nhé
Demo
Các bạn có thể xem demo của mình ở đây nhé
Nếu bạn gặp lỗi
Trong khi code nếu có khi nào bạn gặp lỗi thì xem lại bài đầu tiên trong series này của mình phần debug mình đã ghi rấttttttt là tâm huyết rồi các bạn à 😘😘😘
Kết bài
Qua bài này hi vọng các bạn đã hiểu hơn được cách sử dụng Private và Presence Channel trong laravel để xử lý realtime data. Thường ở các dự án thật thì mình dùng 2 loại channel này chứ không dùng Public channel như bài trước , nhưng vì public channel khá dễ cấu hình, không cần xác thực nên mình giới thiệu với các bạn trước để các bạn làm quen với cách làm realtime trong Laravel.
Có rất nhiều bạn đã hỏi mình về các sử dụng Private/Presence channel, và các lỗi mà các bạn gặp phải, mong rằng qua bài này các bạn hiểu thêm về cách sử dụng chúng 😘
Trong bài nếu có gì thắc mắc các bạn cứ để lại commen cho mình nhé. Hẹn gặp các bạn ở các bài sau 👋👋
All rights reserved