Cập nhật ·  

Ứng dụng chat sử dụng Laravel Reverb

Xây dựng ứng dụng chat với Laravel Reverb - Giao tiếp real-time, hiệu suất cao, mở rộng linh hoạt.
Minh Lâm

Minh Lâm

@minhlam1996vn

Laravel Reverb là máy chủ WebSocket chính thức của Laravel, giúp tích hợp tính năng thời gian thực một cách dễ dàng. Nó cho phép gửi và nhận dữ liệu tức thì mà không cần tải lại trang, mang đến trải nghiệm mượt mà và nhanh chóng.

Trong bài viết này, chúng ta sẽ xây dựng một ứng dụng chat đơn giản, nơi các tin nhắn được gửi ngay lập tức, giúp người dùng tương tác hiệu quả hơn.

Thiết Lập Dự Án Laravel

  • Yêu Cầu Trước Khi Bắt Đầu Để xây dựng ứng dụng trong bài viết này, bạn cần chuẩn bị các công cụ sau:
    • PHP: Phiên bản 8.2 trở lên (Kiểm tra bằng lệnh: php -v)
    • Composer: Đảm bảo Composer đã được cài đặt (Kiểm tra bằng lệnh: composer)
    • Node.js: Phiên bản 20 trở lên (Kiểm tra bằng lệnh: node -v)
  • Đảm bảo rằng bạn đã có một ứng dụng Laravel (Laravel 10.x trở lên).
  • Nếu bắt đầu từ đầu, chạy lệnh:
.
composer create-project laravel/laravel laravel-reverb-vue

Hiện tại mình đang sử dụng (Laravel 11.x)

Cài Đặt & Cấu Hình Laravel Reverb

Cài đặt Laravel Reverb bằng lệnh:

.
php artisan install:broadcasting

Sau khi cài đặt, cập nhật file .env

.env.
...
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_SCHEME=http
...

Cấu Hình Echo

Quá trình cài đặt cũng tự động tạo file echo.js trong thư mục resources/js.

resources/js/echo.js.
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'],
})

Tham khảo Laravel Documentation để biết thêm chi tiết về các bước cấu hình cụ thể.

Chạy Máy Chủ Reverb

Bạn có thể khởi chạy máy chủ Reverb bằng lệnh:

.
php artisan reverb:start

Mặc định, máy chủ Reverb sẽ chạy tại địa chỉ 0.0.0.0:8080, nghĩa là nó có thể truy cập từ tất cả các giao diện mạng.

Nếu bạn muốn chỉ định host hoặc port cụ thể, sử dụng các tùy chọn --host--port

.
php artisan reverb:start --host=127.0.0.1 --port=9000

Ngoài ra, bạn cũng có thể định nghĩa các biến môi trường REVERB_HOSTREVERB_PORT trong file .env của ứng dụng.

.env.
...
REVERB_HOST="localhost"
REVERB_PORT=8080
...

Thiết Lập Cơ Sở Dữ Liệu

Mở file .env và điều chỉnh các cài đặt để thiết lập cơ sở dữ liệu. Ví dụ, sử dụng SQLite

.env.
DB_CONNECTION=sqlite

Tạo file database:

.
touch database.sqlite

Tạo model & migration:

.
php artisan make:model ChatMessage --migration

Cập nhật migration:

database/migrations/..._create_chat_messages_table.php.
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('chat_messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('receiver_id');
            $table->foreignId('sender_id');
            $table->text('text');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('chat_messages');
    }
};

Chạy migrate:

.
php artisan migrate

Cập nhật model ChatMessage.php:

.
<?php

namespace App\Models;

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

class ChatMessage extends Model
{
    use HasFactory;

    protected $fillable = [
        'sender_id',
        'receiver_id',
        'text'
    ];

    public function sender()
    {
        return $this->belongsTo(User::class, 'sender_id');
    }

    public function receiver()
    {
        return $this->belongsTo(User::class, 'receiver_id');
    }
}

Tạo Sự Kiện

Tạo sự kiện MessageSent.php:

.
php artisan make:event MessageSent
.
<?php

namespace App\Events;

use App\Models\ChatMessage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageSent implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct(public ChatMessage $message)
    {
        //
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("chat.{$this->message->receiver_id}")
        ];
    }
}

Định Nghĩa Kênh Private

routers/channels.php.
<?php

