1. NestJS là gì?
Nest hay NestJS là một framework để xây dựng các ứng dụng server-side mạnh mẽ dựa trên nền tảng Nodejs. NestJS được xây dựng và hỗ trợ ngôn ngữ TypeScript (vẫn cho phép lập trình viên code bằng JavaScript thuần), và kết hợp các tính năng của OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).
Sâu bên dưới thì NestJS vẫn sử dụng các framework HTTP Server đơn giản hơn như Express và Fastify. NestJS cung cấp một level trừu tượng hơn phía bên trên các framework này, nhưng cũng cung cấp các API trực tiếp để lập trình viên có thể sử dụng các tính năng, cũng như là các module bên thứ 3 được hỗ trợ cho các nền tảng Express và Fastify.
2. Tại sao nên sử dụng NestJS?
Đối với một lập trình viên Nodejs, việc lựa chọn đúng framework cho project rất quan trọng. Trong khi Expressjs được sử dụng để xây dựng ứng dụng web trong nhiều năm thì NestJS đã xuất hiện và mang lại các lợi ích và tính năng nổi trội hơn:
- Ngôn ngữ mạnh về kiểu dữ liệu: NestJS được xây dựng trên nền TypeScript, một phiên bản hỗ trợ kiểu dữ liệu của JavaScript. Sử dụng Typescript khi viết code giúp code sạch hơn và dễ xử lý khi có lỗi.
- Kiến trúc modular: NestJS được xây dựng trên kiến trúc modular giúp chúng ta dễ tổ chức và phát triển mã nguồn trên quy mô lớn hơn. Kiến trúc này bao gồm các khối tách biệt nhau như controller, provider, và module, giúp cho việc quản lý dễ dàng hơn. Mình sẽ nói kĩ hơn về các thành phần này ở phần sau của bài viết nhé.
- Dependency Injection: NestJS sử dụng Dependency Injection (DI) để quản lý luồng của các phụ thuộc giữ các module và component. Nó cho phép ứng dụng dễ kiểm thử và bảo trì hơn, do sự độc lập giữa các component với nhau. Cụ thể độc lập như thế nào thì cùng đợi đến phần sau nhé.
- Built-In Validation: NestJS được hỗ trợ sẵn tính năng validate dữ liệu đầu vào - thứ đặc biệt quan trong khi xây dựng các API. Framework này giúp định nghĩa và thực thi các validation rule một cách dễ dàng hơn, giảm đi các sai sót và lỗi không đáng có.
- Hỗ trợ các tính năng nâng cao: NestJS hỗ trợ hàng loạt các tính năng được tích hợp sẵn, ví dụ như WebSocket, GraphQL, và Microservices. Điều này giúp đơn giản hóa việc xây dựng các ứng dụng yêu cầu tính năng realtime hay kiến trúc microservices.
- Cộng đồng hỗ trợ mạnh mẽ: NestJS có một cộng đồng các lập trình viên đóng góp cho framework thường xuyên. Điều này sẽ giúp NestJS luôn được cập nhật và sửa lỗi, trở thành 1 framework đáng tin cậy.
3. Nhược điểm của NestJS
Việc hỗ trợ nhiều tính năng mạnh mẽ không có nghĩa là NestJS không có bất kỳ điểm yếu nào.
- Sự phụ thuộc vòng lặp (circular dependencies): Đây là một vấn đề phổ biến mà hầu hết các dự án NestJS sẽ gặp phải. Vấn đề này rất rắc rối và nó có thể làm chậm đi quá trình phát triển của phần mềm. May mắn là, một bài báo gần đây của Trilon đã đưa ra một công cụ tên là Madge, giúp xác định sớm circular dependencies.
- Logs bị ẩn khi ứng dụng khởi động: Đây cũng là một vấn đề khá phổ biến. Điều này có thể khiến các lập trình viên khó debug khi có lỗi xảy ra. Để giải quyết thì họ thường vô hiệu hóa việc dừng ứng dụng khi gặp lỗi và gửi lại thông báo lỗi.
- Khó khăn trong kiểm thử đơn vị (unit test): Unit test được tích hợp sâu vào framework. Việc kiểm thử ở mức đơn vị nhỏ thường gây ra nhiều boilerplate code. Hơn nữa, việc viết test có thể gây khó khăn với một số lập trình viên, vì họ phải hiểu được cơ chế hoạt động của dependency injection tree trong NestJS.
4. Các khái niệm quan trọng trong NestJS
Ở phần trước chúng ta đã biết, NestJS được tổ chức theo kiến trúc chia module, và sử dụng Dependency Injection để quản lý các phần phụ thuộc giữa các thành phần trong ứng dụng.
4.1. Dependency Injection (DI)
Dependency Injection (DI) là kiểu thiết kế phần mềm, mà trong đó các phụ thuộc (dependencies) của một đối tượng được cung cấp từ bên ngoài - thay vì đối tượng tự tạo ra chúng.
class Repository {
getData() {
return "Data from repository";
}
}
class Service {
private repository: Repository;
constructor() {
this.repository = new Repository();
}
getServiceData() {
return this.repository.getData();
}
}
const service = new Service();
console.log(service.getServiceData());
Ở đây ta thấy lớp Service phụ thuộc vào lớp Repository. Theo cách làm thông thường thì ta sẽ khởi tạo Repository bên trong Service luôn. Nhưng cách này sẽ tạo ra tight coupling, gây ra sự khó bảo trì trong tương lai. Chẳng hạn khi Service muốn thay đổi Repository thì buộc phải thay đổi trực tiếp mã nguồn của lớp Service.
Dependency Injection sẽ giải quyết vấn đề này bằng việc tạo ra loose coupling giữa các lớp, không còn sự phụ thuộc hoàn toàn như trường hợp trên. DI có 3 loại chính là Constructor Injection, Setter Injection và Interface Injection.
- Với Constructor Injection thì dependency được “tiêm” vào class thông qua constructor của lớp đó.
class Repository {
getData() {
return "Data from repository";
}
}
class Service {
constructor(private repository: Repository) {}
getServiceData() {
return this.repository.getData();
}
}
const repository = new Repository();
const service = new Service(repository);
console.log(service.getServiceData());
- Với Property Injection thì dependency được “tiêm” vào thông qua setter của lớp.
class Repository {
getData() {
return "Data from repository";
}
}
class Service {
private repository!: Repository;
setRepository(repository: Repository) {
this.repository = repository;
}
getServiceData() {
return this.repository.getData();
}
}
const repository = new Repository();
const service = new Service();
service.setRepository(repository);
console.log(service.getServiceData());
- Interface Injection tương đối giống Constructor Injection và Setter Injection nhưng sử dụng các interface để kết nối, tạo ra sự linh hoạt hơn nhưng yêu cầu setup phức tạp hơn 2 loại trên.
interface RepositoryInterface {
getData(): string;
}
class Repository implements RepositoryInterface {
getData() {
return "Data from repository";
}
}
interface ServiceInterface {
setRepository(repository: RepositoryInterface): void;
getServiceData(): string;
}
class Service implements ServiceInterface {
private repository!: RepositoryInterface;
setRepository(repository: RepositoryInterface) {
this.repository = repository;
}
getServiceData() {
return this.repository.getData();
}
}
const repository = new Repository();
const service = new Service();
service.setRepository(repository);
console.log(service.getServiceData());
4.2. Decorator
Decorator là một loại khai báo đặt biệt có thể gắn vào các khai báo lớp, phương thức, accessor (getter, setter), thuộc tính, hoặc tham số. Decorators sử dụng dạng @expression, trong đó expression phải được đánh giá là một hàm sẽ được gọi tại thời điểm chạy với thông tin về khai báo được trang trí.
Decorator được hỗ trợ ở ngôn ngữ TypeScript và ES6.
Để bật tính năng hỗ trợ thử nghiệm cho decorators, chúng ta cần bật tùy chọn biên dịch experimentalDecorators bằng cách sử dụng dòng lệnh:
tsc --target ES5 –experimentalDecorators
hoặc thêm vào file tsconfig.json nội dung sau:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
Ví dụ ta có lớp User với phương thức greet():
class User {
constructor(private name: string, private age: number) {}
greet() {
console.log('start: greet')
console.log(`Hello, my name is ${this.name}.`);
console.log('end: greet')
}
printAge() {
console.log('start: printAge')
console.log(`I am ${this.age} years old`);
console.log('end: printAge')
}
}
const user = new User("Duong", 21);
user.greet();
user.printAge();
Kết quả sẽ là:
// start: greet
// Hello, my name is Duong.
// end: greet
// start: printAge
// I am 21 years old
// end: printAge
Đoạn code trên ghi log đánh dấu thời điểm gọi và kết thúc khi gọi hàm greet() và printAge(), liệu chúng ta có cách nào để tái sử dụng đoạn code được lặp lại này không?
Câu trả lời là có. Và ta sẽ dùng decorator.
Đầu tiên tạo một decorator:
function logger(originalMethod: any, _context: any) {
function replacementMethod(this: any, ...args: any[]) {
console.log("start:", originalMethod.name);
const result = originalMethod.call(this, ...args);
console.log("end:", originalMethod.name);
return result;
}
return replacementMethod;
}
Tiếp theo là decorate nó vào phương thức cần ghi log:
class User {
constructor(private name: string, private age: number) {}
@logger
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
@logger
printAge() {
console.log(`I am ${this.age} years old`);
}
}
const user = new User("Duong", 21);
user.greet();
user.printAge();
Và đây là kết quả:
start: greet
Hello, my name is Duong.
end: greet
start: printAge
I am 21 years old
end: printAge
4.3. Module
Module chính là thành phần mà NestJS tổ chức cấu trúc một ứng dụng. Một module là một lớp được chú thích bằng decorator @Module().
![Sơ đồ kiến trúc module của NestJS - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-module.png)
Hệ thống các module của một ứng dụng NestJS được tổ chức dưới dạng đồ thị và mỗi ứng dụng có ít nhất một module, và đó là module gốc (root module).
Decorator @Module nhận vào một object. Để mô tả cho object này ta có thể dùng các thuộc tính sau:
- providers: providers (ví dụ như các service) là các lớp mà NestJS injector sẽ instance hóa (tạo ra object) và được chia sẻ trong phạm vi module hiện tại. Các instance có thể được inject vào các component khác của module, ví dụ như controllers, thể hiện kỹ thuật Dependency Injection trong NestJS.
- controllers: là các controllers có trong module.
- imports: là danh sách các module được module hiện tại import vào.exports: là tập hợp các providers được export ra để các module khác có thể sử dụng khi import module này.
Lưu ý rằng các module được mặc định là đóng gói (encapsulate) các provider. Có nghĩa là chúng ta không thể inject các provider của module A vào module B mà không export provider ra từ module A và được module B import vào.
Để ví dụ cho module ta có một đoạn code sau trong file cats/cats.module.ts:
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}
Ta có thể thấy CatsController và CatsSerivce cùng chung một domain, nên ta có thể gói chúng vào chung một module. Điều này giúp cho code được tổ chức chặt chẽ và tạo ra ranh giới rõ ràng giữa các phần của ứng dụng với nhau. Giúp chương trình tuân thủ theo nguyên tắc SOLID, và chúng ta có thể quản lý tốt source code ngay cả khi kích thước của ứng dụng hay đội nhóm tăng lên.
4.4. Controller và Routes
Controller
Controler chịu trách nhiệm quản lý các request được gửi đến và gửi đi các response cho client.
![Sơ đồ kiến trúc Controller của NestJS - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-controller.png)
Cơ chế routing của NestJS sẽ quyết định một request nhận được sẽ đi đến controller nào. Thông thường, mỗi controller sẽ có nhiều hơn một route, mỗi route sẽ thực hiện các tác vụ khác nhau.
Ta có thể dùng lệnh sau để tạo ra một controller:
nest g controller [name]
Controller sẽ được thể hiện dưới dạng lớp và dùng decorator @Controler()
import { Controller, Get } from '@nestjs/common';
@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}
Routes
Ngoài ra chúng ta cũng cần định nghĩa một tiền tố đường dẫn, ở đây là cats. Việc sử dụng tiền tố trong decorator của lớp sẽ cho phép chúng ta nhóm một tập hợp các route liên quan và giảm sự lặp đi lặp lại khi thêm “cats” và từng route của controller.
Chúng ta cùng đi sâu vào phương thức findAll(). Phương thức này sử dụng decorator @Get() giúp cho Nest biết rằng handler này nhận vào các request có phương thức GET.
Decorator của phương thức trong controller cũng có thể nhận vào một tham số tùy chọn là đường dẫn của handler tương ứng với phương thức đó. Ở ví dụ trên thì decorator @Get() của phương thức findAll() không có đối số nào. Nhưng ta hoàn toàn có thể thêm một đối số, chẳng hạn như @Get('breed'). Sau cùng thì NestJS sẽ tạo ra một handler tương ứng với route findAll(), nhận vào các request có phương thức là GET và route path là /cats. Tương tự với @Get('breed') thì route path sẽ là /cats/breeds.
4.5. Provider và Service
Providers
Providers là một khái niệm căn bản trong NestJS. Nhiều lớp cơ bản của NestJS có thể được coi là một provider như services, repositories, factories, helpers, ....
Ý tưởng chính của một provider là nó có thể được inject như một dependency. Có nghĩa là các đối tượng có thể phụ thuộc lẫn nhau, và nhiệm vụ "kết nối" các đối tượng này được ủy thác cho NestJS runtime.
![Sơ đồ kiến trúc Provider của NestJS - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-component.png)
Trong phần trước thì ta đã có một controller là CatsController. Các controller sẽ chỉ có nhiệm vụ nhận các request và gửi đi các response. Các xử lý phức tạp hơn liên quan đến nghiệp vụ sẽ được đẩy cho các provider. Provider đơn giản là các lớp JavaScript thuần được khai báo như một provider bên trong một module.
Decorator được sử dụng để khai báo Service là @Injectable.
Service
Service sẽ chịu trách nhiệm cho việc lưu trữ và truy xuất dữ liệu, được sử dụng bởi controller, và nó cần được inject vào controller. Điều này làm cho service trở thành một provider. Ta cùng bắt đầu với một service tên là CatsService.
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';
@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];
findAll(): Cat[] {
return this.cats;
}
}
CatsService là một lớp cơ bản với 1 thuộc tính và 2 phương thức. Điều đặt biệt nằm ở decorator @Injectable(). Decorator này giúp khai báo CatsService như một class có thể được quản lý bởi IoC container. Ngoài ra ví dụ trên còn sử dụng interface Cat.
export interface Cat {
name: string;
age: number;
breed: string;
}
Ta đã có một service để truy xuất toàn bộ mèo. Bây giờ hãy sử dụng nó trong CatsController.
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';
@Controller('cats')
export class CatsController {
constructor(private catsService: CatsService) {}
@Post()
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
@Get()
async findAll(): Promise<Cat[]> {
return this.catsService.findAll();
}
}
CatsService được inject vào CatsController thông qua constructor của controller. Đây là Contructor Injection mà ta đã tìm hiểu ở các phần trước. Để Constructor Injection được thực thi thì ta cần khai báo controller và provider trong file app.module.ts.
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}
Và đây là cấu trúc thư mục:
![Cấu trúc thư mục chương trình NestJS đơn giản - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-cau-truc-thu-muc.png)
5. Viết API đầu tiên bằng NestJS?
Hiểu được cơ bản về các khái niệm và cách hoạt động của NestJS rồi, bây giờ ta cùng bắt tay vào xây dựng một API đơn giản với 2 chức năng chính là tạo và lấy ra toàn bộ user nhé.
Trước khi bắt đầu thì hãy chắc chắn rằng bạn đã cài Node.js vào máy.
Bước 1: Khởi tạo project
Đầu tiên ta cần cài Nest CLI để sử dụng các lệnh của NestJS:
Tiếp theo là tạo project mới:
nest new introduce-nestjs
Bạn sẽ được hỏi “Which package manager (npm, yarn, pnpm) to use?”. Hãy chọn package manager mà bạn đang dùng nhé. Ở đây thì mình sẽ dùng npm.
Mở project lên, sau khi khởi tạo thì ta sẽ được cấu trúc folder như thế này:
![Cấu trúc thư mục sau khi khởi tạo chương trình NestJS - NestJS là gì](https://statics.cdn.200lab.io/2024/07/Picture1.png)
Bước 2: Tạo module users
NestJS cung cấp lệnh nest g res [resource name] để tạo ra một CRUD resource. Ta dùng nó để tạo ra module users bao gồm các route mặc định gồm thêm, xem, sửa và xóa users:
nest g res users
![Cấu trúc thư mục của folder users - NestJS là gì](https://statics.cdn.200lab.io/2024/07/Picture2.png)
Bước 3: Định nghĩa các Entity và DTO
Định nghĩa User trong file user.entity.ts:
export class User {
id: number;
name: string;
age: number;
}
Định nghĩa UserDTO trong file create-user.dto.ts:
export class CreateUserDto {
name: string;
age: number;
}
Bước 4: Thêm Controller module
Đây là code trong file users.controller.ts. Ta giữ lại 2 route là create() và findAll():
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
findAll() {
return this.usersService.findAll();
}
}
File này định nghĩa controller UsersController với các phương thức:
- create() được map với các request có dạng POST /users.
- findAll() được map với các request có dạng GET /usersDecorator @Body sẽ giúp ta lấy được request body thông quan DTO là createUserDto.
Decorator @Body sẽ giúp ta lấy được request body thông quan DTO là createUserDto.
Bước 5: Thêm Service cho module
Tiếp theo định nghĩa UsersService trong file users.service.ts, đây là nơi thực hiện logic thêm và lấy ra tất cả user.
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
private users: User[] = [];
create(createUserDto: CreateUserDto) {
const user = new User();
// id hiện tại bằng độ dài của mảng users
user.id = this.users.length;
user.name = createUserDto.name;
user.age = createUserDto.age;
this.users.push(user);
return user;
}
findAll() {
return this.users;
}
}
Lưu ý: Ta cần import UsersModule vào AppModule, nhưng khi ta tạo module bằng lệnh nest g res users thì trong NestJS đã tự động import module user rồi nên ta không cần config thêm.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Bước 6: Chạy và demo
Khởi chạy API bằng lệnh sau:
npm start
Port mặc định sẽ là 3000. Ta sẽ cùng thử với Postman.
- Tạo user mới:
![Call API để tạo user mới - 1 - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-create-user-1.png)
![Call API để tạo user mới - 2 - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-create-user-2.png)
- Lấy tất cả users:
![Call API để tạo lấy toàn bộ users - NestJS là gì](https://statics.cdn.200lab.io/2024/07/nestjs-la-gi-get-all-users.png)
Vậy là API của chúng ta đã hoạt động thành công rồi. Source code bạn có thể lấy ở đây nhé.
6. Kết luận
Như vậy thì các bạn đã đi hết các nội dung mà mình muốn giới thiệu về NestJS. Qua bài viết này chúng ta đã viết được NestJS là gì, tìm hiểu được ưu nhược điểm của NestJS, hiểu được các khái niệm cơ bản nhất trong framework này và cùng nhau xây dựng một API đơn giản.
NestJS là một giải pháp mới trong lĩnh vực backend, với tập hợp các tính năng nhằm giúp lập trình viên xây dựng và triển khai các ứng dụng enterprise đáp ứng được nguyên tắc SOLID. Đây là một framework xứng đáng để lựa chọn cho project của bạn.
Bài viết liên quan
![Phan Thanh Dương](https://statics.cdn.200lab.io/2024/07/duong-edited.jpg)
Phan Thanh Dương
It's not about how well you did in life... it's about how hard you tried.
follow me :
Bài viết liên quan
Functional Programming là gì? Giải pháp cho Hệ thống đa luồng và Xử lý song song
Sep 06, 2024 • 6 min read
ESLint là gì? Hướng dẫn cấu hình ESLint cho dự án Typescript
Sep 04, 2024 • 11 min read
Hướng dẫn TypeScript Syntax cơ bản cho người mới - Phần 2
Sep 04, 2024 • 16 min read
Jest là gì? Hướng dẫn cấu hình Jest với Typescript
Sep 02, 2024 • 9 min read
Amazon S3: Giải pháp lưu trữ Đám mây An toàn và Linh hoạt
Sep 02, 2024 • 12 min read
Rust là gì? Ngôn ngữ lập trình Bảo mật và Hiệu suất cao
Sep 01, 2024 • 11 min read