Viết ứng dụng chat realtime với Laravel, VueJS, Redis và Socket.IO, Laravel Echo

Xin chào tất cả các bạn, đây là một trong những bài post đầu tiên của mình. Sau bao năm toàn đi đọc các blog tích luỹ được chút kiến thức của các cao nhân trên mạng. Đến ngày hôm nay mới quyết định tập toẹ viết blog. Mục đích vừa muốn chia sẻ kiến thức của mình với mọi người, vừa muốn tăng khả năng viết lách của bản thân. Có gì sai sót anh em comment nhiệt tình nhé.

Hôm nay mình hướng dẫn các bạn xây dựng ứng dụng đơn giản demo chat reatime với Laravel, Vue, Redis và Socket.IO


Sau khi có góp ý của 1 số bạn thì mình đã check lại và sửa lại các thiếu sót và đã test lại đầy đủ, nếu vẫn còn bug các bạn cứ comment nhiệt tình cho mình nhé 😃


I. Setup

<Ở thời điểm hiện tại Laravel đang ở phiên bản 6.2, Vue 2.6>

Đầu tiên mình muốn giải thích tại sao mình dùng Redis.

Mình đã từng xem rất nhiều tuts về app real time dùng Laravel, và hầu hết chúng sử dụng Pusher(một bên thứ 3 giúp ta xử lý các tác vụ thời gian thực), mình đánh giá Pusher khá là ổn nhưng có nhược điểm là bị giới hạn về số kết nối và số lượng tin nhắn truyển tải, nên cần phải trả tiền theo nhu cầu sử dụng. Vì thế nên mình quyết định chọn Redis trong bài này, thực ra ở các dự án thật mình từng làm đều sử dụng Redis (hỗ trợ caching trên RAM giúp truy vấn nhanh hơn, và quan trọng nữa là nó free).

Đồng thời nếu sau này ta dùng Laravel Horizon, hay các loại queue jobs (để gửi mail chẳng hạn), thì hầu hết ta lại sử dụng Redis

Nói thế cũng nhiều rồi, sau đây mình bắt tay vào setup project thôi:

Trước nhất và quan trọng nhất các bạn check xem đã cài redis chưa nhé. Gõ command redis-cli để check nhé (Ubuntu/Mac cài redis khá đơn giản, Win thì hơi vất hơn 😉)

Đầu tiên chúng ta sẽ khởi tạo project Laravel, đặt tên là chat-app.Mở PowerShell (Windows) hoặc terminal(MAC, Linux) và chạy câu lệnh sau:

laravel new chat-app

Chắc sẽ có bạn hỏi sao có thể dùng được câu lệnh trên, tưởng phải thế này chứ nhỉ:

composer create-project --prefer-dist laravel/laravel chat-app

Thì các bạn có thể xem hướng dẫn ở đây nhé: https://laravel.com/docs/5.6#installing-laravel

Nhớ tạo key cho project nhé:

php artisan key:generate

Sau khi cài đặt xong các bạn cd vào thư mục project vừa tạo nhé, tiếp theo chúng ta cài driver để kết nối Redis bằng câu lệnh:

composer require predis/predis

Mỗi người dùng muốn chat thì cần phải đăng nhập vào ứng dụng, để làm điều đó chúng ta sử dụng luôn lớp Auth của Laravel nhé, khởi tạo bằng cách:

php artisan make:auth

Tiếp theo chúng ta setup VueJS nhé:

composer require laravel/ui --dev
php artisan ui vue --auth
npm install

Nó sẽ tự tích hợp VueJS vào project của chúng ta. Cùng xem kết quả nhé:

Nó sẽ tạo sẵn ra cho chúng ta các thành phần cần thiết của Vue. Sau đây sẽ bắt tay vào tạo phần giao diện nhé.

Đầu tiên chúng ta tạo file chat.blade.php chứa layout cơ bản của ứng dụng.

Các bạn xem code trên gist ở đây

Trong đoạn code trên mình có tạo sẵn một cặp thẻ “chat-layout”, đây là component VueJS mà chúng ta sẽ nói ở phần sau nhé.

