Giới thiệu
Trước khi bắt đầu, nếu bạn chưa đọc bài viết khác của tôi về cấu trúc dự án, hãy làm điều đó. Điều này rất quan trọng trong hành trình học tập NestJS của bạn.
Vấn đề Vòng Tròn
Nhiều nhóm phát triển, đặc biệt là những người xây dựng ứng dụng CRUD, thường gặp phải lỗi phụ thuộc vòng tròn. Thông điệp bí ẩn đó báo hiệu một vấn đề sâu xa hơn trong kiến trúc ứng dụng của bạn. Phụ thuộc vòng tròn là một mùi hôi mã vì nó khiến các module của bạn bị ràng buộc chặt chẽ, khó khăn trong việc kiểm thử và tái sử dụng, và khó khăn trong việc tái cấu trúc.
Vậy tại sao điều này lại xảy ra với nhiều người như vậy? Nguyên nhân phổ biến nhất là một sai lầm trong mô hình tư duy.
Sai Lầm Trong Mô Hình Tư Duy: Nhầm Lẫn Dữ Liệu Trong Cơ Sở Dữ Liệu Với "Công Việc" Của Module ⚠️
Đây là lỗi trung tâm dẫn đến hầu hết các phụ thuộc vòng tròn trong NestJS. Các nhà phát triển thường cố gắng mô hình hóa các mối quan hệ cơ sở dữ liệu trực tiếp trong các module của họ.
- Mối quan hệ cơ sở dữ liệu có thể hai chiều: Một
Authorcó thể có nhiềuBooks, và mộtBookcó thể có mộtAuthor. Đây là một mối quan hệ hai chiều mà cơ sở dữ liệu và ORM được thiết kế để xử lý dễ dàng. - Phụ thuộc module phải là một chiều: Một
AuthorModulecó thể cung cấp mộtAuthorServicemàBookModuletiêu thụ. Nhưng nếuBookModulecố gắng nhập một cái gì đó từAuthorModule— vàAuthorModuleđã phụ thuộc vàoBookModule— bạn đã tạo ra một vòng lặp. Tôi chắc chắn rằng mọi người đều đã gặp phải điều này.
Các module của ứng dụng của bạn không phải là một bức gương của cơ sở dữ liệu của bạn. Mục đích của chúng là để bao bọc chức năng, và các phụ thuộc của chúng nên phản ánh dòng chảy của logic ứng dụng, không phải cấu trúc của dữ liệu của bạn.
Mô Hình Tư Duy Đúng: Các Module Như Một Thành Phố Với Đường Một Chiều 🏙️
Hãy lấy ứng dụng của bạn làm phép ẩn dụ cho một thành phố. Nhưng, thay vì nghĩ về thành phố của bạn với những con đường hai chiều, hãy tưởng tượng chúng như một thành phố với đường một chiều nghiêm ngặt. Mỗi module là một khu phố trong thành phố (ví dụ: UserModule, AuthModule, AuthorModule, BookModule, v.v.), và các phụ thuộc là các con đường. Một chiếc xe có thể di chuyển từ khu phố BookModule đến AuthorModule để lấy thông tin tác giả, nhưng một chiếc xe từ AuthorModule không thể quay lại trên con đường đó.
Những gì bạn đang hình dung với các phụ thuộc module của bạn là một đồ thị có hướng không chu trình (DAG):
- Có hướng: Các mối quan hệ chảy theo một hướng duy nhất.
Aphụ thuộc vàoB, không phải ngược lại. - Không chu trình: Không có vòng lặp. Bạn không thể bắt đầu từ
A, theo dõi các phụ thuộc và quay lạiA.
Phép Ẩn Dụ Vận Chuyển: Lộ Trình Giao Hàng Của Bạn Trong Thành Phố
Đây là nơi ứng dụng NestJS của bạn trở thành một dịch vụ giao hàng. Hãy nghĩ về một yêu cầu đến ứng dụng của bạn như một vận chuyển hàng bắt đầu một lộ trình giao hàng. Người vận chuyển vào thành phố và di chuyển xuống các con đường một chiều, ghé thăm từng module để thực hiện một nhiệm vụ. Quy tắc chính là người vận chuyển không bao giờ quay lại và trở về một ngôi nhà mà họ đã ghé thăm.
Toàn bộ "lộ trình giao hàng" hình thành đồ thị có hướng không chu trình. Người vận chuyển bắt đầu từ đầu (AppModule), đi qua các phụ thuộc, và ở cuối lộ trình, module cuối cùng gửi lại một kết quả, xác nhận "giao hàng đã hoàn tất." Mô hình này nhắc nhở chúng ta rằng dòng chảy thực thi luôn phải đi về phía trước và có mục đích, không bao giờ quay lại chính nó.
Quy Tắc Thực Tiễn Để Tránh Vòng Tròn
-
Xác Định Một Cấu Trúc Rõ Ràng: Sắp xếp các module của bạn thành các lớp. Các module cốt lõi nên ở dưới cùng, các module cụ thể cho tính năng ở giữa, và các module điểm vào ở trên cùng. Các phụ thuộc chỉ nên chảy xuống cấu trúc. Nguyên tắc này là một nền tảng của các mẫu kiến trúc như Kiến trúc Sạch, được Robert C. Martin ("Uncle Bob") phổ biến.
-
Tách Logic Chia Sẻ: Nếu hai module cần cùng một tiện ích chia sẻ, hãy tạo một
UtilModuletách biệt thứ ba mà cả hai có thể nhập. Đây là quy tắc "tách các mối quan tâm chung". Những thứ này vào một module "chung" hoặc "chia sẻ". -
Sử Dụng Một Module Cấp Cao Hơn Để Tổ Chức: Thay vì có hai module phụ thuộc trực tiếp vào nhau, hãy tạo một module cấp cao hơn phụ thuộc vào cả hai. Module này đóng vai trò như một "người trung gian" tổ chức dòng chảy dữ liệu mà không tạo ra phụ thuộc vòng tròn. Loại module này nên "thực hiện các tác vụ" chứ không đại diện cho một mô hình dữ liệu cụ thể.
Một Ví Dụ Cụ Thể: Đếm Số Sách Của Một Tác Giả
Hãy sử dụng ví dụ Author và Book. Chúng ta cần lấy số lượng sách mà một tác giả đã viết.
AuthorsModule: Chịu trách nhiệm cho mọi thứ liên quan đến tác giả.BooksModule: Chịu trách nhiệm cho mọi thứ liên quan đến sách.
Thay vì để AuthorsModule nhập BooksModule (để lấy số lượng sách) và BooksModule nhập AuthorsModule (để tìm thông tin tác giả), chúng ta giới thiệu một module cấp cao hơn mới: PublishingModule. Module này đóng vai trò như "người vận chuyển hàng," tổ chức yêu cầu.
typescript
import { Module } from '@nestjs/common';
import { AuthorsService } from './authors.service';
@Module({
providers: [AuthorsService],
exports: [AuthorsService],
})
export class AuthorsModule {}
typescript
import { Module } from '@nestjs/common';
import { BooksService } from './books.service';
@Module({
providers: [BooksService],
exports: [BooksService],
})
export class BooksModule {}
typescript
import { Module } from '@nestjs/common';
import { AuthorsModule } from '../authors/authors.module';
import { BooksModule } from '../books/books.module';
import { PublishingService } from './publishing.service';
import { PublishingResolver } from './publishing.resolver';
@Module({
imports: [
AuthorsModule,
BooksModule,
],
providers: [PublishingService, PublishingResolver],
})
export class PublishingModule {}
PublishingModule mô phỏng đúng lộ trình của người vận chuyển hàng. Nó tổ chức quá trình bằng cách ghé thăm AuthorsModule để lấy tác giả và sau đó là BooksModule để lấy sách, tất cả đều duy trì dòng chảy phụ thuộc một chiều. AuthorsModule và BooksModule không biết gì về PublishingModule và vẫn giữ nguyên sự tách biệt và có thể tái sử dụng.
Đẩy Xa Hơn: Trừu Tượng Với Một Giao Diện
Ví dụ cụ thể ở trên là một điểm khởi đầu tuyệt vời, nhưng nếu ứng dụng của chúng ta phát triển? Nếu chúng ta thêm các loại nội dung mới như Blogs hoặc Articles? Chúng ta sẽ phải cập nhật PublishingModule để nhập BlogsModule, ArticlesModule, và như vậy, làm cho module trở nên lộn xộn và khó quản lý.
Đây là lúc sức mạnh của trừu tượng phát huy tác dụng. Thay vì phụ thuộc vào các thực thi cụ thể, chúng ta có thể dựa vào một hợp đồng chung, hay giao diện. Điều này làm cho mã của chúng ta linh hoạt và có khả năng mở rộng hơn.
1. Định Nghĩa Giao Diện
Đầu tiên, chúng ta tạo một giao diện xác định các phương thức mà một module nội dung có thể xuất ra. Chúng ta cũng sẽ thêm một phương thức để xác định loại nội dung.
typescript
export interface IPublishable {
getPublishableType(): string;
getContentCountByAuthorId(authorId: string): Promise<number>;
}
2. Thực Hiện và Tổ Chức Với Trừu Tượng
Bây giờ, cả BooksModule và một BlogsModule giả định sẽ thực hiện giao diện này. PublishingModule không còn cần nhập từng module nội dung nữa. Thay vào đó, nó có thể phụ thuộc vào một danh sách các providers mà tất cả đều thỏa mãn giao diện IPublishable. Container tiêm phụ thuộc của NestJS sau đó có thể cung cấp một mảng tất cả các dịch vụ phù hợp với token này.
typescript
import { Injectable } from '@nestjs/common';
import { IPublishable } from '../content/interfaces/publishable.interface';
@Injectable()
export class BooksService implements IPublishable {
getPublishableType(): string {
return 'book';
}
getContentCountByAuthorId(authorId: string): Promise<number> {
return Promise.resolve(10); // Ví dụ
}
}
typescript
import { Module } from '@nestjs/common';
import { BooksService } from './books.service';
@Module({
providers: [
BooksService,
{
provide: 'IPublishable',
useExisting: BooksService,
},
],
exports: ['IPublishable'],
})
export class BooksModule {}
typescript
import { Injectable, Inject } from '@nestjs/common';
import { AuthorsService } from '../authors/authors.service';
import { IPublishable } from '../content/interfaces/publishable.interface';
@Injectable()
export class PublishingService {
constructor(
private readonly authorsService: AuthorsService,
@Inject('IPublishable')
private readonly publishableServices: IPublishable[],
) {}
async getAuthorTotalContentCount(authorId: string): Promise<number> {
// Logic như trước
}
async getAuthorCountByPublishableType(authorId: string, type: string): Promise<number> {
const service = this.publishableServices.find(s => s.getPublishableType() === type);
if (!service) {
throw new Error(`Không tìm thấy dịch vụ cho loại xuất bản: ${type}`);
}
return service.getContentCountByAuthorId(authorId);
}
}
Sức mạnh thực sự của phép ẩn dụ đường một chiều. PublishingService không quan tâm nội dung là sách, blog, hay một loại nội dung mới mà chúng ta tạo ra tuần tới. Nó chỉ quan tâm đến việc có thể giao tiếp với một dịch vụ thỏa mãn hợp đồng IPublishable, duy trì một kiến trúc sạch sẽ và tách biệt. Phương thức mới này cho thấy cách mà người vận chuyển có thể sử dụng một khóa ('book') để bỏ qua tất cả các module khác và đi thẳng đến module mà nó cần, tất cả trong khi tuân theo các con đường một chiều.
Kết Luận
Lần tới khi bạn xây dựng một module mới, hãy dừng lại một chút. Thay vì nghĩ về lấy dữ liệu ("Tôi cần lấy bài viết cho người dùng này"), hãy nghĩ về quy trình đang được thực hiện ("Tôi cần lấy tất cả nội dung được xuất bản bởi tác giả này"). Sự chuyển dịch nhẹ nhàng nhưng mạnh mẽ trong quan điểm này, kết hợp với mô hình đường một chiều, sẽ dẫn bạn đến một kiến trúc sạch sẽ, dễ duy trì và mở rộng. Và bạn sẽ cuối cùng tránh được vấn đề đau đầu của phụ thuộc vòng tròn.
Bạn làm thế nào để tránh các phụ thuộc vòng tròn? Hoặc bạn làm thế nào để làm cho các module của mình ít phụ thuộc hơn? Hãy cho tôi biết trong các bình luận dưới đây.