use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('chat.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});

Đoạn mã này định nghĩa một kênh private có tên chat.{id} sử dụng facade Broadcast của Laravel. Các kênh private giúp hạn chế truy cập dựa trên logic xác thực và ủy quyền người dùng.

Định Nghĩa Các Route

routes/web.php.
// ...

Route::get('/chat/{friend}', function (User $friend) {
    return view('chat', [
        'friend' => $friend
    ]);
})->middleware(['auth'])->name('chat');

Route::get('/messages/{friend}', function (User $friend) {
    return ChatMessage::query()
        ->where(function ($query) use ($friend) {
            $query->where('sender_id', Auth::id())
                ->where('receiver_id', $friend->id);
        })
        ->orWhere(function ($query) use ($friend) {
            $query->where('sender_id', $friend->id)
                ->where('receiver_id', Auth::id());
        })
       ->with(['sender', 'receiver'])
       ->orderBy('id', 'asc')
       ->get();
})->middleware(['auth']);

Route::post('/messages/{friend}', function (User $friend) {
    $message = ChatMessage::create([
        'sender_id' => Auth::id(),
        'receiver_id' => $friend->id,
        'text' => request()->input('message')
    ]);

    broadcast(new MessageSent($message));

    return  $message;
});

// ...
  • GET ('/chat/{friend}'): Chịu trách nhiệm render giao diện chat và nhận tham số động {friend} đại diện cho đối tác chat của người dùng.
  • GET ('/messages/{friend}'): Truy xuất các tin nhắn được trao đổi giữa người dùng đã xác thực và đối tác được chỉ định ({friend}). Truy vấn đảm bảo lấy ra các tin nhắn mà người dùng là người gửi hoặc người nhận, bao gồm cả hai hướng của cuộc trò chuyện.
  • POST ('/messages/{friend}'): Sau khi tạo tin nhắn, lệnh broadcast(new MessageSent($message)) sử dụng tính năng phát sóng của Laravel để gửi tin nhắn mới này tới tất cả người dùng kết nối thông qua Reverb, từ đó kích hoạt tính năng chat thời gian thực.

Tạo Giao Diện Blade

Để render giao diện chat, bạn cần tạo một file view Blade. Tạo file mới có tên chat.blade.php trong thư mục resources/views và thêm đoạn mã sau:

resources/views/chat.blade.php.
<x-app-layout>
  <x-slot name="header">
    <h2 class="text-xl font-semibold leading-tight text-gray-800">
      {{ $friend->name }}
    </h2>
  </x-slot>

  <div class="py-12">
    <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
      <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
        <div class="p-6 bg-white border-b border-gray-200">
          <chat-component
            :friend="{{ $friend }}"
            :current-user="{{ auth()->user() }}"
          />
        </div>
      </div>
    </div>
  </div>
</x-app-layout>

Điểm mấu chốt ở đây là dòng:

.
<chat-component :friend="{{ $friend }}" :current-user="{{ auth()->user() }}" />

Dòng này render một component Vue.js có tên chat-component.

Tạo ChatComponent

Ví dụ tạo một component ChatComponent.vue để quản lý giao diện và hành vi động của chat:

resoures/js/components/ChatComponent.vue.
<template>
  <div>
    <div class="flex flex-col justify-end h-80">
      <div ref="messagesContainer" class="p-4 overflow-y-auto max-h-fit">
        <div
          v-for="message in messages"
          :key="message.id"
          class="flex items-center mb-2"
        >
          <div
            v-if="message.sender_id === currentUser.id"
            class="p-2 ml-auto text-white bg-blue-500 rounded-lg"
          >
            {{ message.text }}
          </div>
          <div v-else class="p-2 mr-auto bg-gray-200 rounded-lg">
            {{ message.text }}
          </div>
        </div>
      </div>
    </div>
    <div class="flex items-center">
      <input
        type="text"
        v-model="newMessage"
        @keydown="sendTypingEvent"
        @keyup.enter="sendMessage"
        placeholder="Type a message..."
        class="flex-1 px-2 py-1 border rounded-lg"
      />
      <button
        @click="sendMessage"
        class="px-4 py-1 ml-2 text-white bg-blue-500 rounded-lg"
      >
        Send
      </button>
    </div>
    <small v-if="isFriendTyping" class="text-gray-700">
      {{ friend.name }} is typing...
    </small>
  </div>
</template>

<script setup>
import axios from 'axios'
import { nextTick, onMounted, ref, watch } from 'vue'

const props = defineProps({
  friend: {
    type: Object,
    required: true,
  },
  currentUser: {
    type: Object,
    required: true,
  },
})

const messages = ref([])
const newMessage = ref('')
const messagesContainer = ref(null)
const isFriendTyping = ref(false)
const isFriendTypingTimer = ref(null)

watch(
  messages,
  () => {
    nextTick(() => {
      messagesContainer.value.scrollTo({
        top: messagesContainer.value.scrollHeight,
        behavior: 'smooth',
      })
    })
  },
  { deep: true },
)

const sendMessage = () => {
  if (newMessage.value.trim() !== '') {
    axios
      .post(`/messages/${props.friend.id}`, {
        message: newMessage.value,
      })
      .then((response) => {
        messages.value.push(response.data)
        newMessage.value = ''
      })
  }
}

const sendTypingEvent = () => {
  Echo.private(`chat.${props.friend.id}`).whisper('typing', {
    userID: props.currentUser.id,
  })
}

onMounted(() => {
  axios.get(`/messages/${props.friend.id}`).then((response) => {
    console.log(response.data)
    messages.value = response.data
  })

  Echo.private(`chat.${props.currentUser.id}`)
    .listen('MessageSent', (response) => {
      messages.value.push(response.message)
    })
    .listenForWhisper('typing', (response) => {
      isFriendTyping.value = response.userID === props.friend.id

      if (isFriendTypingTimer.value) {
        clearTimeout(isFriendTypingTimer.value)
      }

      isFriendTypingTimer.value = setTimeout(() => {
        isFriendTyping.value = false
      }, 1000)
    })
})
</script>

Component Vue.js này quản lý giao diện chat với các tính năng

  • Hiển thị danh sách tin nhắn có khả năng cuộn, được định dạng khác nhau tùy theo người gửi (người dùng hiện tại hoặc bạn bè).
  • Cung cấp ô nhập liệu cho việc soạn tin và nút gửi tin.
  • Sử dụng axios để gửi các yêu cầu HTTP nhằm lấy danh sách tin nhắn ban đầu và gửi tin nhắn mới.
  • Tính năng thời gian thực được thực hiện qua Laravel Echo
    • Lắng nghe các sự kiện MessageSent được phát sóng để cập nhật danh sách tin nhắn khi có tin nhắn mới.
    • Sử dụng whisper trên các kênh private để thông báo cho đối tác khi người dùng đang gõ và nhận thông báo gõ từ bạn bè.

Kết luận

Laravel Reverb giúp xây dựng các ứng dụng real-time nhanh chóng và mạnh mẽ. Với bài viết này, bạn có thể tạo một hệ thống chat đơn giản nhưng hiệu quả.

Tài liệu tham khảo:

Mã nguồn tham khảo: Laravel Reverb Chat