Sau đó tạo một route để truy cập vào view này.

Route::get('/', function () {
    return view('chat');
});

II. VueJS

Ứng dụng sẽ có 2 Vue components là ChatLayout.vue và ChatItem.vue

Trước tiên chúng ta vào resource/assets/js/app.js và khai báo như sau:

Vue.component('chat-layout', require('./components/ChatLayout.vue').default)

const app = new Vue({
    el: '#app'
})

Tạo 2 file mới trong thư mục components là ChatLayout.vueChatItem.vue với nội dung như sau:

Vì nội dung hơi dài 1 xíu nên mình đã đưa lên gist để các bạn tiện xem hơn nhé: Link Gist

Oke thế là xong giao diện đơn giản, check thử coi nào:

Hiện đã có sẵn chút dữ liệu do mình tạo cho các bạn nhìn trực quan, lát mình sẽ xoá đi nhé.

III. Laravel Backend

Quay trở lại với server.

Đầu tiên chúng ta tạo model Message với câu lệnh:

php artisan make:model Message -m

option -m để tạo luôn migration cho nó nhé.

Các bạn vào database/migrations/ Tìm đến file migration vừa tạo, và sửa lại hàm up() như sau:

public function up()
{
    Schema::create('messages', function (Blueprint $table) {
        $table->bigIncrements('id');
        $table->text('message');
        $table->integer('user_id')->unsigned();
        $table->timestamps();
    });
}

sửa lại file app/Message.php như sau:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    protected $fillable = ['message', 'user_id'];

    public function user() {
        return $this->belongsTo(User::class);
    }
}

Thêm function sau vào App\User.php:

public function messages() {
    return $this->hasMany(Message::class);
}

Giải thích: vì mỗi tin nhắn sẽ phụ thuộc vào 1 user nên ở Message ta có quan hệ belongsTo và mỗi User có thể có nhiều tin nhắn nên ở User ta có quan hệ hasMany.

Sau đó setup lại file .env theo thông số database của các bạn(DB_DATABASE, DB_DATABASE, DB_USERNAME, DB_PASSWORD), đồng thời thiết lập các thông số của phần broadcast như sau luôn nhé:

BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_DRIVER=redis

Thiết lập xong nhớ chạy lại server bằng câu lệnh:

php artisan serve

Tau đó các bạn chạy câu lệnh sau để khởi tạo database:

php artisan migrate

Nếu các bạn gặp lỗi Specified key was too long; max key length is ... Thì fix như sau nhé:

Mở file app/providers/AppServiceProvider.php và sửa lại như sau:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Schema;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Schema::defaultStringLength(191);
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

Sau đó chạy lại php artisan migrate:refresh là được nhé.

Tiếp theo chúng ta sẽ tạo một route để mỗi khi một tin nhắn do người dùng gửi tới sẽ được lưu vào database.

Ta tạo các route mới như sau (đồng thời sửa lại route đã tạo để yêu cầu đăng nhập nếu muốn vào phòng chat nhé):

Auth::routes();

Route::get('/chat', function() {
    return view('chat');
})>middleware('auth');

Route::get('/getUserLogin', function() {
	return Auth::user();
})>middleware('auth');

Route::get('/messages', function() {
    return App\Message::with('user')> get();
})>middleware('auth');

Route::post('/messages', function() {
   $user = Auth::user();

  $message = new App\Message();
  $message->message = request()->get('message', '');
  $message->user_id = $user->id;
  $message->save();

  return ['message' => $message->load('user')];
})->middleware('auth');

Để app đơn giản mình sẽ không tạo controller mà thao tác trực tiếp trong route nhé. *giải thích:

  • route GET /chat để trả về view.
  • route Get /getUserLogin mục đích trả về user hiện tại đang login
  • route GET /messages mục đích lấy các messages trong database ra, đi kèm là thông tin user người gửi message đó.
  • route POST /messages đơn giản là lưu tin nhắn do user gửi đi vào database. Tạo thêm một route để Vue component có thể gọi và lấy dữ liệu chat ban đầu (lịch sử chat) lúc mới load trang.Quay trở lại Vue component ChatLayout.vue để thiết lập sự kiện gửi tin nhắn.

