I. Giới thiệu

Chào các bạn, trong bài blog lần trước, mình đã cùng các bạn tìm hiểu về Nest.js Lifecycle (các bạn có thể đọc lại ở đây). Trong bài lần này chúng ta cùng thực hành coding và nâng cao hơn bằng một chủ đề khá thú vị, đó là xây dưng một ứng dụng chat realtime bằng Nest.js.

Trước khi bắt đầu code, chúng ta cùng khái quát lại các công nghệ chúng ta sẽ sử dụng trong ứng dụng nhé.

1.1. Nest.js

Nest.js đang là một trong những framework của Node.js rất mạnh ở thời điểm hiện tại, với khả năng tạo ra những ứng dụng server-side nhanh, với khả năng mở rộng phát triển cực kì lớn. Nó được xây dựng dựa vào những điểm mạnh của các framework khác như Express, Fastify. Nestjs thêm vào một lớp abstraction vào Node.js framework và publish các API cho các developer. Nó cũng cung cấp hệ thống quản lý database mạnh cho PostgreSQL và MySQL. Ngoài ra, nó cũng có thể inject các dependencies vào các lớp trong lifecycle, thậm chí cả Websockets và API Gateways.

1.2. Websocket

Websocket là một giao thức giao tiếp trong máy tính, cung cấp các channel hai chiều, đồng thời tiếp nhận thông tin và truyền thông tin theo hai hướng (full-duplex) thông qua một kết nối TCP. Websocket là các giao thức có trạng thái, có nghĩa là kết nối được thiết lập giữa máy chủ và máy khách sẽ tồn tại trừ khi bị máy chủ hoặc máy khách chấm dứt; khi kết nối WebSocket bị đóng ở một đầu, nó sẽ mở rộng sang đầu kia.

II. Chuẩn bị

Bài blog này vừa chứa lý thuyết và thực hành song song, các bạn có thể vừa đọc vừa code theo mình để dễ nắm bắt. Để việc này được song song, không bị gián đoạn, hãy đảm bản bạn đã cài những thứ sau:

2.1. Khởi tạo Project

Đầu tiên, hãy khởi tạo project Nest.js, chúng ta cùng chạy lệnh sau để khởi tạo project folder:

mkdir chatapp && cd chatapp

Sau đó tải Nest.js CLI với câu lệnh:

npm i -g @nestjs/cli

Khi việc tải CLI đã xong, hãy chạy câu lệnh sau để dựng lên base project của chúng ta:

nest new nest-chat
cd nest-chat

Trong lúc dựng base dự án, trên CLI, ứng dụng sẽ hỏi chúng ta muốn sử dụng package manager nào, tùy vào sở thích của mọi người nhưng mình sẽ sử dụng npm, sau đó hãy chờ để các package cần thiết được tải xuống. Sau khi việc tải này xong, hãy cài đặt WebsocketSocket.io vào dự án của chúng ta. Nest.js đã hỗ trợ 2 package này cho chúng ta, chúng ta có thể tải xuống với câu lệnh sau:

npm i --save @nestjs/websockets @nestjs/platform-socket.io

Sau đó, hãy tạo một Gateway. Về mặt kỹ thuật, Gateway là một nút mạng được sử dụng trong viễn thông nhằm kết nối hai mạng có giao thức truyền thông khác nhau có thể giao tiếp được với nhau. Ở đây nghĩa là kết nối giữa mang web của chúng ta (HTTP/HTTPS) thành Websocket(TCP). Gateway mà Nest hỗ trợ sẵn chúng ta đã tương thích với tất cả các thư viện Websocket một khi mà adapter đã được khởi tạo. Chúng ta có thể cài với lệnh dưới đây:

nest g gateway app

File Gateway sẽ được khởi tạo src/app/app.gateway.ts

import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';

@WebSocketGateway()
export class AppGateway {
  @SubscribeMessage('message')
  handleMessage(client: any, payload: any): string {
    return 'Hello world!';
  }
}

Bây giờ hãy khởi động server của chúng ta bằng cách chạy lệnh bên dưới:

npm run start:dev

2.2. Cài đặt PostgreSQL vào project

Giờ chúng ta sẽ thiết lập Postgres để lưu trữ dữ liệu người dùng vào server. Đầu tiên, chúng ta sẽ sử dụng TypeORM (Object Relational Mapper) để kết nối database với ứng dụng.