ở ô input nhập tin nhắn các bạn sửa lại như sau:

<div class="message-box">
    <input type="text" v-model="message" @keyup.enter="sendMessage" class="message-input" placeholder="Type message..."/>
    <button type="button" class="message-submit" @click="sendMessage">Send</button>
</div>

Ở đây khi người dùng bấm enter hoặc click vào button thì phương thức sendMessage sẽ được gọi. Chúng ta sẽ tạo phương thức sendMessage nhé, đồng thời chúng ta tạo luôn một phương thức để load các Message trong database mỗi lần truy cập nhé:

<script>
import ChatItem from './ChatItem.vue'
export default {
    components: {
        ChatItem
    },
    data() {
        return {
            message: '',
            list_messages: []
        }
    },
    created () {
        this.loadMessage()
    },
    methods: {
        loadMessage() {
                axios.get('/messages')
                    .then(response => {
                        this.list_messages = response.data
                    })
                    .catch(error => {
                        console.log(error)
                    })
            },
            sendMessage() {
                axios.post('/messages', {
                        message: this.message
                    })
                    .then(response => {
                        console.log('success')
                        this.list_messages.push(response.data.message)
                        this.message = ''
                    })
                    .catch(error => {
                        console.log(error)
                    })
            }
    }
} 
</script>

Giải thích chút: ở trên trong data mình tạo 2 thuộc tính

  • message: là đoạn message hiện thời mà user đang nhập trong input,
  • list_messages: là danh sách các tin nhắn lấy ra trong database mỗi khi lần đầu trang web được load lên.
  • 2 hàm trong methods mọi người đọc qua một sẽ hiểu, cũng không khó lắm, chú ý ở hàm sendMessage() sau khi gửi tin nhắn thành công thì mình sẽ push ngay nó vào list_messages để hiển thị.
  • Đồng thời các bạn để ý mình dùng biến this.$root.currentUserLogin, mục đích lấy về user hiện tại đang login, ta cùng thiết lập biến này ở app.js nhé.

app.js:

const app = new Vue({
    el: '#app',
    data: {
        currentUserLogin: {}
    },
    created() {
        this.getCurrentUserLogin()
    },
    methods: {
        getCurrentUserLogin() {
            axios.get('/getUserLogin')
            .then(response => {
                this.currentUserLogin = response.data
            })
        }
    }
});

Ở đây các bạn có thể thấy chúng ta lấy message được truyền từ component ChatLayout.vue và hiển thị ra, đồng thời mình bind thẻ span chứa tên người gửi, nếu trùng với người đang login thì sẽ bôi màu tím

Ấy quên, phần truyền data từ ChatLayout.vue vào ChatItem.vue nữa, chúng ta quay lại file ChatLayout.vue và sửa một chút ở phần template nhé:

<div class="messages-content">
    <ChatItem v-for="(message, index) in list_messages" :key="index" :message="message"></ChatItem>
</div>

Chú ý: vì chúng ta dùng v-for trực tiếp cho component nên bắt buộc chúng ta phải bind key cho chúng nhé. Key thế nào tuỳ các bạn nhưng đảm bảo là khác nhau với mỗi component ChatItem được render. Đồng thời ở trên mình có truyền vào component ChatItem props message để hiện thị.

Tiếp theo các bạn sửa lại 1 chút ở phần template của ChatItem.vue như sau để hiện thị đúng thông tin nhé:

<template>
	<div class="message" :class="{'is-current-user': $root.currentUserLogin.id === message.user.id}">
		<div class="message-item user-name">
			{{ message.user.name}}
		</div>
		<div class="message-item timestamp">
			| {{ message.created_at.split(' ')[1] }}: 
		</div>
		<div class="message-item text-message">
			{{ message.message }}
		</div>
	</div>
</template>