Trước hết chúng ta phải tải package TypeORM cho ứng dụng Nest của chúng ta:

npm i --save @nestjs/typeorm typeorm

Ngoài ra chúng ta cũng phải cài thêm driver để nest hỗ trợ lưu và đọc dữ liệu từ postgres

npm install pg --save

Để bắt đầu, chúng ta sẽ tạo database sử dụng các bước dưới đây. Đầu tiên, chuyển sang tài khoản người dùng Postgres của hệ thống.

sudo su - postgres

Sau đó tạo một user mới sử dụng command dưới đây:

createuser --interactive

Tiếp theo tạo 1 database tên là chat:

createdb chat

Vậy là đã xong phần tạo database. Giờ chúng ta sẽ kết nối với database chat chúng ta vừa mới tạo xong. Mở app.module.ts, sau đó add đoạn code dưới đây vào array imports[]:

...
import { TypeOrmModule } from '@nestjs/typeorm';
import { Chat } from './chat.entity';
imports: [
   TypeOrmModule.forRoot({
     type: 'postgres',
     host: 'localhost',
     username: '<USERNAME>',
     password: '<PASSWORD>',
     database: 'chat',
     entities: [Chat],
     synchronize: true,
   }),
   TypeOrmModule.forFeature([Chat]),
 ],
...

Trong đoạn code trên, chúng ta đã kết nối ứng dụng của mình với cơ sở dữ liệu PostgresSQL bằng method TypeOrmModule forRoot() và truyền vào thông tin đăng nhập cơ sở dữ liệu của chúng ta. Thay thế <USERNAME> và <PASSWORD> bằng user và password bạn đã tạo cho cơ sở dữ liệu trước đó.

III. Coding

3.1. Tạo entity Chat

Sau khi chúng ta đã kết nối ứng dụng với cơ sở dữ liệu, hãy tạo một entity tên là Chat để lưu tin nhắn của người dùng. Để làm điều đó, hãy tạo chat.entity.ts trong thư mục src:

import {
 Entity,
 Column,
 PrimaryGeneratedColumn,
 CreateDateColumn,
} from 'typeorm';
 
@Entity()
export class Chat {
 @PrimaryGeneratedColumn('uuid')
 id: number;
 
 @Column()
 email: string;
 
 @Column({ unique: true })
 text: string;
 
 @CreateDateColumn()
 createdAt: Date;
}

Trong đoạn code này, chúng ta đã khai báo các cột trong table của bảng chat, sử dụng decorator @Entity(), chúng ta có các column như id, email, text, createdAt sử dụng các decorator được hỗ trợ bởi typeORM. Vì ở trên file app.module.ts chúng ta có khai báo với 1 option là synchronize: true. Vậy nên ngay khi chúng ta lưu file chat.entity.ts, một table chat đã được tạo ra trong CSDL của chúng ta với đầy đủ các trường chúng ta khai báo. Rất tiện lợi phải không nào.

3.2. Thiết lập Websocket

Giờ chúng ta sẽ thiết lập kết nối Websocket để gửi tin nhắn real-time trong file app.gateway.ts

import {
  SubscribeMessage,
  WebSocketGateway,
  OnGatewayInit,
  WebSocketServer,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';
import { AppService } from '../app.service';
import { Chat } from '../chat.entity';
@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class AppGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  constructor(private appService: AppService) {}

  @WebSocketServer() server: Server;

  @SubscribeMessage('sendMessage')
  async handleSendMessage(client: Socket, payload: Chat): Promise<void> {
    await this.appService.createMessage(payload);
    this.server.emit('recMessage', payload);
  }

  afterInit(server: Server) {
    console.log(server);
    //Do stuffs
  }

  handleDisconnect(client: Socket) {
    console.log(`Disconnected: ${client.id}`);
    //Do stuffs
  }

  handleConnection(client: Socket, ...args: any[]) {
    console.log(`Connected ${client.id}`);
    //Do stuffs
  }
}

Chúng ta sử dụng SubscribeMessage() để lắng nghe event từ phía client, WebSocketGateway() sẽ được sử dụng để cho chúng ta quyền access vào socket.io. chúng ta implement instance của OnGatewayInit, OnGatewayConnection, và OnGatewayDisconnect để có thể biết được trạng thái của ứng dụng của chúng ta để can thiệp vào. Chẳng hạn như sau khi khởi tạo server, sau khi kết nối, sau khi client disconnect,...