<script>
	export default {
        props: {
          message: {
            type: Object,
            default: {}
          }
        }
    }
</script>

Ok thế là phần gửi tin nhắn và hiển thị cũng khá ổn rồi đó nhỉ, cùng test thôi nào.

Các bạn mở 2 tab và đăng nhập 2 tài khoản vào đó sau đó test chat thử nhé.

Các bạn có thể thấy là khi có user A gửi tin nhắn thì chỉ A mới thấy tin nhắn mình vừa gửi xuất hiện trên màn hình, còn B thì không, nhưng khi B F5 lại trình duyệt thì mới xuất hiện tin nhắn của A.

Đó là lúc chúng ta xử lý phần realtime cho ứng dụng này để khi A gửi tin B có thể nhìn thấy tin nhắn của A ngay lập tức mà không phải tải lại trang.

IV. REAL TIME

Note quan trọng trước khi làm tiếp: mình nhắc lại để các bạn chú ý là ở bài này ta dùng Redis nên các bạn cần check lại xem các bạn đã cài redis chưa nhé. Ở terminal gõ redis-cli để check nhé 😉

Tiếp theo các bạn vào file config/database.php kéo xuống phần redis, ở trường client, các bạn sửa lại cho mình như sau:

'client' => env('REDIS_CLIENT', 'predis')

Để xứ lý realtime chúng ta sử dụng socket.iolaravel echo nhé, cùng với đó chúng ta phải setup thêm laravel echo server

Cài đặt bằng cách chạy câu lệnh:

npm install --save socket.io-client laravel-echo laravel-echo-server

Chú ý: sau bước trên mà có bạn nào bị lỗi Cannot find module 'node_modules/is-buffer/index.js' thì chạy lại npm install là được nhé.

Sau khi cài đặt xong ta vào file resources/assets/js/bootstrap.js, kéo xuống cuối bỏ comment và sửa lại như sau:

import Echo from "laravel-echo"

window.io = require('socket.io-client');

window.Echo = new Echo({
    broadcaster: 'socket.io',
    host: window.location.hostname + ':6001'
});

Tiếp theo chúng ta setup laravel-echo-server nhé. Ta chạy command:

laravel-echo-server init

Cứ yes và nhớ chọn Redis nhé mọi người.

Tiếp theo ta thêm cuối vào file .env như sau:

LARAVEL_ECHO_SERVER_REDIS_HOST=127.0.0.1
LARAVEL_ECHO_SERVER_REDIS_PORT=6379

Note: các bạn mở file laravel-echo-server.json vừa được tạo , sửa lại trường authHost cho đúng với địa chỉ app chúng ta đang chạy (nếu không đến khi làm private channel sẽ lỗi nhé các bạn):

    "authHost": "http://localhost:8000",

Phân tích một chút nhé. mỗi khi có một tin nhắn được gửi lên server, server sẽ fire một event gọi là MessagePosted sau đó sẽ broadcast event này đi cho các client khác đang kết nối tới, ở bên Vue chúng ta sẽ lắng nghe event này và lấy dữ liệu hiển thị.

Trước tiên ta cần kích hoạt BroadcastServiceProvider, các bạn mở file config/app.php kéo xuống phần providers bỏ comment dòng:

    App\Providers\BroadcastServiceProvider::class,

Sau đó chúng ta sẽ cùng tạo một event là MessagePosted:

php artisan make:event MessagePosted

Chúng ta sửa lại file App/Events/MessagePosted.php như sau nhé:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use App\Message;
use App\User;

class MessagePosted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public $message;
    public $user;

    public function __construct(Message $message, User $user)
    {
        $this->message = $message;
        $this->user = $user;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return ['chatroom'];
    }
}

Ở đây chúng ta tạo ra một public channel tên là chatroom, event trả về thông tin gồm message và thông tin user gửi message đó.

Sửa lại một chút ở file routes/web.php để fire event cho các mỗi lần có message gửi lên server nhé. Ta sửa Route POST /messages lại như sau:

Route::post('/messages', function () {
   $user = Auth::user();

  $message = new App\Message();
  $message->message = request()->get('message', '');
  $message->user_id = $user->id;
  $message->save();
  
  broadcast(new App\Events\MessagePosted($message, $user))->toOthers();
  return ['message' => $message->load('user')];
})->middleware('auth');

Ở đây mỗi khi có một user gửi message lên server, server sẽ fire event MessagePosted và sẽ broadcast cho các client khác, ta dùng phương thức toOthers() mục đích chỉ broadcast cho các client khác cập nhật còn chính user gửi thì không cần.

Giờ ta quay qua Vue component để lắng nghe event nhé (sắp xong rồi đó 😃).

ChatLayout.vue chúng ta sửa lại phương thức created như sau:

created() {
    this.loadMessage()
    Echo.channel('chatroom')
    .listen('MessagePosted', (data) => {
        let message = data.message
        message.user = data.user
        this.list_messages.push(message)
    })
},

Ở đây khi các client khác lắng nghe thấy sự kiện MessagePosted được gọi, chúng sẽ lấy dữ liệu gửi về từ server và push vào mảng danh sách message.

Cuối cùng để mọi thứ có thể chạy được ta cần khởi động laravel echo server

ta chạy command sau:

laravel-echo-server start
php artisan queue:work # chạy ở terminal khác

Giải thích command php artisan queue:work: ở bài này chúng ta dùng redis như 1 queue (hàng đợi), khi user gửi tin nhắn mới, tin nhắn sẽ được đẩy vào queue, và command này có nhiệm vụ lấy các tin nhắn ở trong queue và gửi đi cho các users khác

OK ổn rồi đó, đến giờ test rồi nào 😄. Mở 2 tab trình duyệt, 1 chính 1 ẩn danh, hoặc nếu không muốn mở ẩn danh thì các bạn mở 1 loại trình duyệt khác cũng được.

VÂNGGGG, sau khi test vẫn không thấy có realtime gì hết, WTF??? Làm nãy giờ mờ cả mắt không ra cái gì @@. Tại saoooo??

Thử mở lại terminal nơi chạy laravel-echo-server thì thấy:

laravel_echo_server

Đã có event rồi mà nhỉ. Ôi DỪNG!!! tại sao tên channel lại là laravel_database_chatroom, cái này ở đâu ra vậy????? (khổ bài post này của mình từ thời 5.6 nên nhiều bạn đọc hay bị lỗi do phiên bản thay đổi, mình cứ phải sửa hoài cho kịp anh Taylor Otwell update Laravel 😄)

Sau 1 lúc tìm kiếm google thì mình đã hiểu được. Ở laravel bản mới (5.8), ở file config/database.php phần redis được thiết lập mặc định sẵn như sau:

'redis' => [

        ......

        'options' => [
            ...
            'prefix' => Str::slug(env('APP_NAME', 'laravel'), '_').'_database_',
        ],

mọi kết nối tới redis đã được thêm tiền tố (prefix) như sau: tên app của bạn ở trong file .env nếu ko có lấy mặc định là laravel thêm _database_

OK đã hiểu, giờ ta chỉ cần quay lại file ChatLayout.vue, sửa lại chút ở created:

created () {
  this.loadMessage()

  Echo.channel('laravel_database_chatroom')
  .listen('MessagePosted', (data) => {
      let message = data.message
      message.user = data.user
      this.list_messages.push(message)
  })
},

quay lại tab Chrome chính và xem kết quả, BOOM, tin nhắn đã tự load lên mà không phải tải lại trang hay làm gì cả, các bạn tự sướng thêm để thấy thêm được nhiều kết quả.

Dữ liệu được truyền qua websocket nên nếu bạn nào muốn xem cội nguồn của việc truyền gửi dữ liệu thì có thể bật tab chrome->network->WS, nếu không thấy có gì thì load lại trang nhé, các bạn sẽ thấy thông tin về websocket và các dữ liệu được truyền qua như thế nào.

V. Bonus

Tiếp theo tới phần này chúng ta sẽ hiển thị số users đang online nhé. Phần này chỉ hiển thị được số users, bao nhiều users chứ không thể lấy được chi tiết từng user (user tên gì, email nào) vì bài này chúng ta dùng public channel, nếu muốn lấy thông tin chi tiết user ta cần dùng Presence Channel (mình sẽ viết vào bài khác nhé). Ok bắt đầu thôi nào 😄

Đầu tiên ta sửa lại file resources/js/app.js một chút ở data như sau:

import laravelEchoServer from '../../laravel-echo-server.json'

data: {
        currentUserLogin: {},
        echoCredentials: {
            appId: laravelEchoServer.clients[0].appId, //  appId in laravel-echo-server.json
			key: laravelEchoServer.clients[0].key // key in laravel-echo-server.json
        }
    }

Tiếp theo ta quay lại file ChatLayout.vue, thêm vào 1 method như sau:

getUsersOnline() {
    axios.get(`${window.location.protocol}//${window.location.hostname}:6001/apps/${this.$root.echoCredentials.appId}/channels/laravel_database_chatroom?auth_key=${this.$root.echoCredentials.key}`)
    .then(response => {
        this.usersOnline = response.data.subscription_count
    })
    .catch(e => console.log(e))
}

Vẫn ở ChatLayout.vue, ta thêm vào data 1 thuộc tính usersOnline để chỉ số users đang online như sau:

data() {
    return {
        message: '',
        list_messages: [],
        csrfToken: '',
        usersOnline: 0
    }
},

Tiếp đó ở mounted ta thêm vào:

setInterval(() => {
    this.getUsersOnline() // lấy số users online mỗi 3 giây (tuỳ chỉnh theo ý muốn)
}, 3000)

Sau đó thêm vào đầu template đoạn code sau để hiển thị nhé:

<div class="users-online">
    <button type="button" class="btn btn-primary">
        Users online: <span class="badge badge-light">{{ usersOnline }}</span>
    </button>
</div>

Css mông má tí cho đẹp chứ nhỉ, thêm vào cuối thẻ style đoạn code sau nhé:

.users-online {
    position: absolute;
    top: 20px;
    left: 50px;
    z-index: 3;
}

Vậy là ổn rồi đó, quay lại trình duyệt check thôi nào: user_online

Thử mở thêm tab mới login với account khác vào xem nhé các bạn 😉

Ở trang github của laravel echo server còn cung cấp thêm 1 số API khác để chúng ta lấy thêm thông tin ngoài số user đang online nữa nhé. Các bạn check ở đây nhé

VI. DEMO

Các bạn có thể xem demo ở đây nhé. Xem xong nhớ đọc phần kết luận bên dưới nhé các bạn 😉

The End

Đến đây mình đã kết thúc bài post này rồi, mong rằng qua bài này các bạn sẽ có thể hiểu được cách xây dựng ứng dụng realtime với Laravel EchoSocket.IO như thế nào từ đó áp dụng vào các dự án thật.

Toàn bộ code có thể được xem ở đây nhé mọi người: Source code

Ở trong bài này mình dùng public channel trong Laravel. Khi dùng public channel: 1 user gửi tin nhắn sẽ được broadcast cho toàn bộ các user khác, và các user khác không cần xác thực (login), vẫn có thể nhận được. Có thể bạn sẽ thắc mắc: "Ô hay thế đoạn login ban đầu có rồi mà", thực ra bài này mình để login vào mục đích để lưu lại tin nhắn của từng user về sau hiển thị lại nếu user logout 😄.

Do đó khi làm thực tế thì thường ta sẽ cần xác thực user trước xem đã login, có quyền được nhận tin nhắn realtime từ user khác hay không,... và khi đó ta cần sử dụng tới Private/Presence channel trong Laravel. Các bạn theo dõi ở bài tiếp của mình nhé

Bài post của mình có thể có những thiếu sót, các bạn có thắc mắc thì cứ comment nhiệt tình nhé.

Cảm ơn các bạn đã theo dõi!

All Rights Reserved