Ở trong đoạn code trên, sau khi chúng ta implement Websocket instances, ta tạo constructor và bind AppService để có thể truy cập các phương thức ở trong nó. chúng ta khởi tạo 1 instance từ WebSocketServer decorator. Sau đó, chúng ta tạo handleSendMessage bằng cách sử dụng @SubscribeMessage() và phương thức handleMessage() để gửi dữ liệu đến phía client. Khi một tin nhắn được gửi đến chức năng này từ client, chúng ta sẽ lưu nó vào CSDL và gửi lại tin nhắn cho tất cả người dùng được kết nối ở phía client của chúng ta. Chúng ta cũng có nhiều phương pháp thức khác mà chúng ta có thể can thiệp vào xử lý của nó, chẳng hạn như afterInit, được kích hoạt sau khi client đã kết nối, handleDisconnect, được kích hoạt khi người dùng ngắt kết nối. Phương thức handleConnection bắt đầu khi người dùng tham gia kết nối.

Để cho phép phía client có thể giao tiếp với server, chúng ta sẽ mở CORS bằng cách khởi tạo WebSocketGateway như trên

@WebSocketGateway({
 cors: {
   origin: '*',
 },
})

3.3. Tạo controller và service

Bây giờ, chúng ta sẽ tạo service và controller để lưu cuộc trò chuyện và hiển thị giao diện trang web. Mở tệp app.service.ts và sử dụng đoạn code dưới đây:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Chat } from './chat.entity';
 
@Injectable()
export class AppService {
 constructor(
   @InjectRepository(Chat) private chatRepository: Repository<Chat>,
 ) {}
 async createMessage(chat: Chat): Promise<Chat> {
   return await this.chatRepository.save(chat);
 }
 
 async getMessages(): Promise<Chat[]> {
   return await this.chatRepository.find();
 }
}

Trong đoạn code này chúng ta inject và sử dụng repository của Chat entity. có 2 function là createMessagegetMessages. createMessage dùng để tạo một bản ghi của Chat và getMessages để lấy ra tất cả dữ liệu của Chat mà chúng ta đã lưu.

Sau đó, chúng ta sẽ cập nhật file app.controller.ts như sau:

import { Controller, Render, Get, Res } from '@nestjs/common';
import { AppService } from './app.service';
import { Chat } from './chat.entity';
 
@Controller()
export class AppController {
 constructor(private readonly appService: AppService) {}
 
 @Get('/chat')
 @Render('index')
 Home() {
   return;
 }
 
 @Get('/api/chat')
 async Chat(@Res() res) {
   const messages = await this.appService.getMessages();
   res.json(messages);
 }
}

Trong đoạn code trên, chúng ta sẽ tạo ra 2 routes, 1 route để render ra trang web của chúng ta và một route để làm api lấy dữ liệu chat từ database đổ ra trang web.

3.4. Xây dựng static page

Bây giờ chúng ta sẽ config để ứng dụng render ra trang web tĩnh mà chúng ta sẽ build. Để làm được vậy, chúng ta sẽ render trang web theo SSR (Server Side Rendering). Đầu tiên, trong file main.ts chúng ta sẽ config để get các file tĩnh như sau:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(__dirname, '..', 'static'));
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('ejs');
  await app.listen(3000);
}
bootstrap();

Ở đoạn code trên, chúng ta sẽ config để sử dụng các file từ 2 thư mục staticviews trong thư mục code. Tiếp theo, chúng ta sẽ tạo 2 folder này ngay trong root project, bên cạnh thư mục src. Trong thư mục views, tạo 1 file index.ejs. Ở đây, chúng ta sẽ sử dụng EJS cho nhanh. EJS là một thư viện JavaScript được thiết kế để hỗ trợ tác vụ templating - tạo ra các tệp code HTML dạng mẫu tempalte chờ gắn dữ liệu thực tế - và chuyển đổi các template này trở thành văn bản HTML để trình duyệt web hiển thị.

Tải ejs:

npm install ejs

Trong file index.ejs:

<!DOCTYPE html>
<html lang="en">
 
<head>
 <!-- Required meta tags -->
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 
 <!-- Bootstrap CSS -->
 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
   integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous" />
 
 <title>Let Chat</title>
</head>
 
<body>
 <nav class="navbar navbar-light bg-light">
   <div class="container-fluid">
     <a class="navbar-brand">Lets Chat</a>
   </div>
 </nav>
 <div class="container">
   <div class="mb-3 mt-3">
     <ul style="list-style: none" id="data-container"></ul>
   </div>
   <div class="mb-3 mt-4">
     <input class="form-control" id="email" rows="3" placeholder="Your Email" />
   </div>
   <div class="mb-3 mt-4">
     <input class="form-control" id="exampleFormControlTextarea1" rows="3" placeholder="Say something..." />
   </div>
 </div>
 <script src="https://cdn.socket.io/4.3.2/socket.io.min.js"
   integrity="sha384-KAZ4DtjNhLChOB/hxXuKqhMLYvx3b5MlT55xPEiNmREKRzeEm+RVPlTnAn0ajQNs"
   crossorigin="anonymous"></script>
 <script src="app.js"></script>
 <!-- Option 1: Bootstrap Bundle with Popper -->
 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
   integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
   crossorigin="anonymous"></script>
</body>
</html>

Với mục đích demo, nên phần template này mình sẽ dựng nhanh bằng cách sử dụng Bootstrap để thêm các styles có sẵn. Tiếp đến, chúng ta sẽ tạo 2 field input và một list để hiển thị các tin nhắn của user. Đồng thời, chúng ta sẽ tạo file app.js và link nó với socket.io. Tạo file app.js:

// /static/app.js

const socket = io('http://localhost:3000');
const msgBox = document.getElementById('exampleFormControlTextarea1');
const msgCont = document.getElementById('data-container');
const email = document.getElementById('email');
 
//get old messages from the server
const messages = [];
function getMessages() {
 fetch('http://localhost:3002/api/chat')
   .then((response) => response.json())
   .then((data) => {
     loadDate(data);
     data.forEach((el) => {
       messages.push(el);
     });
   })
   .catch((err) => console.error(err));
}
getMessages();
 
//When a user press the enter key,send message.
msgBox.addEventListener('keydown', (e) => {
 if (e.keyCode === 13) {
   sendMessage({ email: email.value, text: e.target.value });
   e.target.value = '';
 }
});
 
//Display messages to the users
function loadDate(data) {
 let messages = '';
 data.map((message) => {
   messages += ` <li class="bg-primary p-2 rounded mb-2 text-light">
      <span class="fw-bolder">${message.email}</span>
      ${message.text}
    </li>`;
 });
 msgCont.innerHTML = messages;
}
 
//socket.io
//emit sendMessage event to send message
function sendMessage(message) {
 socket.emit('sendMessage', message);
}
//Listen to recMessage event to get the messages sent by users
socket.on('recMessage', (message) => {
 messages.push(message);
 loadDate(messages);
})

Trong đoạn code trên, chúng ta đã tạo ra socket.io instance và lắng nghe các event từ server để gửi và nhận tin nhắn. Mặc định, chúng ta sẽ muốn các đoạn chat cũ sẽ được hiển thị khi một user tham gia vào đoạn chat.

IV. Thành quả

Nếu chúng ta truy cập vào localhost:3000/chat, trang web của chúng ta sẽ hiển thị như sau:

Mở 2 trình duyệt với 2 email khác nhau và chat với nhau thôi:

Ngoài ra, khi check console, các bạn có thể thấy được log khi 1 user kết nối hay ngắt kết nối với server (mà chúng ta đã xử lý trong handleConnectionhandleDisconnect).

Dữ liệu chat được lưu trữ vào database:

V. Kết Luận

Vậy là trên đây, mình đã hướng dẫn các bạn tạo một ứng dụng chat real-time sử dụng Nestjs, Websocket và Socket.io. Các bạn có thể tham khảo source code đầy đủ tại đây. Hy vọng các bạn đã thực hành thành công và hiểu được cơ bản những gì mình giải thích. Ứng dụng còn sơ sài và đơn giản, các bạn hãy cải thiện nó vào tạo ra các ứng dụng khác phức tạp hơn nhé. Cảm ơn các bạn đã đọc.